# CLAUDE.md

Guidance for Claude Code working in this repository.

## What this is

A **publishable React UI kit** — a library, not an application. There is no
router, no pages, no application state. 39 components ship from 48 export
subpaths, so a consumer writes `import { Button } from '<pkg>/button'` and pays
for Button.

`pnpm dev` opens the **showcase** (port 5175), not Storybook. The showcase is
the primary surface: a gallery that documents every component as a variant grid,
and the artifact published as a static doc site. Storybook is **optional** — this
kit may have been scaffolded without it, so never assume `.storybook/` exists.

A first visit opens the **intro tour** — six slides in `showcase/src/chrome/ui/`,
reopened from the header, remembered in `localStorage` under
`cbar-showcase:tour`. Two rules if you edit it. Its slides demo with **live kit
components, never screenshots**: the site renders in two themes and three
languages, so an image is right in one combination and wrong in five, and
nothing in the build would catch it going stale. And every count in it is read
from the registry, the icon manifest and `package.json#exports` — do not type a
number in, it will be wrong by the next component you add.

The output is `dist/`. `package.json#files` is `dist`, `README.md`,
`CHANGELOG.md` and `NOTICE` — nothing else reaches a consumer, which is why
the showcase, the tests and this file can be as large as they need to be.

## The rule that everything else follows from

**Components use the semantic layer. Never a primitive, never a literal.**

```
src/styles/tokens.css   --ui-*      raw values      MACHINE-OWNED
src/styles/theme.css    semantic roles + .dark + .palette-*   human-owned
src/styles/globals.css  entry
```

A component writes `bg-background`, `text-muted-foreground`, `rounded-md`. It
must never write `bg-neutral-200`, `#fff`, `oklch(...)`, or reference a
`--ui-color-*` primitive directly. That indirection is the only reason the whole
kit can be rebranded by rewriting one file.

`pnpm lint` enforces this and will fail the build. The **only** primitives a
component may reference are `--ui-duration-*`, `--ui-ease-*` and `--ui-z-*`,
because nothing re-maps them per theme so no semantic layer sits above them.

## Generated files — never hand-edit these

Editing any of these looks like it works and is silently undone by the next
build:

| File | Written by |
|---|---|
| `package.json#exports` and `#typesVersions` | `pnpm exports:gen` |
| `src/styles/tokens.css` | `pnpm brand`, or the `/figma-sync` skill |
| `src/icons/generated/` (250 icons, 9 modules) | its own pipeline; regenerate, never edit |
| `showcase/src/registry/props.generated.json` | `pnpm props:gen` |
| `consumer-skills/ui-kit-usage/references/` | `pnpm consumer:gen` |
| `showcase/src/registry/figma-spec.json` | `pnpm figma:spec` |
| `docs/TOKENS.md`, `dist/tokens.json` | `pnpm tokens:gen` |
| `src/lib/tw-merge/` | vendored upstream — see its README before touching |

`pnpm verify` re-runs the `--check` mode of several of these and fails if the
committed output has drifted from its source.

## Zero runtime dependencies

`dependencies` is empty and that is a feature, not an accident:

- **Radix** is compiled into `dist` by tsup's `noExternal`.
- **tailwind-merge** is vendored as source at `src/lib/tw-merge/`.
- Icons, the variant builder (`cva`), the class composer (`cx`), the command
  palette, toasts, calendar, table, tree, upload — all local code under `src/`.

Two consequences worth holding onto:

1. **Import Radix by per-primitive subpath, never the barrel.**
   `import * as Dialog from 'radix-ui/dialog'` — not `from 'radix-ui'`. The
   barrel is one module reachable from every Radix-using component, so importing
   it defeats code splitting: measured, it took Button from 10 kB to 434 kB.
   `pnpm lint` blocks it by name.
2. **Adding a package to `dependencies` is a decision, not a detail.** It lands
   in every consumer's lock file. `clsx`, `class-variance-authority`,
   `lucide-react`, `cmdk`, `sonner`, `vaul`, `rc-*` and every date library are
   already replaced by local code and blocked by lint.

Because nothing appears in a downstream lock file, `npm audit` cannot see any of
it. `pnpm audit:shipped` resolves the inlined tree instead — that is the audit
that matters, and it gates `pnpm release`.

## Adding a component

One folder, four files:

```
src/components/<name>/
  <name>.tsx           'use client' + implementation
  <name>.test.tsx      Vitest + Testing Library
  index.ts             'use client' + named re-exports, including the Props type
  <name>.stories.tsx   only if this kit kept Storybook
```

Prefer the `/ui-kit-component` skill — it covers the whole checklist. Doing it by
hand, the parts most often missed:

- `'use client'` on **both** `<name>.tsx` and `index.ts`.
- `data-slot="<name>"` on the root element — a styling hook for consumers that
  does not depend on class names.
- `cn(variants(...), className)` with `className` **last**, so a consumer can
  override any utility without `!important`.
- Compose `@/lib/cva-presets` (`focusRing`, `disabledState`, `invalidState`,
  `iconSizing`) rather than retyping focus and disabled styling.
- A case in `test/cases.tsx` — that one table feeds **both** the axe sweep and
  the SSR sweep, so a component missing from it is untested twice over.
- A showcase entry in `showcase/src/registry/<name>.tsx`, registered in
  `showcase/src/registry/index.ts`. Without it the component is invisible in the
  kit's primary surface.
- `pnpm exports:gen` — `index.ts` is what generates the subpath.

A component built on an **optional** peer dependency stays out of
`src/index.ts` and is reachable only at its own subpath. `form` is the existing
example: it imports `react-hook-form`, so re-exporting it from the barrel would
break every consumer that has not installed it.

## `consumer-skills/` — written for the other side of the install

Everything under `.claude/skills/` is for whoever develops this kit.
`consumer-skills/` is the opposite: three Claude Code skills (`ui-kit-setup`,
`ui-kit-usage`, `ui-kit-review`) that a developer copies into an **application**
that depends on this package, so Claude there uses the kit instead of
hand-rolling a button and reviews code that was written without it.

Two things govern edits there:

- **Write for an application.** It has `node_modules/<pkg>/dist` and its own
  source — no `src/`, no scripts of this repo, no showcase, no way to edit a
  token. `pnpm consumer:gen --check` greps for that vocabulary inside every
  `ui-kit-*` folder and fails `verify` on a hit.
- **`ui-kit-usage/references/` is generated** from `package.json`, the component
  barrels, `props.generated.json`, `theme.css` and the icon manifest. The prose
  is hand-written; the facts never are.

They do not reach npm — `files` is `dist`-only, deliberately. Handing them over
is a manual copy, documented in `consumer-skills/README.md`. Prefer the
`/consumer-skills` skill over editing them by hand.

## Build and verification

```bash
pnpm lint && pnpm typecheck && pnpm test:run && pnpm build && pnpm verify && pnpm size
```

`build` runs `exports:gen → tsup → add-use-client → build-css → tokens:gen`, and
two of those steps exist because of a specific failure:

- **`add-use-client.mjs`** re-applies the directives after bundling, because
  esbuild does not reliably carry top-level directives through. `check-exports`
  fails the build if a directive present in source is missing from `dist`.
- **`run-tsup.mjs`** raises the heap before invoking tsup. The dts worker holds
  the whole type graph at once and dies with `ERR_WORKER_OUT_OF_MEMORY` at the
  default limit — a failure that reads like a tsup bug and is only the heap.

`pnpm kit:local -- --to ../app` installs the kit into another project without
publishing it — it builds, then copies the file set `package.json#files` names
into that project's `node_modules`. It copies rather than symlinks, because a
link makes Node resolve `react` from the kit and every hook then throws. Read
the package name from `package.json` if you touch it; `pnpm brand` rewrites it.

`typecheck` runs **two** programs: the kit and the showcase, which has its own
`tsconfig.json`. **Never set `declaration` in the root `tsconfig.json`** — it
belongs in `tsconfig.build.json`. Turning it on for the root program applies
declaration-portability checking to stories, whose inferred types reach into
transitive packages, and under pnpm every story then fails **TS2883**. npm's
flat tree passes, so this hides during local development and breaks in CI.

## Git hooks

`.husky/` holds three hooks plus `lib.sh`, the shared step/timer machinery they
source. Husky installs them from the `prepare` script — but only inside a git
repository, so a fresh scaffold needs `git init` before `npm install`, or
`npx husky` afterwards.

| Hook | Checks | Cost |
|---|---|---|
| `pre-commit` | branch-name gate, then ESLint on the **staged files only** | ~2-5s |
| `commit-msg` | `<prefix>[(scope)][!]: <text>` subject | instant |
| `pre-push` | `lint`, `typecheck`, `test:run` — constant `TOTAL=3` | ~70s |

**Cost decides which hook a new check goes in**, and this is the rule, not a
preference: staged-scoped and under ~5s → `pre-commit`. Whole-repo or over ~10s
→ `pre-push`. Over ~60s → **neither**, it belongs to CI. That last bucket is why
`build`, `verify`, `size` and `audit:shipped` are in no hook at all — they need
`dist/` built first, and a hook that slow gets bypassed with `-n` within days,
which protects nothing.

Four things about them are load-bearing:

- **The branch gate is first and is not numbered.** It runs before the staged
  list is even read, so a wrongly-named branch costs zero ESLint seconds — and
  no total exists yet to count it against. `main`, `master` and a detached
  `HEAD` are exempt, or every commit on a rebase would fail.
- **The message check cannot move into `pre-commit`.** The editor opens *after*
  `pre-commit` returns, so at that point the message does not exist. Git's own
  generated subjects (`Merge `, `Revert `, `fixup!`, …) are exempt, or
  `git merge` and `rebase -i --autosquash` fail on messages nobody wrote.
- **`pre-push`'s denominator is a constant.** A step skipped for a missing
  `node_modules` still consumes its number; a denominator that changed per
  machine would make "how many are left" meaningless. Bump `TOTAL` when adding
  a step there.
- **No hook installs anything and none writes to the working tree.** Without
  `node_modules` a step is skipped with a warning — CI still checks it.

Fail with `fail "<reason>"`, never a bare `exit 1`: `fail` closes the
in-progress step line first, otherwise the error lands mid-line. `pre-push`
buffers each script's output and prints it only on failure, because vitest
reports a *passing* run in eight lines and they would land inside the step line.

Bypass with `HUSKY=0` or `--no-verify`; run them by hand with
`npm run check:staged` / `npm run check`.

## Failures that are not defects

Check here before debugging:

| Symptom | Cause |
|---|---|
| Storybook fails to start, `ERR_DLOPEN_FAILED` | A native binding blocked by Windows Smart App Control / WDAC. Machine policy — do not reinstall. |
| TS2883 on every story, pnpm only | `declaration` leaked into the root `tsconfig.json`. See above. |
| `pnpm build` cannot find `esbuild` | Install script not approved. It must be in **both** `allowScripts` (npm) and `pnpm.onlyBuiltDependencies` (pnpm) — each manager ignores the other's field. |
| `ERR_WORKER_OUT_OF_MEMORY` after "CJS Build success" | The dts worker heap. That is what `run-tsup.mjs` is for. |
| One component blows its `size` budget | A `radix-ui` barrel import crept in. |
| Showcase sub-path build 404s every asset in preview | `SHOWCASE_BASE` must be given to **build and preview alike**; `base` does nothing on the dev server. |
| A copied showcase link is refused in another browser | `localhost` resolved IPv6-only. Use `pnpm showcase:host`. |
| A consuming app reports TS2559 on a component's own children | The shipped `.d.ts` still imports `radix-ui/<primitive>` for its prop types. The app needs `radix-ui` as a **dev** dependency; the runtime bundle is unaffected. |
| TypeScript 7 breaks the build | Pinned to 6 on purpose in `.ncurc.json`; 7 breaks the dts step. |
| `npm install` prints `.git can't be found` | Husky refuses to install hooks outside a git repository. Expected on a fresh scaffold — `git init`, then `npx husky`. |
| A hook fails on its first line with a syntax error | The checkout rewrote it to CRLF. `.gitattributes` pins `.husky/**` to LF; on an already-cloned tree run `git add --renormalize .`. |

## Skills

`.claude/skills/` — prefer these over improvising:

`/ui-kit-component` add a component · `/brand-kit` rebrand the kit ·
`/figma-sync` pull tokens from Figma · `/design-audit` compare the kit with
Figma and write a report plus a fix plan · `/professional-review` review the code
itself — architecture, clean code, naming — and write `professional_review.md`
with a refactor plan · `/kit-doctor` run the gate and triage
failures · `/release-kit` publish · `/update-deps` dependency updates ·
`/kit-guide` refresh the onboarding docs · `/consumer-skills` refresh the skills
a consuming app receives

`/design-audit` and `/professional-review` answer different questions: does the
kit match the design file, versus is the code well written. Neither changes code
on its own — both end at a plan.

A design divergence is **not** automatically the kit's to fix — the design file
has been wrong before, and some divergences are recorded decisions. `/design-audit`
classifies before it proposes; do not shortcut it by editing a component to match
a Figma frame you just looked at.

## Conventions

- Sizes are `sm` / `md` / `lg`, with `md` the default everywhere.
- **Every folder under `showcase/src/` holds one kind of thing.** `pages/`,
  `registry/`, `figma/`, `i18n/`, `brand/`, `assets/` — and `chrome/`, the site's
  own furniture, which is split the same way inside itself:

  | | |
  |---|---|
  | `chrome/ui/` | React components — rail, toolbar, matrix, copy buttons, tour |
  | `chrome/hooks/` | `use-*.ts`, one hook per file |
  | `chrome/lib/` | the pure half — `codegen`, `color`, `prop-params`, `token-values`, `import-line`, `package-name` |

  A new file goes in the folder that matches what it *is*, not what imported it
  first. `chrome/` was a flat bucket of all four kinds once; that is what this
  rule exists to prevent happening again.
- **`~` is the showcase's own root; `@` is the kit's `src`.** Declared in
  `showcase/vite.config.ts`, `showcase/tsconfig.json` **and** `vitest.config.ts`
  — the showcase's one test runs under the kit's Vitest, so all three have to
  agree. Cross-folder reaches use it (`~/registry`, `~/chrome/ui/matrix`);
  siblings and one hop inside `chrome/` stay relative (`./copy`,
  `../lib/codegen`). An alias for a sibling is noise.

  Two places it does **not** reach. `import.meta.glob` patterns are resolved by
  Vite alone: `chrome/lib/codegen.ts` can and does use `@/components/*/index.ts`,
  but `chrome/lib/package-name.ts` reaches the kit *root*, which no alias names,
  so it counts `../` and moves whenever that file moves. And a `~/` import is
  invisible to a plain text search for a folder name — grep for the symbol.
- **A hook lives with its only consumer; a hook with two moves out — unless it
  has a body worth reading.** One trivial consumer-specific hook → keep it in
  that component's file. Two or more consumers, or a hook long enough to be
  followed on its own, → `chrome/hooks/use-<name>.ts`. The kit side already reads
  this way (`src/hooks/`, one file each). `useRailVisible`, `useThemeClass` and
  `useTour` each have exactly one consumer (`app.tsx`) and still keep their own
  file: each owns a piece of persisted or DOM-level state, and inlining three of
  them would make the shell the longest file in the showcase.
  `useRoute`/`useParam`/`useParamWriter` stay in `showcase/src/router.tsx` for
  the opposite reason — they share module-private hash state with
  `subscribe`/`write`/`parse`, so separating them would create a module that
  exists only to be imported back.
- **`transition-all` is not allowed.** List the properties. Read the element
  first: what a class list appears to animate often belongs to a child that
  carries its own transition, and `all` will animate border width and radius,
  which should snap.
- Every change a consumer can observe needs a changeset (`pnpm changeset`).
  Dev-only changes do not. `pnpm release` refuses to publish while changesets
  are pending.
- Breaking changes are documented prop by prop in `README.md` §9, because that
  is the file a consumer actually receives — changesets are stripped from the
  tarball.
- Report real command output. If a check fails, say so with the output rather
  than declaring success.
