# Working with this UI kit

You have just scaffolded a **publishable React component library** — not an app. You restyle it, you publish it, and your teammates then `npm install` it and import one component at a time.

This is the map. [`../README.md`](../README.md) is the territory: when a section here ends with a link, that is where the detail lives.

> Written by the `kit-guide` skill. Run `/kit-guide` after a change to bring it back in line with the code — do not let it rot.

---

## 1. The first ten minutes

```bash
git init          # only on a fresh scaffold — the git hooks need a repo to install into
pnpm install
pnpm dev          # → http://localhost:5175
```

`pnpm dev` opens the **showcase** — every component in the kit on one site, laid out the way the CBAR Figma file lays them out. Start on the overview page, then open any component: you get a live playground with a control per prop, the variant matrices, the props table, and a Figma panel.

npm works identically: `npm install`, `npm run dev`. Every command in this guide has an `npm run <script>` equivalent, and publishing shells out to `npm` internally, so you never need pnpm installed to release.

Four things to know before you go further:

- **`dependencies` is empty, and that is the product.** Installing this kit adds nothing to your teammates' lock file. Radix is compiled into `dist`; `tailwind-merge` is vendored into `src/lib/tw-merge/`. Both licences travel in `NOTICE`.
- **39 components**, each with its own import subpath (`@your-team/ui/button`).
- **The showcase is dev tooling.** `package.json#files` publishes `dist` only — the showcase and `tools/` never reach the package you release. That is also why the showcase is free to be as elaborate as it is.
- **Install scripts are pre-approved.** `esbuild` and `@parcel/watcher` are allowlisted in `package.json` — twice, once per package manager. If you ever add a dependency with an install script, add it to **both** lists or the install exits 0 and the build breaks later for no visible reason.
- **The install also puts three git hooks in place** — but only if the directory is already a git repository. On a fresh scaffold it is not, so the install prints `.git can't be found` and does nothing; `git init` first, or run `npx husky` afterwards. They are §3.

---

## 2. Make it yours

Three edits, before you write any component code.

**`package.json` → `name`.** It says `@cbar/uikit`, published to the Nexus repository named in `publishConfig.registry`. If this kit is not CBAR's, change both — use a scoped name (`@your-team/ui`), because an unscoped one can collide with anything on the public registry. `pnpm release` refuses to publish while a scaffold default (`uikit-plate`, `cbar-uikit-plate`) is still there.

**`LICENSE` → `__COPYRIGHT_HOLDER__`.** Replace it with your name or your organisation's. The year is already filled in. npm packs a root `LICENSE` into every tarball whether or not `files` lists it, so this is the licence your consumers actually receive; `pnpm release` blocks on the placeholder.

**`src/styles/tokens.css` → the brand.** Every colour, radius, space and font in the kit comes from here. Change `--ui-color-brand-500` and the whole library rebrands, because no component hardcodes a value.

That is the entire rebranding surface. The reason it is that small is a three-layer stack:

```
src/styles/tokens.css     primitives (--ui-*)                machine-owned
      ↓
src/styles/theme.css      semantic roles + .dark + @theme    hand-owned
      ↓
src/styles/globals.css    entry: tailwind + tokens + base
```

**Components may only use the semantic layer** — `bg-background`, `text-primary-foreground`, `rounded-md`. Referencing a `--ui-*` primitive directly is a lint error, and it is the indirection that makes a Figma re-sync restyle everything at once instead of nothing.

CBAR models a control as two independent axes — a treatment and a hue — so most components take both:

```tsx
<Button variant="outline" colorPalette="tertiary">Details</Button>
```

Deeper: [README §1](../README.md#1-make-it-yours), [§3 Design tokens](../README.md#3-design-tokens).

---

## 3. Every script, and when you reach for it

### Daily

| Command | What it does | When |
| --- | --- | --- |
| `pnpm dev` | the showcase gallery on :5175 (alias of `showcase`) | always running while you work |
| `pnpm showcase` | the same thing, explicit name | when `dev` is ambiguous in a script |
| `pnpm showcase:host` | the dev server bound to every interface | sharing a link with a teammate, or opening it in another browser profile |
| `pnpm storybook` | Storybook on :6006 — one story at a time | authoring a component in isolation (only if this kit kept Storybook) |

### Before you commit

| Command | What it does | When |
| --- | --- | --- |
| `pnpm lint` | ESLint — **and** the token and dependency rules | every time; this is where the kit's constraints are enforced |
| `pnpm typecheck` | `tsc` over the kit **and** a second pass over `showcase/` | after touching types or the showcase |
| `pnpm test` | Vitest in watch mode, including the jest-axe suite | while changing component behaviour |
| `pnpm test:run` | the same, once, non-interactive | in CI, or before a release |
| `pnpm check:staged` | what the `pre-commit` hook runs, by hand | checking a commit will pass before you make it |
| `pnpm check` | what the `pre-push` hook runs, by hand | before a push you would rather not have to amend |

A `pnpm lint` failure is usually one of four things: a `--ui-*` primitive or a literal colour inside `src/`, an import of a package the kit replaced, or an import of the `radix-ui` barrel. All four are §8.

### The git hooks

You do not have to remember the two tables above, because three hooks in `.husky/` run them for you. `prepare` installs them on `pnpm install` — see §1 for why a fresh scaffold needs `git init` first.

| Hook | What it checks | Cost |
| --- | --- | --- |
| `pre-commit` | the branch name, then ESLint on the **staged files only** | ~2–5s |
| `commit-msg` | the subject line is `<prefix>[(scope)][!]: <text>` | instant |
| `pre-push` | the full `lint`, `typecheck` and `test:run` | ~70s |

The split is a cost rule and it is worth knowing, because it decides where a check you add later goes: staged-scoped and under ~5s belongs in `pre-commit`, whole-repo or over ~10s in `pre-push`, and anything over ~60s in neither. That is why `build`, `verify`, `size` and `audit:shipped` are in no hook at all — they need `dist/` built first, and a hook that takes minutes gets bypassed within days, which protects nothing. CI stays the authority; the hooks are a faster echo of it.

The branch name and the commit subject take the **same** prefixes, so a branch reads `feat/progress-circle` and its commits read `feat: …`:

```
chore feat hotfix bugfix reconcile fix docs refactor test perf ci
```

`main`, `master` and a detached `HEAD` (mid-rebase, mid-bisect) are exempt from the branch check. Git's own generated subjects — `Merge …`, `Revert …`, `fixup!`, `squash!` — are exempt from the message check, or a merge would fail on a message nobody wrote.

To skip them: `HUSKY=0 git commit` or `git commit --no-verify` (and the same for `git push`). There is no `lint-staged` and no `commitlint` — the hooks are four shell scripts in `.husky/`, meant to be read and edited. `lib.sh` holds the shared step and timer machinery the other three source; it must stay tracked, or they fail outright.

### Before you publish

| Command | What it does | When |
| --- | --- | --- |
| `pnpm build` | `exports:gen` → tsup (ESM + CJS + `.d.ts`) → `add-use-client` → Tailwind CLI → `dist/` | before verifying, and in `prepublishOnly` |
| `pnpm verify` | export map current, props tables current, every target exists, `publint` + `attw` clean | after every build you intend to ship |
| `pnpm size` | per-subpath byte budgets | after adding a component or touching an import |
| `pnpm audit:shipped` | which advisories reach *consumers* through `dist` | before every publish, and on a schedule |
| `pnpm changeset` | write the changelog entry, pick the bump | with the change, not after it |
| `pnpm release` | the guided publish | last |

**`audit:shipped` matters more here than in a normal package.** With `dependencies` empty, a consumer's `npm audit`, Dependabot and Snyk all report clean even when the Radix compiled into `dist` has a known vulnerability — they cannot see code they did not install. This repo is the only place it is still visible.

### Occasional

| Command | What it does | When |
| --- | --- | --- |
| `pnpm exports:gen` | regenerates `package.json#exports` from `src/components/*/index.ts` | after adding or renaming a component |
| `pnpm props:gen` | regenerates the showcase's props tables | after changing a component's props |
| `pnpm figma:spec` | rewrites the Figma capture the parity page reads | when the design file moves |
| `pnpm showcase:build` | builds the showcase to `showcase/dist` | deploying the doc site (§5) |
| `pnpm showcase:preview` | serves that build on :5176 as a host would | before deploying it |
| `pnpm build:js` / `build:css` | halves of `build` | debugging the build itself |
| `pnpm lint:fix` | ESLint with `--fix` | mechanical cleanups |
| `pnpm kit:local -- --to ../app` | installs the kit into another project without publishing it | trying a component in a real app (§6b) |

Deeper: [README §6 Build](../README.md#6-build).

---

## 4. Adding a component

```bash
/ui-kit-component <name>     # Claude Code — folder, story, test, index.ts, exports
```

By hand it is four steps:

```bash
npx shadcn@latest add dialog                       # writes a flat src/components/dialog.tsx
mkdir src/components/dialog
mv src/components/dialog.tsx src/components/dialog/
# add index.ts (starting with 'use client'), a .stories.tsx and a .test.tsx
pnpm exports:gen                                   # regenerate package.json#exports
```

**The folder boundary is load-bearing.** `scripts/gen-exports.mjs` scans `src/components/*/index.ts` and generates the `exports` map from what it finds — that is what gives every component its own import path. `shadcn add` writes a flat file; moving it into a folder and regenerating is the whole difference.

Two more things a new component needs:

- **`'use client'`** at the top of every file that uses state, effects or handlers — including `index.ts`. Without it the component breaks inside Next.js Server Components.
- **A showcase entry**: `showcase/src/registry/<name>.tsx` declares the axes, the defaults, one `render`, and the matrices. That single file is what puts the component on the site.

Deeper: [README §5](../README.md#5-adding-a-component).

---

## 5. Publishing the showcase

The showcase is a static site. `pnpm showcase:build` writes `showcase/dist`, which serves from anywhere — GitHub Pages, Vercel, Netlify, S3, a folder on a share.

**There is no server configuration.** The app routes on the URL hash, so every path is `index.html` as far as a host is concerned: no rewrite rule, no `404.html`, no `try_files`.

The one thing a sub-path deployment needs is `SHOWCASE_BASE`, which tells the build where its assets live:

```bash
# served from the domain root
pnpm showcase:build

# served from a sub-path, e.g. <you>.github.io/<repo>/
SHOWCASE_BASE=/my-kit/ pnpm showcase:build
SHOWCASE_BASE=/my-kit/ pnpm showcase:preview      # → http://localhost:5176/my-kit/
```

**Give `SHOWCASE_BASE` to the preview too.** `base` has no effect on the dev server, so preview is the only way to check a sub-path build — and without the variable it serves that build from the root, where every asset 404s for a reason that has nothing to do with the build.

Two things to confirm in the preview: the console is free of 404s, and the Figma panel reports no live connection (live lookups are dev-only, so a hosted showcase never probes a visitor's machine).

A sample GitHub Pages workflow ships at `.github/workflows/showcase-pages.yml`. It is `workflow_dispatch`-only out of the box — enable Pages (Settings → Pages → Source: **GitHub Actions**), run it once from the Actions tab, then uncomment its `push:` trigger. Its header comment carries the Vercel / Netlify / S3 equivalents. Delete it if you host elsewhere.

Deeper: [README §4c](../README.md#4c-publishing-the-showcase).

---

## 6. Publishing the kit

```bash
pnpm changeset          # describe the change, pick major / minor / patch
pnpm changeset version  # applies the bump, writes CHANGELOG.md
npm pack --dry-run      # read the file list before anyone else does
pnpm release            # guided publish
```

**A published version can never be reused**, on npm or on most private registries — `npm unpublish` does not free the number. Everything below happens before anything leaves your machine.

`pnpm release` checks the package name, the `LICENSE` placeholder, the working tree, and whether the version already exists on the target registry; then it builds, verifies, prints the tarball contents, and asks you to type the version number before publishing. `--dry-run` runs every check and stops short.

Pick the bump by what a consumer experiences:

| Bump | When |
| --- | --- |
| **patch** | bug fix, style correction, no API change |
| **minor** | new component, new prop, new token — additive only |
| **major** | removed or renamed export, changed prop semantics, dropped React version — **and a breaking change in code compiled into `dist`**, because Radix reaches consumers even though it is not a dependency |

Two easy-to-miss majors: renaming a component folder changes its import subpath, and removing a `--ui-*` token breaks anyone who referenced it.

For a private registry (Nexus, GitHub Packages), uncomment the matching block in `.npmrc`. **Never hardcode a token there** — use the `${NPM_TOKEN}` form so the secret comes from the environment.

What consumers do with it:

```tsx
import '@your-team/ui/styles.css';        // no Tailwind needed in their app
import { Button } from '@your-team/ui/button';
```

Deeper: [README §7](../README.md#7-publishing), [§8 Consuming the published kit](../README.md#8-consuming-the-published-kit), and the `/release-kit` skill, which walks the whole sequence with you.

---

## 6b. Using it before you publish it

A published version can never be reused, so the worst time to discover a component is wrong is right after `pnpm release`. `kit:local` removes the reason to find out that way:

```bash
pnpm kit:local -- --to ../my-app     # or: npm run kit:local -- --to ../my-app
```

It builds the kit and copies the **same file set `npm publish` would send** into `../my-app/node_modules/`, then prints what that app has to change. Add `--watch` to keep it in sync while you work. It is plain Node — `node scripts/link-local.mjs --to ../my-app` works with no package manager involved at all.

Three things worth knowing:

- **It copies rather than links.** `npm link` symlinks, and Node then finds `react` inside the kit before the app's own — two React copies and an `Invalid hook call` from the first hook. A copied directory has no `node_modules` beside it, so there is only ever one React.
- **A copy is in no lock file**, so an `install` in the target can prune it. Re-run the script, or use `--mode pack`, which installs a real tarball and is recorded as a dependency.
- **TypeScript needs `radix-ui` as a dev dependency** in the consuming app. The shipped `.d.ts` files still name it for their prop types even though the JavaScript has it compiled in; without it, eighteen components type-check as taking no props.

Deeper: **[`kit-local.md`](./kit-local-development/kit-local.md)** — the full guide: every flag, what the consuming app has to change for Vite and for Next, the copy-vs-pack trade, and a troubleshooting table. Shorter: [README §8b](../README.md#8b-using-the-kit-without-publishing-it).

---

## 6c. Handing the kit to an app team

Installing the package is half the handover. The other half is that the team on
the receiving end now has 39 components, two independent style axes and a
types-only dev dependency to get right, and their Claude Code knows none of it.

`consumer-skills/` is that half. Three skills, written for an application rather
than for this repo:

| Skill | Fires when | Does |
| --- | --- | --- |
| `ui-kit-setup` | just installed, or something renders unstyled | stylesheet route, dark mode, the `radix-ui` dev dependency, root providers, bundler flags |
| `ui-kit-usage` | any UI is written in that app | which component to reach for, subpath imports, `variant` × `colorPalette`, safe overrides, icons |
| `ui-kit-review` | asked to review or align the UI | audits their code against the kit, findings first, then fixes |

Copy all three folders into their project:

```bash
cp -r consumer-skills/ui-kit-* ../their-app/.claude/skills/
```

They travel by copy, not through npm — `files` is `dist`-only on purpose. Copy
all three together: `ui-kit-setup` and `ui-kit-review` both read
`ui-kit-usage/references/`.

Those references are generated, so they cannot drift from the kit:

```bash
pnpm consumer:gen          # rewrite them
pnpm consumer:gen --check   # part of `pnpm verify`
```

Run it after adding a component, after a rebrand, and after any change to the
token layer. The `/consumer-skills` skill does the whole job — regenerate, then
reconcile the hand-written prose. Details: `consumer-skills/README.md`.

---

## 7. The Figma bridge

Everything the showcase calls "live" — the side-by-side render, the computed-style diff, `/figma-sync`, `pnpm figma:spec` — runs against a small loopback service in `tools/figma-bridge/`. It reads Figma through a **plugin** rather than the REST API: no token, no rate limit, works on a free plan, works offline from the internet's point of view. It has no dependencies, so there is nothing to install.

### One-time setup

1. **Figma desktop → Plugins → Development → Import plugin from manifest…** and pick `tools/figma-bridge/plugin/manifest.json`.
2. **Restart Claude Code once** so the `figma_*` MCP tools appear. `.mcp.json` already registers the server (`node tools/figma-bridge/mcp.mjs`).

### Every session

1. Open your file in Figma.
2. Run **Plugins → Development → CBAR Figma bridge**.
3. **Leave the plugin window open** — closing it breaks the connection.

Check it with `figma_health` in Claude Code, or `node tools/figma-bridge/cli.mjs health`. The answer names the bridge, the plugin and the queue; **`plugin: connected`** is the line that matters.

You do not need to start the bridge by hand: the MCP server hosts it in its own process. `node tools/figma-bridge/bridge.mjs` is only for the CLI-without-Claude-Code path, or to point the showcase at a bridge elsewhere (`FIGMA_BRIDGE_URL`).

### What it feeds

| | |
| --- | --- |
| `/figma-sync` | reads the Figma **Variables** table and rewrites `tokens.css`, showing the diff first |
| `pnpm figma:spec` | regenerates `showcase/src/registry/figma-spec.json`, the capture the parity page diffs against |
| The showcase's Figma panel | CBAR's drawing of the current variant beside the kit's render, plus a computed-style comparison |

**With the bridge down, all of it still renders** from the stored capture — live is additive, never required. In the showcase the toolbar indicator is also the switch (`?figma=live|off`), and live is on only in dev.

**Findings get written down, not fixed in passing.** `#/parity?tab=findings` is the report of where the design file and the kit disagree, with the evidence and which side has to change. The known one: CBAR's Button set paints `primary` turquoise and `secondary` navy, the opposite of everything else in the file — the kit matches the variable table and Button is the outlier. Do not "fix" that by swapping ramps in `tokens.css`; the next sync would overwrite it anyway.

Deeper: [README §4b](../README.md#4b-two-ways-to-look-at-it), `tools/instructions-mcp.md` for day-to-day commands, `tools/figma-bridge/README.md` for the protocol.

---

## 8. Rules that are not negotiable

Each of these is cheap to break and expensive to undo.

**`src/styles/tokens.css` is machine-owned.** `figma-sync` rewrites it wholesale. A hand edit survives exactly until the next sync.

**`src/icons/generated/` is machine-owned too** — 250 icons in nine modules, generated from the Figma file. Treat them as data; replace them wholesale. The hand-owned half is `src/icons/icons.tsx`: the aliases and the three Lucide keepers.

**Never import the `radix-ui` barrel.** Always the per-primitive subpath:

```ts
import * as Slot from 'radix-ui/slot';   // yes
import { Slot } from 'radix-ui';         // no — lint error
```

The barrel is a single module reachable from all 21 Radix-using components, so bundlers park it in one shared chunk and a component that only wants `Slot` drags in the whole library. Measured: Button goes from 10 kB to 434 kB.

**`src/lib/tw-merge/` is vendored upstream source.** It carries exactly one deliberate edit (`import` → `import type`, for `verbatimModuleSyntax`), and a test compares the folder against the installed package file by file. Tidying it fails the suite.

**`dependencies` stays empty.** Anything you put there lands in every consumer's lock file forever. Reach for a component before reaching for a package; `pnpm lint` already blocks the ones this kit replaced — `clsx`, `class-variance-authority`, `lucide-react`, `cmdk`, `sonner`, `vaul`, `rc-*`, and every date library.

**No `--ui-*` primitives and no literal colours in `src/`.** `#hex`, `rgb()`, `hsl()`, `oklch()` are all lint errors. The exceptions are `--ui-duration-*`, `--ui-ease-*` and `--ui-z-*`, which have no semantic layer above them because nothing re-maps them per theme.

**A new dependency with an install script goes in both allowlists** — `allowScripts` for npm, `pnpm.onlyBuiltDependencies` for pnpm. Each manager ignores the other's field.

**`showcase/src/showcase.css` holds the showcase's Tailwind `@source`, never `src/styles/globals.css`.** globals.css is what the Tailwind CLI compiles into the published `dist/styles.css`, so a glob added there ships showcase-only classes to every consumer.

---

## 9. When something breaks

**Storybook won't start — `ERR_DLOPEN_FAILED`.** Storybook depends on `oxc-resolver`, whose native binding has no Microsoft reputation and is blocked by Smart App Control / WDAC. It is a machine policy, not a defect in the kit — reinstalling does not help. The showcase runs fine, and so does CI.

**A copied `localhost:5175` link is refused in another window.** `pnpm showcase` binds `localhost`, which on Windows is the IPv6 loopback alone — a browser profile that resolves `localhost` to IPv4 (an incognito window, a phone) gets a connection error on a link that works where you copied it. `pnpm showcase:host` binds both families and the LAN. It also exposes the dev server and the kit's source to that network; for anything more than a look over a shoulder, deploy the built site instead.

**The Figma panel shows nothing.** The plugin window is closed, or the bridge is not running. Check `figma_health`. Nothing else is affected — the capture-driven half of the panel keeps working.

**`SHOWCASE_BASE` from Git Bash becomes a Windows path.** MSYS rewrites a leading-slash value, so `/my-kit/` arrives as `C:/Program Files/Git/my-kit/`. Use PowerShell or `cmd` — or prefix the command with `MSYS_NO_PATHCONV=1`.

**`ERR_WORKER_OUT_OF_MEMORY` right after `CJS ⚡️ Build success`.** The `.d.ts` bundle is built in a worker that holds the whole type graph at once. `scripts/run-tsup.mjs` raises the heap ceiling for exactly this; if it still happens, set a larger `--max-old-space-size` in `NODE_OPTIONS` yourself and the script leaves your value alone.

**`pnpm verify` fails on the export map.** You added or renamed a component without running `pnpm exports:gen`. Same for the props tables and `pnpm props:gen`.

**The install prints `.git can't be found`.** Husky refuses to install hooks outside a git repository, which a freshly scaffolded kit is not yet. Nothing else is affected — the install itself succeeded. Run `git init` and then `npx husky`; `git config --get core.hooksPath` should answer `.husky/_`.

**A hook fails on its first line with a shell syntax error.** The checkout rewrote it to CRLF. `.gitattributes` pins `.husky/**` to LF for exactly this; on a tree that was already cloned, `git add --renormalize .` fixes it.

---

## Where to go deeper

| Topic | Where |
| --- | --- |
| Architecture, the three rules | [README §2](../README.md#2-architecture) |
| Token layers, palettes | [README §3](../README.md#3-design-tokens) |
| The component list and its edge cases | [README §4](../README.md#4-what-ships) |
| The 250-icon set | [README §4a](../README.md#4a-the-icon-set) |
| Showcase vs Storybook, the parity page | [README §4b](../README.md#4b-two-ways-to-look-at-it) |
| Migration notes for existing consumers | [README §9](../README.md#9-migrating) |
| Using the kit before publishing it, in full | [`kit-local.md`](./kit-local-development/kit-local.md) |
| Handing the skills to an app team | [`consumer-skills/README.md`](../consumer-skills/README.md) |
| Figma commands, day to day | `tools/instructions-mcp.md` |

| Skill | What it does |
| --- | --- |
| `/kit-guide` | rewrites this document from the current code |
| `/ui-kit-component` | adds a component in the shape the build expects |
| `/figma-sync` | pulls tokens out of Figma into `tokens.css` |
| `/design-audit` | compares the kit with Figma and writes a report plus a fix plan |
| `/professional-review` | reviews the code itself — architecture, clean code, naming — and writes `professional_review.md` with a refactor plan |
| `/brand-kit` | makes the kit the team's own — name, colour, licence, wordmark |
| `/kit-doctor` | runs the whole gate and triages what fails |
| `/release-kit` | walks a publish end to end |
| `/update-deps` | refreshes dependencies without breaking consumers |
| `/consumer-skills` | refreshes the skills a consuming app receives |
