How to work on this repo: how it is put together, how to run it, and the exact
sequence of files a change touches when you add or change a component, a
pattern, or a doc, and contribute it back. README.md sells the repo,
CLAUDE.md primes an agent's judgment, and this doc is the mechanical how-to
underneath both.
1. What this repo is#
versable-builder is a shared UI system three things are built from: a
component kit (packages/ui, published as @versable-git/ui), a
framework-free utility toolkit (packages/toolkit), and a written design
canon (docs/design-language, docs/app-patterns, packages/ui/docs) that
says what the kit's components mean and when to reach for them
(README.md:39-47). apps/playground is the fourth piece: a Next.js app that
renders every component and pattern live, doubles as the docs browser, and is
the only home of a few primitives that never shipped elsewhere. The three
apps that consume the kit (walmart-mvp, speedway, and the forge console
versable-forge-v6) live in sibling checkouts next to this repo, not inside
it; their pins are stated once, in the first paragraph of CLAUDE.md. Two
more things sit in the tree: packages/qsync, the URL half of the table
sync seam, and contract/, the foundry contract, a tenant here until the org
split moves it to its own repo.
2. Setup#
Prerequisites: pnpm, pinned to pnpm@11.10.0 in package.json:4, and Node
>=24 (package.json:5-7), pinned exactly to 24 in the root .node-version
file, which is also what every GitHub Actions workflow reads
(.github/workflows/ci.yml:14-17). This matters concretely. A plain node or
pnpm on a machine whose default Node is newer (Homebrew's is commonly 26)
resolves the wrong runtime and breaks the build, which is why AGENTS.md:14-15
says to run everything through fnm exec --using 24 -- pnpm <cmd> if pnpm
alone doesn't already land on Node 24. Check what you have before assuming:
node -vcat .node-versionInstall:
git clone https://github.com/versable-git/versable-builder.gitcd versable-builderpnpm installThis is a pnpm workspace (pnpm-workspace.yaml:1-3): apps/* and
packages/* are the member packages, installed together from one lockfile at
the root. Always run pnpm, not npm, inside it. No environment variables
are required; the playground ships fictional mock data (see "Deploying the
playground to Vercel" under section 3).
Workspace layout:
apps/playground/ Next 16 app: the gallery, the docs browser, the demoapps/_templates/ what you copy to start a new consuming app, one folder per shapepackages/ui/ @versable-git/ui, published, raw TypeScript; USAGE.md is the consumer's doorpackages/toolkit/ @versable-git/toolkit, zero React, published, no app consumer yetpackages/qsync/ @versable-git/qsync, the URL half of the table sync seamdocs/ the design canon, app patterns, ADRs, the board, the inbox, plan historycontract/ the foundry contract, a tenant until the org split; nothing in CI reads it3. Running things#
Every script under scripts/ is explained in scripts/README.md: what it is
for, when to run it, and what it must never do. This section covers the ones
you reach for while working.
The playground, the fastest way to see a component render, on port 5104
(apps/playground/package.json:6):
pnpm dev # from the repo root; filters to the playgroundIt also runs as a standing pm2 process in this environment, so it may already
be up: curl -s -o /dev/null -w '%{http_code}' http://localhost:5104/ answers
200 when it is. Two shapes share the port, never both at once:
playground-dev, the hot-reloading dev server, for editing. Turbopack's memory grows with every route visited and never releases (2.2 GB after a session;scripts/dev-guard.mdis the cap for long sessions).playground-prod, a production build served bynext start, for review and testing sessions (about 190 MB). No hot reload; a change means a rebuild. The filesystem-reading routes (docs browser, pattern source) keep working because it is still a server; a true static export is not possible for this app.
When pm2 says neither exists (they are not saved across daemon restarts),
start one from cold, from apps/playground:
pm2 start "fnm exec --using 24 -- pnpm dev -p 5104" --name playground-devpnpm build && pm2 start "fnm exec --using 24 -- pnpm start" --name playground-prodNever run pnpm build while playground-dev is up; the build clobbers
.next under it. pm2 delete the one you are replacing first.
pnpm dev at the root maps to pnpm --filter @versable-git/playground dev
(package.json:13). A memory-capped alternative exists,
apps/playground: pnpm dev-safe (apps/playground/package.json:7), documented
in scripts/dev-guard.md. It is opt-in; only run it if a human asks
(scripts/dev-guard.md:5).
Everything else, from the root, in parallel across every workspace package
(package.json:8-15):
pnpm typecheck # tsc -p tsconfig.json in each packagepnpm lint:tokens # eslint ., the real lint: bans raw palette/arbitrary colorspnpm test # vitest in packages/ui (render smoke, SchemaForm, sidebar), packages/toolkit and packages/qsyncpnpm build # next build in apps/playground; the kit has no build step, it ships raw TSTwo things are worth knowing before you reach for these. pnpm lint
(pnpm -r --parallel lint) currently runs a placeholder in every package
(echo 'lint: wired in M0.4', e.g. packages/ui/package.json:11), so the
lint that actually catches something is pnpm lint:tokens, run at the root
with plain eslint . (package.json:11); it is what fails a raw
bg-blue-500 or a bracket-arbitrary color (AGENTS.md:20-24). And
packages/ui has no build script at all (packages/ui/package.json:9-12):
the kit is shipped and consumed as source, so "does the kit build" is really
"does the kit typecheck," which is exactly what CI and the publish workflow
both run (.github/workflows/ci.yml:19,
.github/workflows/publish-kit.yml:91).
Citation health, two scripts: the full report on demand, and the ratchet CI
runs (scripts/check-citations.mjs, scripts/ci-citations.mjs):
node scripts/check-citations.mjs docsIt walks every backtick `file.ext:N` or `file.ext:N-M` citation
across the doc corpus and reports files that are missing, line ranges past
end-of-file, and citations pointing at a file committed after the doc that
cites it (scripts/check-citations.mjs:1-9). Its default scan set is
docs/design-language, docs/app-patterns, packages/ui/docs, and
apps/playground (scripts/check-citations.mjs:20). That set does not
include the top-level docs/ root, so a doc like docs/SCRIPTURE.md is only
checked if you pass docs explicitly, as above (docs/SCRIPTURE.md:24-26).
node scripts/ci-citations.mjs is the gate: it fails when the docs-wide
BROKEN or OUT-OF-RANGE count rises above scripts/citations-baseline.json
or when a governing doc (canon, contract docs, breakdowns) carries any BROKEN
citation. Lowering the baseline once a count drops is a one-line edit to that
file.
The bar for done#
This is the one list. CLAUDE.md and AGENTS.md point here rather than
restating it, so it cannot drift between doors. It is what
.github/workflows/ci.yml runs on every push, in this order, after an
install with a frozen lockfile; run it locally before a commit, through fnm
(fnm exec --using 24 -- <command>):
pnpm -r typecheckpnpm lint:tokenspnpm -r testpnpm buildnode scripts/ci-citations.mjsnode scripts/build-do-nots.mjs && git diff --exit-code -- docs/DO-NOTS.mdnode scripts/sync-method-docs.mjs --check --drift-onlylint:tokens fails on errors and only warns on the tint-ladder nudge. The
last two are the ones a docs change most often trips: the citation ratchet
(docs-wide BROKEN and OUT-OF-RANGE may not rise above the baseline; the
governing layers stay at zero) and the do-nots freshness check (a Banned
section edited without regenerating the page fails the push). CI runs one
more step that is advisory on a laptop, node scripts/check-notebook.mjs,
the canon notebook's lag check. A UI change is done only after all of this
AND the page has been looked at on :5104 in both themes. publish-kit.yml
fires on a push to main touching packages/ui/**, packages/toolkit/** or
packages/qsync/** and publishes each package whose package.json version
the registry lacks; a commit that bumps nothing publishes nothing, and
[skip kit] in the message holds all three
(.github/workflows/publish-kit.yml:24-31). docs-publish.yml fires on a push
to main touching docs/**, packages/ui/docs/**, or mkdocs.yml, and
calls a reusable workflow in the sibling slack-automation repo that renders
and publishes to tech.versable.ai
(.github/workflows/docs-publish.yml:7-24). There is no documented local
mkdocs build in this repo. The theme (.docs-kit/) is fetched by that CI job
from slack-automation and is not vendored here (mkdocs.yml:5-7,
pipelines/README.md:40-46). Verify a docs change with check-citations.mjs
and a read of the rendered markdown, and trust the workflow for the actual
render.
The live playground on Vercel#
The playground is deployed at https://versable-builder-playground.vercel.app/
(Vercel project versable-builder-playground in the Versable Team, Root
Directory apps/playground, Node 24.x, framework Next.js, no env vars; the
app is static with mock data). Vercel's GitHub integration redeploys it on
every push to main; the git-main alias
(versable-builder-playground-git-main-versable-team.vercel.app) tracks the
branch and the bare domain follows the last successful production deploy.
Nothing in the repo configures it (there is no vercel.json); the settings
above live in the Vercel dashboard.
What the repo does carry: apps/playground/next.config.ts sets
turbopack.root and outputFileTracingRoot to the repo root so the build
traces the raw-TS transpilePackages workspace deps (@versable-git/ui,
@versable-git/qsync), and the @source "../src" line in
packages/ui/theme/index.css is load-bearing for kit classes on the deployed
tree. Do not remove either.
After a push, verify at the live URL: /, /components/button,
/components/data-table, /docs, /demo/review; click a row, flip the
theme. Known noise: a push to the gh-pages branch (the mkdocs site) also
triggers a Vercel preview build, which fails at once because that branch has
no apps/playground; it does not touch production. An Ignored Build Step on
the Vercel project would silence it.
4. How to add a kit component#
A component is never one file. Worked example: Breadcrumbs, added whole in one
pass (task doc 53 G6.1, apps/playground/src/lib/docs-registry.ts:5-11
callout aside). The edits, in the order they need to happen:
- Source: a new directory under
packages/ui/src/<name>/, at minimum<name>.tsx(the component) and<name>.types.ts(its props and any exported unions), for examplepackages/ui/src/breadcrumbs/breadcrumbs.tsxandbreadcrumbs.types.ts. Presets, if the component has shaped variants, are aRecord<Preset, Partial<Props>>merged with explicit props always winning (packages/ui/src/breadcrumbs/breadcrumbs.tsx:5-13); this is a repo-wide rule, not a Breadcrumbs-specific choice (AGENTS.md:38-45). - Barrel export: add the component and its types to
packages/ui/src/index.ts. This file is the authoritative inventory. If a thing is not exported there, it does not exist as far as any consumer or any doc is concerned (docs/index.md:36-37,packages/ui/src/index.ts:46-54for the Breadcrumbs block). - Contract doc:
packages/ui/docs/<name>.md, one file per component. Frontmatter carrieskit_icon:(noticon:; mkdocs-material claimsicon:for its own paths and the build breaks otherwise,apps/playground/src/lib/docs-registry.ts:375-377) andsubtitle:, a one-line summary index pages read instead of guessing from the body (apps/playground/src/lib/docs-registry.ts:378). The body follows the shape every kit doc uses: what it is, "When to reach for it", "Contract" (props), "Sanctioned combinations" (a table of combination, what it produces, where it's used, why), "Banned combinations", "Before you adopt this" (six questions with the failure each catches: does the shell or a parent already render this; does the app already ship a local one; does the kit pin reach the version; who supplies the accessible name and keyboard path; which canon §A rules bind it; does a stateful look read the platform's own selector or mint an attribute, and if it mints, does it follow thedata-toneshape, doc 13 §A13), "Travels with", "Snippet". Seepackages/ui/docs/breadcrumbs.md:1-39for a complete instance andpackages/ui/docs/app-shell.mdfor the checklist filled in. - Playground page:
apps/playground/src/app/components/<name>/page.tsxplus agallery.tsx.page.tsxstays a thin server module: it setsmetadatafromshowcaseMetadata(href)and passesreadExampleSources(name)into the gallery (apps/playground/src/app/components/breadcrumbs/page.tsx:1-8). Anexamples/subfolder of small named.tsxsnippets is common but not universal. It exists when the gallery pulls printable source strings for a CodeBlock (seeapps/playground/src/app/components/field-chip/examples/for the shape, seven small files, one behavior each); Breadcrumbs itself ships without one because its gallery composes everything inline (apps/playground/src/app/components/breadcrumbs/gallery.tsx). - Registry entry: add a
ShowcaseEntryto the right group inCOMPONENT_GROUPS,apps/playground/src/app/showcase-registry.ts:31-411. The fields that matter:href,label,Icon,note(the landing-card tag),subtitle(one sentence, shown under the page title),doc(the repo-relative contract-doc path, wired into the page's "view contract" link), andrelated(sibling routes worth a jump from this page's header); see the Breadcrumbs entry atapps/playground/src/app/showcase-registry.ts:267-274. This one registry object is what derives the sidebar nav (toNav, line 472-477), the breadcrumb trail above the page (showcaseCrumbs, line 498-506), and the per-page AI-awareness prompt embedded in page metadata (awarenessPrompt, line 521-539). Nothing here is hand-wired twice. - Docs browser exposure: nothing to do here if the doc lives under
packages/ui/docs/. That whole directory is already swept on every request by thekitroot inapps/playground/src/lib/docs-registry.ts:206-212, so a new file appears in/docs/kit/<name>automatically. - Verify live in both themes: run the playground (already up as
playground-dev), load the new page, flip the theme toggle in the top bar (apps/playground/src/app/theme-toggle-button.tsx, persisted tolocalStorage.pg-theme,docs/index.md:59-61), and runpnpm --filter @versable-git/ui typecheck(or the rootpnpm typecheck). - Release note: a kit change does not publish by itself. It rides the
owner's next deliberate version bump to
packages/ui/package.json, pushed tomain.publish-kit.ymldoes the rest (README.md:170-179,CLAUDE.md, "Releasing the kit"). Never publish by hand from a laptop; it races the workflow (README.md:175-177).
Changing what an existing component looks like by default#
A default change reaches every screen in every app on the next upgrade, so it
has three extra gates. Why they exist: docs/plan/90-defect-prevention.md.
- Quote the ruling. The commit and the changelog row quote the owner's verbatim line that asks for the change. A paraphrase is not a ruling, and a change with no quote does not ship.
- Show it once. The owner sees one before and after image of the change and agrees before it merges. One image per change, not one per screen.
- Count the call sites. Grep each app for the usages the change alters
(for a flipped
Buttondefault, everyButtonwith novariant) and put the counts in the changelog row, so an upgrader knows what to check.
5. How to add a pattern#
A pattern is a composite recipe, not a new primitive: real components
composed the way an app actually ships them, with the provenance to prove it.
Worked example: compact-dropzone
(apps/playground/src/app/patterns/compact-dropzone/).
example.tsx: the composite itself,"use client", importing only from@versable-git/ui(hereCard,Dropzone,Tabs) plus React state to drive a knob through the real states the pattern has to handle: empty, populated, error, never a static screenshot (apps/playground/src/app/patterns/compact-dropzone/example.tsx:1-70).page.tsx: reads that same file's text viareadExampleSource("compact-dropzone")(apps/playground/src/app/patterns/read-source.ts:7-12) and hands it toPatternShellalongside the rendered composite, so the printed source and the live render can never drift apart.PatternShell's props worth filling in (apps/playground/src/app/patterns/pattern-shell.tsx:22-48):problem: what kind of problem this solves (visual, information, interaction, ease of use, discovery, gestalt hierarchy, self-explaining action container), explained in a sentence or two.useWhen: one to three{ when, example }situations that call for it.provenance: aProvenanceRow[], each a realcite(repo-relativefile:line, resolvable in the sibling checkouts) and anote. Seeapps/playground/src/app/patterns/compact-dropzone/page.tsx:31-40citing into bothspeedway/app/components/NewJobForm.tsxandwalmart-mvp/frontend/src/pages/ImportParts.tsx.appSpecific: what a real page adds that the fixture deliberately leaves out.
- Registry entry: add it to
PATTERN_GROUPSinapps/playground/src/app/showcase-registry.ts:413-469, same shape as a component entry, withrelatedpointing at the components it composes (compact-dropzone names/components/dropzone,/components/tabs,/components/card, line 434). - Canon citation: if the pattern encodes a rule the design canon should
know about (a spacing decision, a state-transition rule), that rule
belongs in the relevant
docs/design-language/*.mdtrait doc, cited back to the pattern'sexample.tsx, not restated only in the pattern page. The canon is the layer of record for anything a second, unrelated surface would also need (docs/design-language/00-overview.md:14-16).
6. How to propose a toolkit utility#
packages/toolkit/PROPOSALS.md is the backlog of helpers that might belong in
the toolkit. Filing is deliberately cheaper than building: an agent who notices
a generalizable helper writes six lines and moves on, and the whole list is
processed at once at a wave boundary. The file's own header carries the entry
shape and the four operations. Two rules bind a filer: grep for the name before
adding, and never delete an entry, since a discarded proposal is the record
that stops the next agent re-proposing it.
7. How to add or change a doc#
Three doc families, three different rules.
Canon docs (docs/design-language/*.md, docs/app-patterns/*.md): one
pen. Every change is an owner-visible diff, never a bulk rewrite
(docs/SCRIPTURE.md:22-26). Every trait doc keeps the same three-section
shape: §A principles and mental models, §B presets and how-to, §C use cases
through the apps, cited to file:line (docs/design-language/00-overview.md:12-16,
CLAUDE.md, "The canon"). After any edit, run the citation checker with the docs
argument as shown in §3 above. The checker's own default scope would
silently skip a change to a file at the docs/ root, so pass it explicitly
when in doubt.
Kit contract docs (packages/ui/docs/*.md): frontmatter needs
kit_icon: and subtitle: (§4 above explains why the key isn't icon:),
and the body follows the fixed shape: intro paragraph, "When to reach for
it," "Contract," "Sanctioned combinations," "Banned combinations," "Before
you adopt this," "Travels with," "Snippet" (packages/ui/docs/breadcrumbs.md;
the checklist section is on packages/ui/docs/app-shell.md). Keep it current
with the release it describes, per docs/index.md:36-38. Every Banned
section feeds the generated docs/DO-NOTS.md; regenerate it with
node scripts/build-do-nots.mjs after editing one.
Repo docs (README, this file, AGENTS.md, docs/SCRIPTURE.md,
docs/index.md, ADRs): no fixed shape, but the same kit_icon: and
subtitle: frontmatter convention if you want the doc to appear
well-labeled in the in-app docs browser, since that browser is a fixed
allowlist of files in three groups, Start here, Working here and Canon
(apps/playground/src/lib/docs-registry.ts:77-181, 213-246). A brand-new top-level
doc has to be added to one of those files: arrays by hand to be reachable
at /docs/<group>/<slug>. A new file dropped into docs/design-language/,
docs/app-patterns/ or docs/breakdowns/ needs no such registration, since
those roots are swept by directory listing, with each tree's README as the
group's first row (apps/playground/src/lib/docs-registry.ts:182-212).
One shared trap: mkdocs.yml's nav: block is a second, independent index
of the same doc trees, used only by the published site, not by the in-app
browser. A new canon or kit doc needs an entry there too if it should appear
on tech.versable.ai. The in-app /docs route and the published site read
two different lists, and neither derives the other. The check that catches
a forgotten entry is node scripts/sync-method-docs.mjs --check, which
besides the method copies now lists every doc in the four published trees
that the nav does not name (run by hand before a docs commit; deliberately
not a CI gate, owner ruling D7a 2026-09-03). Seventeen docs were missing from
the nav when the check was written.
A component is described once, and that once is the contract doc's frontmatter
subtitle. node scripts/sync-showcase-subtitles.mjs writes it into the
showcase registry, and --check fails when the two have drifted. Edit the doc,
never the registry entry. Two kinds of page keep their own sentence, because no
single contract describes them: a page with no contract doc, and one of several
pages sharing one (the table family reads table.md, chip-compare reads
chip.md). The script names them when it runs, so the exception is visible
rather than silent. Owner ruling D4a, decision page vb-rewrites-s01,
2026-09-04.
The same sync now rewrites paths on the way into the package: a canon doc citing
docs/design-language/… is copied with method/design-language/…, because that
is where the file actually sits inside an installed kit. A path this package does
not carry, such as docs/plan/ or docs/evidence/, is left as the repo wrote
it, so it reads as provenance rather than as a broken link. Owner ruling D1a,
same page, whose note reads "Nonsense question, it should not have to be a
trade-off".
8. Conventions that bite#
Zero em dashes in anything rendered or written. This is a standing,
previously-breached constraint, not a suggestion. The canon calls it out by
name (docs/app-patterns/00-data-loading.md:57), and the scripture's own
honesty section records that the rule "is documented in this repo as having
failed" once before, because the gate that catches it lives in the writing
agent's own configuration, outside this repo, not in a repo-local hook
(docs/plan/49-scripture-evidence/honesty-gate.md:85-91). Treat it as
something to check by eye, not something CI guarantees for you.
Mono is for identifiers only, SKUs, ids, code, never decoration
(docs/design-language/01-foundations.md:28).
Verify UI changes in both themes, not just one. The dark theme's literal
name is versable-dark (packages/ui/theme/index.css:24), stamped as
data-theme on <html>. The full pass, with the review skills that run each
step and when each fires, is docs/app-patterns/14-validating-a-ui-change.md.
Never stage user_docs/speedway-todo.md or its sibling-repo counterpart,
speedway/docs/user-notes.md. Both are owner-authored, and agents never
commit them (CLAUDE.md, "Doc map").
Path-scoped git add, never git add -A, especially with more than one
agent writing in parallel (AGENTS.md:34-35).
Never publish the kit from a laptop. A hand publish races the
publish-kit workflow, which then finds the version already present and
skips. [skip kit] in a commit message is the deliberate way to suppress a
publish, not a hand npm publish (README.md:175-177).
The rendering judge, at batch boundaries only#
Ruled 2026-08-21 (doc 73 C1, which carries the full text and the evidence).
A fresh seat that never authored the changes looks at the rendered result
twice per cycle: once per review wave before the owner's sitting, once per
release candidate over changed shipped exports. Cheapest instrument first
(see --ui, then /ui-categorical-check, native vision only on a clean
pass over a new surface); a canon edit to a visual rule sends the judge to
the affected playground surfaces. Always advisory: findings land as
/callouts rows citing a canon file:line, never as blocks. The kill
condition rides the mechanism: two consecutive post-judge sittings without
a lower render-visible defect count stops the judge. Per-turn telemetry
notices must exist before its first run, so value per cost is measured
rather than argued.
9. Filing what an app sends back#
A defect, a proposal or a question about the kit that surfaced in an app is
one entry in docs/plan/INBOX.md, in the shape that file gives, and then you
go back to your task. Nothing is built from the inbox directly; a review run
the owner calls reads it whole and promotes what earns it. Grep the file for
the surface's name before adding, and never delete an entry. Deferrals of work
this repo already knows about go to docs/plan/BACKLOG.md; a helper that might
belong in the toolkit goes to packages/toolkit/PROPOSALS.md.
10. Where to look next#
README.md, who reads what, and the map by what you came to do.packages/ui/USAGE.md, the door for anyone putting the kit in an app: what you came for in one hop, the six steps, the scope rule.CLAUDE.md, the doc map, what's authoritative for requirements vs. design vs. status, and the current product freeze.docs/SCRIPTURE.md, why this repo exists, its goals, and an honest ledger of where it has and hasn't lived up to them.docs/index.md, the docs map: what the docs can do, where kit truth lives, the two showcase surfaces.packages/ui/README.md, installing and upgrading the kit in an app.- the playground's own
/docsroute, every doc above, rendered, searchable, and cross-linked, once the dev server is running.
Until 2026-08-19 this file was USAGE.md; the name changed, the sections did
not.