The shared React component kit behind every Versable app
So a second app does not re-decide what a table, a modal, or an empty state should be, and a correction made once stops being a thing anyone remembers.
./docs ·
../../docs/design-language ·
../../docs/app-patterns ·
../../docs/SCRIPTURE.md
What is this?#
| The kit | |
|---|---|
| Stack | React 19, TypeScript, Tailwind + daisyUI, jotai, sonner, floating-ui |
| Ships | the components on the live gallery, the table feature hooks, standalone hooks; src/index.ts is the inventory |
| Consumed by | walmart-mvp (pins ^0.0.24), speedway (declares ^0.0.14), apps/playground |
| Published | GitHub Packages, by the publish-kit workflow, never by hand |
| Docs | docs/, one contract doc per component |
| Entry point | One. Everything imports from the package root |
Architecture#
Three layers, and the middle one is the reason the kit stays framework-free.
your app @versable-git/ui the canon┌──────────────┐ ┌───────────────────────┐ ┌────────────────┐│ page / route│ │ Layer A primitives │ │ design-language││ │ ────────▶│ Table · Card · Modal │◀─ ─ ─│ 13 traits ││ useDataTable│ ├───────────────────────┤ law │ §A principles ││ │ │ │ Layer B feature │ └────────────────┘│ ▼ │ │ hooks: sort, search, ││ DataTable │ ────────▶│ filter, select, page │ ┌────────────────┐│ │ ├───────────────────────┤ │ app-patterns ││ │ │ Layer C useFeature │◀─ ─ ─│ recipes AP-10 │└──────┬───────┘ │ State + sync seam │ what │ AP-11 │ │ └───────────┬───────────┘ a └────────────────┘ │ router lives here │ request ▼ ▼ brings┌──────────────┐ adapter ┌───────────────┐│ react-router │ ─ ─ ─ ─ ─ ─ ▶│ TableSync │ the kit NEVER imports a│ or next │ (to build) │ Adapter │ router; the seam is why└──────────────┘ └───────────────┘The dashed edges are the deliberate ones. The kit never imports a router, so URL
sync plugs in through TableSyncAdapter rather than being built in, and the
canon governs the kit without the kit depending on it.
Install#
Everything an app, or an agent starting one, needs to get the kit running. Each step is taken from what speedway and walmart actually do, with the file that proves it; if a step here disagrees with an app, the app is the reference and this page is wrong, say so on the board.
Prerequisites#
| Need | Value | Why |
|---|---|---|
| Node | 24 or newer (package.json engines at the repo root) | the workspace and the playground run on it; an app on 22 works for consuming the kit, the console proves it |
| Package manager | pnpm inside this repo; pnpm or npm in yours | the kit publishes source, not a build, so any installer that resolves GitHub Packages works |
| React | 19 (peerDependencies: react, react-dom ^19) | the kit imports nothing below it |
| Tailwind | 4, with @tailwindcss/postcss (or Vite's plugin) | the theme file is a Tailwind 4 entry; the kit brings its own daisyUI 5 |
| A token | a GitHub personal access token with read:packages | the registry is GitHub Packages, not npmjs; without it every install of the scope returns 401 |
The kit brings daisyUI, floating-ui, jotai, react-icons, react-markdown and sonner itself; do not add them to the app.
1. Point the scope at GitHub Packages#
Two lines in the app's .npmrc:
@versable-git:registry=https://npm.pkg.github.com//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}GITHUB_TOKEN is the read:packages token, held in the shell environment or a
local .env that is not committed. The 401 you get without it is the first
thing every fresh adopter has hit (docs/GUIDEBOOK.md section 7, row 24).
2. Install, and pin on purpose#
pnpm add @versable-git/ui@<version> # or npm install @versable-git/ui@<version>The current version is the version field of package.json here. Pin the
exact number: a caret on a 0.x version admits no minor bump, so ^0.2.0
behaves like a pin anyway, and the plain number says what you mean. An app
upgrades with its next customer-facing piece of work, never as a ride-along
(owner ruling 2026-08-17, docs/GUIDEBOOK.md section 4).
The framework-free helpers live in a second package on the same registry,
@versable-git/toolkit (dates, text, numbers, urls, results, sampling; no
React). Add it the same way when a real call site needs one of its modules and
import by subpath (@versable-git/toolkit/date); packages/toolkit/README.md
is the module map. Do not add it speculatively.
3. Import the theme once#
The theme file is the Tailwind entry for the app: it pulls Tailwind, the two
daisyUI themes (versable-light, the default, and versable-dark), and it
scans the kit's own source so kit-only classes generate in the app's build.
One line at the top of the app's root stylesheet, before anything else:
@import "@versable-git/ui/theme/index.css";Speedway: app/styles/app.css:9. Walmart: frontend/src/index.css:2 (walmart
imports tailwindcss first because it keeps its own @theme static block; that
block aliases every app token onto the kit's ramp, which is the shape to copy if
the app has tokens of its own).
Set the theme on the document element and let a pre-paint script switch it:
<html data-theme="versable-light"> (speedway app/root.tsx:466, the constant
in app/components/theme.tsx:14; the Next template's src/lib/theme-boot.ts).
4. The bundler contract#
The kit publishes source, not a build: exports maps . to ./src/index.ts
and its files carry "use client". Every consumer must transpile it.
Next: one line in next.config.ts (the playground and the Next template):
transpilePackages: ["@versable-git/ui"],Vite and React Router: two blocks, copied from speedway vite.config.ts:33-47:
optimizeDeps: { exclude: ["@versable-git/ui"] },ssr: { noExternal: ["@versable-git/ui", "sonner", "jotai", /^@floating-ui\//, "react-icons"] },Without optimizeDeps.exclude, the pre-bundle throws "React is not defined" at
the first kit component. Without ssr.noExternal, the SSR build externalizes
the package and breaks outright.
If the app overrides Tailwind's type scale in its own @theme, kit components
inherit it: walmart's override inflated PageTitle to 52px until it was scoped
(frontend/src/index.css:44-51). Keep app-level type overrides off the bare
utility names the kit uses.
5. Check the install#
import { Button, DataTable, useDataTable } from "@versable-git/ui";If DataTable resolves, the barrel is reachable; every runtime export is named
in src/index.ts, and if a name is not there it does not exist. Then open one
screen in both themes. The definition of done for any screen on the kit is
docs/app-patterns/14-validating-a-ui-change.md.
5b. Installing in CI and Docker (the traps a green laptop hides)#
Three failures the first console deploy hit, each invisible locally (app-forge-v6, 2026-08-20):
- A
file:pin on a vendored tarball cannot work in CI.vendor/is gitignored, and source uploads (Cloud Build included) honour.gitignore, so the tarball never reaches the builder. A deployed build installs from the registry or not at all; keepfile:pins for local iteration only. npm ci --omit=devin a deps stage starvesnext build. Tailwind and its postcss plugin are devDependencies and the build needs them; install full deps in the build stage and let standalone output carry the runtime set.--mount=type=secret,id=X,env=Yfails at PARSE time on older builders. Cloud Build's stock docker ships a dockerfile frontend without it; pin a newer frontend (# syntax=docker/dockerfile:1.10) or pass the registry token another sanctioned way.
The GCP-side half (a fresh project's default service account has zero roles,
so the first build 403s on its own uploaded source) is
docs/app-patterns/03-deploying-on-gcp.md.
6. Then read, in this order (the kit-consumer cut of AGENTS.md's governing order)#
AGENTS.md (the route), docs/design-language/README.md, the contract doc for
the first component you touch (docs/<component>.md here), the recipe for the
screen shape (docs/app-patterns/10-recipe-browsable-list.md or
11-recipe-record-detail.md), and docs/DO-NOTS.md before you ship. Starting
a whole app: docs/app-patterns/05-starting-a-new-app.md picks up from here,
and apps/_templates/ holds what you copy.
Known gaps, so nobody rediscovers them#
Releases since 0.2.1 ship docs/ and CHANGELOG.md in the package; 0.2.0
and older carry neither, so on an old pin both live only in this repo and on
the live site. No published release ships bin/ yet (every tarball through
0.2.2 omitted it, so npx canon-check resolves to nothing in a consumer);
the fix is on main and rides the next bump. Speedway develops against a symlink to this repo's
working tree, so its node_modules runs kit HEAD while its manifest says
0.0.14; a clean install of speedway gets 0.0.14. That one is on the board.
The canon check#
The package ships canon-check as a bin (from the next bump; every tarball
through 0.2.2 omitted bin/, see Known gaps). Run it yourself, mid-task,
when a change touches UI paths; it is the author's instrument for catching a
deviation while it is still a keystroke, not a gate someone else holds:
npx canon-check # or: npm run canon-check, once package.json names itA change touching UI paths must add a dated line to the repo's
CANON-NOTES.md (what the kit could not give you and what you did instead) or
carry a human canon-note: none because <reason> line in the PR body; the
added lines are also read for the deviation classes the canon retrospective
names. Repos reviewed by pr-claude get the same check on every PR with no
setup; the notes are read by a curator and may land in the kit. Plan:
docs/plan/71-canon-feedback-loop-plan.md in versable-builder.
Upgrading an app's pin#
The consumer-side half of a release. The kit publishes on the owner's bump
(Releasing, below); this is what an app does to take it. Both apps hold their
pin until customer-facing work arrives (owner ruling 2026-08-17,
docs/GUIDEBOOK.md section 4), so this runs at that moment, on a branch, and
it is a checklist rather than a research project.
Before you touch package.json:
- Know how far behind you are, then read
CHANGELOG.mdfrom your pin to the target.npm view @versable-git/ui versionnames the latest;npm outdated @versable-git/uishows current, wanted and latest in one line. Every release lists what is added, what changes on screens you already have, and what you can delete once you take it (theRetiresline). Write the two lists for your app: screens to look at, files to delete. - List what you import and read those contract docs first, their Banned
combinations and "Before you adopt this" sections:
rg -o "import \{[^}]*\} from \"@versable-git/ui\"" src app | sort -u, thendocs/<component>.mdfor each name (live under/docs/kit/<component>). - Know what your dev tree really runs. Speedway's
node_modules/@versable-git/uiis a symlink into the sibling checkout, so its screens already render kit HEAD; a clean install gets the pinned version. Walmart holds a real copy of its pin. Test the bump from the resolved package, not the link.
The bump:
- On a branch: set the exact version in
package.json("@versable-git/ui": "0.2.0", no caret), then install. Confirm the lockfile resolves the number, notlink:. - Typecheck (the kit ships raw TypeScript, so a removed or renamed prop fails here first).
- Run the app's Playwright suite: speedway has 11 specs, walmart 9. They are the kit's real integration tests on your screens; a red spec is a finding, not noise.
- Look, in both themes, at every screen the changelog's "Changed on existing screens" line touches, plus the jobs list, the review or import table, and one modal. Sidebar groups collapsing, warning ink, the neutral tint and the entrance animations are the ones that surprised the first readers.
- Delete what the
Retireslines name, in the same branch, so the upgrade and the retirement land together and nothing hand-rolled outlives the fix that replaced it (docs/SCRIPTURE.mdF9). - Record what it cost: two lines in
docs/GUIDEBOOK.mdsection 4 (the version jump, the surprises, the files retired), so the next bump starts from a number instead of a guess.
When something breaks: a kit component that cannot do what your screen needs
is a kit item, not a local workaround. File it as walmart did for Select multiple (docs/GUIDEBOOK.md section 7, row 20), keep the local copy only
until the release that carries the fix, and put its name on that release's
Retires line when it lands.
Use#
import { Button, DataTable, useDataTable, pushAlert } from "@versable-git/ui";const model = useDataTable<Job>({ rows, sort: { default: { key: "created", dir: "desc" } }, search: {},});<DataTable model={model} columns={columns} showSearch showPager />Import everything from the package root. There is one entry point plus
@versable-git/ui/theme/* for the theme files.
What is in it#
| Group | Exports |
|---|---|
| Layout and shell | AppShell, Sidebar, Topbar, Card, SidePanel, PageTitle, Tabs |
| Table | Table, DataTable, useDataTable, col, Pager, FilterBar, and six feature hooks for sort, search, filters, selection, pagination and columns |
| Overlays | Modal, ModalTitle, ModalFooter, ConfirmModal, useModal, Dropdown, Tooltip |
| States | PageInfo, EmptyState, PageLoading, PageError, Spinner, Skeleton, SkeletonGroup, Progress |
| Data display | StatusPill, StatusDot, StatTile, Timestamp, CodeBlock, FieldChip, Chip, ListItem, IdentityRow, WorkspaceSwitcher |
| Input | Input, Select, Dropzone, InlineEdit, CopyButton, Button |
| Feedback | Toasts, pushAlert, useAlertToast, Alert |
| Icons | IconFor, RenderIcon, IconKeyList |
| Hooks | useDebounce, useKeyPress, useDisabledReason, useNavHijack, useLocalStorage, useSessionStorage |
Some components carry a decision, not just markup#
Worth knowing before you hand-roll something adjacent, because these exist to make a specific mistake unbuildable:
useDisabledReasonturns a priority-ordered list of[condition, reason]into button props, so a disabled control always explains itself on hover. There is no way to use it and produce a mute disabled button.pushAlertis a module-level export rather than component state, so a toast pushed from a modal outlives the modal by construction.useModalplususeModalMountWarningmake modal identity store-driven and warn at runtime when a modal's declared id and mount state disagree.ConfirmModalreplaces a rawconfirm(, which is drift rather than an alternative.colbuilds table columns, so column shapes stay consistent across surfaces.
Each component doc carries a "What this saves you" section naming the canon rules that component retires.
Developing#
This repo is a pnpm workspace. Run pnpm, not npm, inside it.
pnpm typecheck # here, or from the root for every packageThe playground app is the fastest way to see a change render. It exercises the kit directly and is, for several primitives, the only place they have ever run:
pnpm dev # from the repo root, serves the playground on 5104Verify UI changes in the running app rather than by building. A green
pnpm build says the types line up, not that the component looks right.
Adding or changing a component, and contributing it back, is a fixed network
of edits: the contract doc in docs/, the playground page and its registry
entry, tests, the do-nots regeneration, the citation check. The runbook is the
repo's CONTRIBUTING.md, section 4.
Releasing#
Bump the version in package.json, move the Unreleased entries in
CHANGELOG.md under the new number, commit, then run scripts/ship-kit.sh
from the repo root: it pushes main, watches the publish-kit workflow and
confirms the version on the registry (--check rehearses without pushing).
The workflow publishes any version the registry does not already have.
Do not publish from a laptop. A hand publish races the workflow, which then
finds the version present and skips it. Put [skip kit] in the commit message
to suppress publishing deliberately.
Consumers pin independently, and the pins differ. Walmart is on ^0.0.24, and
speedway declares ^0.0.14, which under npm semver is a patch pin resolving to
exactly 0.0.14. So publishing a version does not put it in either app; someone
has to bump the consumer, with the checklist above.
Documentation#
Per-component reference lives in ./docs, one file per component: what the props are, what the component does with them, and where it is actually used in a shipped app.
The most common way to waste time here is reading the wrong layer.
| You are asking | Read |
|---|---|
what props does DataTable take | packages/ui/docs/, this kit's per-component reference |
| what should a table look like, and why | docs/design-language/, the cross-app visual canon |
| I was asked to build a list page, what does that involve | docs/app-patterns/, the request-shaped recipes |
| which of those rules does a component already handle for me | docs/app-patterns/12-primitives-and-rules.md |
| how do I bring an existing app onto this | docs/app-patterns/04-migrating-an-app.md |
| how do I start a new app on this | docs/app-patterns/05-starting-a-new-app.md |
The canon is law, the component docs are contract, and the recipes are what a request brings along. A component doc never restates a canon rule; it points at the section number.
Where the design comes from#
The platform underneath is bought, not built: Tailwind 4 supplies the utility
layer and daisyUI 5 the controls and the theme mechanism, and an owner rule
says never rebuild a control daisyUI provides, extend it through theme
variables and small CSS (docs/SCRIPTURE.md S16, AGENTS.md). What the kit
adds is the part nothing off the shelf carries: the Versable vocabulary for
tables, status, toolbars, empty and loading states, forms from a schema, and
the corrections two shipping apps have already paid for. Those corrections are
the design system's actual source: 73 owner corrections mined from speedway
and walmart, every one a rule that had never been written down, became the
thirteen traits of the canon (docs/design-language/), and the kit implements
the canon once. A component doc never restates a canon rule; it cites the
section. So the lineage is Tailwind and daisyUI for the material, the two apps
for the vocabulary, and the canon as the written law between them.
Repo Structure#
packages/ui/├── src/│ ├── table/ → the largest surface: Table, DataTable, feature hooks│ │ └── hooks/ · sort, search, filters, selection, pagination,│ │ columns, and sync.ts, the adapter seam (no-op today)│ ├── modal/ → Modal, ConfirmModal, and the jotai-backed store│ ├── internal/ → shared machinery, not exported as components│ ├── hooks/ → standalone hooks, useDisabledReason and friends│ └── index.ts · the single entry point│├── theme/ → theme files, imported as @versable-git/ui/theme/*└── docs/ · one contract doc per component