# UI Kit

A publishable React component library. Restyle it, publish it to npm or a private registry, and your teammates import components one at a time.

**Zero runtime dependencies.** Installing this kit adds **nothing** to your teammates' lock file — no icon library, no date library, no command palette, no toast package, and not Radix either. Every component is code in this repo that you can read, change and own. The two libraries the kit genuinely needs are still here, just not as obligations you pass downstream: `radix-ui` carries the accessibility contract for dialogs, selects and popovers (rewriting it is a project, not a file) and is **compiled into `dist`**, and `tailwind-merge` — what lets a consumer's `className` override a built-in utility at all — is **vendored into `src/lib/tw-merge/`**. Both licences travel in `NOTICE`, because a build that inlines code redistributes it.

```bash
pnpm install
pnpm dev          # the showcase gallery → http://localhost:5175
```

That opens the **showcase** — every component on one page, laid out the way CBAR's Figma file is, and the surface you can publish as a static doc site. Storybook is optional here; if you kept it at scaffold time it is `pnpm storybook` (`→ :6006`). See [§4b](#4b-two-ways-to-look-at-it) for what each is for and [§4c](#4c-publishing-the-showcase) for deploying the gallery.

npm works just as well — `npm install`, then `npm run dev`. Every script in this README has an `npm run <script>` equivalent, and `pnpm release` shells out to `npm` internally, so publishing never needs pnpm installed.

**New here?** [`docs/instructions.md`](docs/instructions.md) is the short version of this README — the scripts and when to run each, publishing the showcase, publishing the kit, setting up the Figma bridge, and the rules that are easy to break. Azerbaijani: [`docs/instructions.az.md`](docs/instructions.az.md). Both are written by the `kit-guide` skill; run `/kit-guide` to bring them back in line after a change.

> **Install scripts are allowlisted.** Both modern package managers refuse to run a dependency's install script unless you approve it, and `esbuild` — the engine behind tsup, Vite and Vitest — needs its `postinstall` to place the platform binary. Under pnpm, skipping it makes the *build* fail; under npm you get a warning. So `esbuild` and `@parcel/watcher` are pre-approved in `package.json`, once per manager:
>
> ```jsonc
> "allowScripts": { "esbuild": true, "@parcel/watcher": true },   // npm 11+
> "pnpm": { "onlyBuiltDependencies": ["esbuild", "@parcel/watcher"] }  // pnpm 10+
> ```
>
> **If you add a dependency that ships an install/postinstall script, add it to both lists** — `npm approve-scripts <pkg>` and `pnpm approve-builds` write them for you. Otherwise the install exits 0 and something breaks much later, with no obvious cause.

---

## 1. Make it yours

**One command does all of it:**

```bash
pnpm brand
```

It asks for a package name, a display name, a brand colour, a copyright holder and a repository URL, then rewrites every surface that still carries the design system this kit was forked from: the package identity and npm metadata, the eleven `--ui-color-brand-*` steps in `tokens.css`, the `LICENSE` holder, the Storybook chrome, and the showcase wordmark — the vendored logo it replaces is a third party's trademark, so removing it is not optional.

The brand ramp is generated from your single seed colour, which lands exactly at step 500. Before writing anything it checks the same four contrast pairs the test suite asserts and **refuses a seed that cannot carry legible text**; if the tints cannot clear AA against your colour it lightens them until they do and tells you it did. `pnpm brand --check` reports what is still unbranded without writing — that check gates `pnpm release` and the kit's own CI, so a kit cannot be published under the name it was scaffolded with.

If your palette lives in Figma, use [`/figma-sync`](#3-design-tokens) instead; the two write the same file and Figma wins once it is the source of truth.

<details>
<summary>Doing it by hand</summary>

Three edits, and `pnpm brand --check` will tell you if you missed one:

**`package.json`** — set the real package name. Scoped names are strongly preferred; an unscoped name can collide with anything on the public registry.

```jsonc
{
  "name": "@your-team/ui",
  "version": "0.1.0"
}
```

**`src/styles/tokens.css`** — this is where every colour, radius, space and font in the kit comes from. Change `--ui-color-brand-500` and the whole library rebrands. No component hardcodes a value.

**`LICENSE`** — replace `__COPYRIGHT_HOLDER__` with your name or your organisation. The year is already filled in. This kit is yours, so the copyright is too; the scaffolder cannot know whose name belongs there. 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` refuses to publish while the placeholder is still there.

Doing it this way leaves the Storybook theme literals, the showcase wordmark and the npm metadata (`repository`, `homepage`, `bugs`, `author`) for you — `pnpm brand --check` lists them.

</details>

---

## 2. Architecture

```
src/
  index.ts                  root barrel
  styles/
    tokens.css              primitives  (--ui-*)          ← machine-owned
    theme.css               semantic roles + @theme inline ← hand-owned
    globals.css             entry: tailwind + tokens + base layer
  lib/                      cn(), cx(), cva(), shared variant fragments
  icons/                    250 CBAR icons          → `<pkg>/icons`
    icon.tsx                the 24×24 chassis
    icons.tsx               aliases + Lucide keepers       ← hand-owned
    generated/              one module per category        ← machine-owned
  hooks/
  components/
    button/
      button.tsx
      button.stories.tsx
      button.test.tsx
      index.ts              ← this file is what creates the `/button` subpath
```

**One folder per component.** The folder boundary is not cosmetic: `scripts/gen-exports.mjs` scans `src/components/*/index.ts` and generates the `exports` map from it. That is what gives every component its own import path.

**Three rules that keep the kit coherent:**

1. Components use only the semantic tokens (`bg-background`, `text-primary-foreground`, `rounded-md`). Never reference a `--ui-*` primitive directly — that is the indirection that makes a Figma re-sync restyle everything at once.
2. Every component file that uses React state, effects or event handlers starts with `'use client'`, including its `index.ts`. Without it the kit breaks inside Next.js Server Components.
3. The kit ships **zero runtime dependencies**. Icons, variants, the command palette, toasts, the calendar, the table and the tree are kit-local code; `radix-ui` is compiled into `dist` and `tailwind-merge` is vendored into `src/lib/tw-merge/`. Reach for a component before reaching for a package; `pnpm lint` fails on the imports that would undo this — including `import … from 'radix-ui'`, which must always be the per-primitive subpath (`radix-ui/slot`, `radix-ui/dialog`). The barrel is one module shared by all 21 Radix-using components, so it defeats code-splitting: measured, it takes Button from 10 kB to 434 kB.

---

## 3. Design tokens

`tokens.css` holds raw values in oklch, grouped into ramps (`neutral`, `brand`, `secondary`, `tertiary`, `red`, `green`, `yellow`, with `danger`/`success`/`warning` aliased onto the last three), plus radius, spacing, type, elevation, motion and z-index scales. The values are generated from the CBAR Design System Figma library.

`theme.css` maps those primitives onto roles (`--background`, `--primary`, `--muted-foreground`, …), defines the `.dark` overrides, and exposes everything to Tailwind via `@theme inline`.

It also defines the **colour palettes**. CBAR models a control as two
independent axes — a treatment (`solid`, `subtle`, `surface`, `outline`,
`ghost`) and a hue (`primary`, `secondary`, `tertiary`, `black`, `red`, `green`,
`yellow`) — and a component takes both:

```tsx
<Button variant="outline" colorPalette="tertiary">Details</Button>
```

Each `.palette-*` class sets the same handful of role variables (`--ctl-solid`,
`--ctl-subtle`, `--ctl-fg`, `--ctl-border`, and the two hover steps), and a
treatment reads them without knowing which palette is active. That is what keeps
5 × 7 combinations to 12 lines of variant code per component, and it means a new
palette is one class here rather than an edit in every component.

The split exists so a design-tool sync can replace the primitives wholesale without touching the semantic mapping:

```bash
# with the Figma Dev Mode MCP server connected
/figma-sync <figma-file-url>
```

The `figma-sync` skill (in `.claude/skills/`) reads your Figma Variables, rewrites `tokens.css`, shows you the diff before writing, and updates the `theme.css` mapping only when a variable name has no counterpart. Without the MCP server it falls back to a Tokens Studio JSON export.

---

## 4. What ships

39 components, each with its own import path, story and — where there is behaviour to test — a test suite.

| Group | Components |
| --- | --- |
| **Form** | `button` `input` `textarea` `label` `checkbox` `radio-group` `switch` `select` `combobox` `field` `form` |
| **Layout & feedback** | `card` `badge` `avatar` `alert` `separator` `skeleton` `spinner` `progress` |
| **Overlay** | `dialog` `sheet` `drawer` `dropdown-menu` `popover` `tooltip` `command` |
| **Navigation** | `tabs` `accordion` `breadcrumb` `pagination` `toast` |
| **Data & input-heavy** | `data-table` `virtual-list` `tree` `tree-select` `date-picker` `range-slider` `upload` `input-number` |

The last row is where Radix stops, and it is written from scratch here: a table with pinned columns and expandable rows, a windowed list, a tree with tri-state checkboxes, a calendar that parses loose text input, an abortable upload. Each was a third-party package once; each is now kit code you can read and change.

Notes on the edges of that list:

- **`input`** takes `startElement` / `endElement` — CBAR's `.leftElement?` and `.rightElement?`. The leading slot is inert so a click reaches the field; the trailing one stays interactive, which is where a clear button or a reveal-password toggle belongs. With either set the field gains a wrapper that carries the box, so `className` sizes the wrapper and `classNames.input` reaches the `<input>`. With neither, it renders a bare `<input>` exactly as before.
- **`field`**'s `FieldLabel` takes `required`, CBAR's `.isRequired?`. The marker is `aria-hidden` — an asterisk read aloud is noise — so set `required` on the control too, which is where assistive technology already looks.
- **`select`**'s trigger takes `clearable` (and `clearLabel` for its accessible name), CBAR's `.isClearable?`. Clearing sets the value to `''`, which is how Radix returns a Select to its placeholder; that is also why no `SelectItem` may use `''` as its own value. The reset control renders as a *sibling* of the trigger, layered over it — `SelectPrimitive.Trigger` is a real `<button>` and one button may not contain another.
- **`form`** is the react-hook-form binding. `react-hook-form` is an *optional* peer dependency, so `form` is reachable only at `<pkg>/form` — it is not in the root barrel, which would otherwise break consumers who do not use it. Everything else works without it. If you are not on react-hook-form, `field` gives you the same layout without the library.
- **`toast`** takes a `theme` prop, defaulting to `'inherit'`. Toasts portal onto `document.body`, which sits inside `<html>`, so a `dark` class on the root element already reaches them; force `'light'`/`'dark'` only if your app scopes dark mode to a wrapper the portal escapes.
- **`date-picker`** exports `DatePicker` and `DateRangePicker` from one path, and its date maths (`formatDate`, `parseDate`, `addMonths`, …) with them — format the picker's value elsewhere in your app without installing a date library.
- **`icons`** lives at `<pkg>/icons` rather than in the root barrel: names like `CheckIcon` collide easily with an app's own set. See §4a — it is the whole CBAR library, not just what the components draw.

## 4a. The icon set

`<pkg>/icons` carries **CBAR's whole library: 250 icons in nine categories**, generated from the Figma `Icons` page. CBAR draws every one of them twice — an outline family and a solid one — so each export takes a `variant`:

```tsx
import { HomeIcon, CloudUploadIcon } from '@cbar/uikit/icons';

<HomeIcon />                        // outline, the default
<HomeIcon variant="solid" />        // the solid family
<CloudUploadIcon size={32} className="text-primary" />
```

| | |
| --- | --- |
| Categories | `General` `Interface` `Navigation` `Communication` `Media` `Files` `Devices` `Status` `Brands` |
| Grid | 24×24, painted in `currentColor` — colour comes from the surrounding text |
| Sizing | `size={n}` sets both dimensions; a `size-*` utility on `className` beats it |
| A11y | `aria-hidden` unless you pass `aria-label`, `role` or `title` |

Three things worth knowing:

- **23 icons are drawn once.** Arrows, carets and a few interface marks have no solid form, so CBAR reuses one drawing; on those, `variant` is accepted and changes nothing. `iconManifest` records which ones with a `shared` flag.
- **Nothing is bundled that you do not import.** Every export is a side-effect-free `const`, so a consumer importing one icon out of 250 ships about **1 kB** — `pnpm size` asserts exactly that.
- **`iconManifest` and `iconCategories`** ship alongside the components and carry no geometry: name, category, which families exist. That is what the showcase gallery is built on, and what you would build a picker on.

The set is generated, not hand-written. `src/icons/generated/` is one module per category plus the manifest, produced from the Figma file by the tooling in the [create-reactivite](https://github.com/jsznpm/create-reactivite) repo (`tools/figma-cbar`), which does not travel with a scaffolded kit. Treat those files as data: replace them wholesale, do not hand-edit them. What *is* hand-owned is `src/icons/icons.tsx`: eleven aliases so the kit's own components can ask for `ChevronDownIcon` while CBAR calls it `Interface/Caret down`, and the three icons Lucide still supplies (`CircleIcon`, `CircleCheckIcon`, `Loader2Icon`) because CBAR has no counterpart. That attribution lives in `NOTICE`, which is part of the published tarball — keep it there.

**The `Brands` category is 46 third-party logos** (Google, Adobe, Slack, …). They travel with the kit as CBAR drew them; publishing a kit means redistributing them, and the trademarks belong to their owners. Drop the category from `src/icons/generated/` if that is not a position you want to be in.

## 4b. Two ways to look at it

```bash
pnpm dev        # the showcase gallery @ :5175 — every component, laid out like Figma
pnpm showcase   # the same thing, explicit name (dev is an alias for it)
pnpm storybook  # Storybook @ :6006 — one story at a time (only if you kept it)
```

**The showcase is the primary surface, and the one you publish.** It answers *what does the whole kit look like right now, and does it match the Figma file?* — a plain Vite app (`showcase/`) that imports the kit from `src/`, so there is nothing to build first and an edit hot-reloads. It is also a static site you can deploy as living documentation; see [§4c](#4c-publishing-the-showcase). Each component gets one page:

- a **playground** — a live instance with a control per axis, the JSX that produces it, and a **Share** button (see below);
- **variant matrices** — two axes crossed into a labelled grid (`variant × colorPalette`, `variant × size`, …). A CBAR component set on the Figma canvas *is* that grid, so the two sit side by side and a divergence is visible rather than inferred;
- **compositions** for the things a matrix cannot express — an open Dialog, a sortable DataTable, a form showing its validation messages;
- a **props table** and, where CBAR publishes them, the **measured heights, padding and radii**;
- a **Figma panel** — every property CBAR's component set declares, and, when the bridge is running, the variant that matches what the playground is showing (see below).

**A playground state is a link.** Every control you move is written into the hash — `#/button?p.variant=outline&p.colorPalette=green&p.size=lg&p.disabled=1` — so a reload keeps what you built and the **Share** button beside the playground heading has something to hand out. It copies two forms of the same view: the **component page**, for a teammate who also wants the props table and the Figma panel, and a **preview link** (`#/embed/button?…`) that renders the component alone on a page with no rail, no toolbar and no tables — for the designer or PM who asked what it looks like, not how it is imported. Both carry the theme and the language you were looking at, so a dark Russian link opens dark and Russian; the preview keeps a way back into the gallery, and a copy button for the code. Props whose value is the component's default are left out, so an untouched playground still gives a clean URL, and they are dropped when you navigate to another component — a `variant=outline` following you onto the next page would silently reconfigure it.

An overview page renders all 40 entries in one scroll, and a tokens page reads every `--ui-*` primitive and semantic role live out of the document — so a `figma-sync` run shows up there without anyone editing the page. The showcase also carries its own accessibility audit — open any page with `?a11y=on` (or the **Axe** switch in the toolbar) and it runs axe-core in the browser, grouped by impact, with a button per offending node that scrolls it into view.

**Storybook is optional.** If you kept it at scaffold time (`pnpm storybook`, `→ :6006`), it is the component-authoring surface — one story at a time, with `@storybook/addon-a11y` running axe in a real browser with `test: 'error'`, which is the CI-enforced contrast check. If you scaffolded with `--no-storybook`, none of that ships: no `.storybook/`, no `*.stories.tsx`, no storybook dependencies, and `pnpm dev` is the showcase. Either way the showcase is what you publish. To add Storybook back to a stripped kit, reinstate the five devDependencies (`storybook`, `@storybook/react-vite`, `@storybook/addon-a11y`, `@storybook/addon-docs`, `eslint-plugin-storybook`), the `storybook`/`build:storybook` scripts and a `.storybook/` config — or scaffold a fresh kit and keep it.

**An icons page** (`/icons`) puts the whole 250-icon set on one screen: search, a category filter, an outline/solid/both switch and a size row, all held in the hash so a filtered view is a link. Clicking an icon opens a panel that copies it four ways — the import plus JSX, the rendered SVG, the export name, and a standalone React component with no dependency on the kit. All four follow the controls: pick 48px and the snippet says `size={48}` and the SVG comes out 48×48. Nothing is stored either — every string is derived from a render, so the drawing has exactly one home.

**A parity page diffs the kit against the design file.** `showcase/src/registry/figma-spec.json` is a capture of every component set on CBAR's canvas — every set's node id, and every component property with its type, its accepted values and the one Figma defaults to. The page walks it against each entry's `axes`, reporting per axis: matched values, values only in Figma, values only in the kit, axes with no prop at all, and the sets with no component. Figma `state` axes are marked and excluded from the counts, because hover/active/disabled are drawn in CSS here rather than taken as props.

It also lists each set's **slots** — the properties that are not variant axes, which no variant name mentions. That row exists because it caught a real gap: CBAR's Button carries `iconLeft?` *and* `iconRight?`, both on by default, so its set draws four icon arrangements, and the showcase was demoing one. The kit takes icons as children, so there is nothing to diff — but it is the checklist a demo should cover.

Today that reads 28 sets, 21 linked to a component, 11 carrying an axis gap. Point an entry at a set with `figma: { set: 'Button' }`, and reconcile the naming with the three optional fields on that link — `axisMap` when the axes are named differently (`Badge`'s is `color`, `Switch`'s is `Size`), `valueMap` when the values are (`tertiary` here is `third` there), `pin` when CBAR has an axis the kit has no prop for at all (`state: 'default'`).

**Regenerate the capture with `pnpm figma:spec`.** It asks the local bridge for `sets` over the whole document and rewrites the JSON; `--from <sets.json>` reduces an answer you already have, so the file can be rebuilt with no Figma session. Run it whenever the design file moves.

### The Figma panel, and the live half

Each component page ends with a **Figma** section. The properties table is read from that capture and is always there — every property CBAR declares, its type (`VARIANT` / `BOOLEAN` / `TEXT` / `INSTANCE_SWAP`), its values and default, and the kit prop it lines up with.

With the bridge plugin open in Figma it does more. It resolves the playground's current selection to a real variant node, then shows:

- CBAR's own drawing of that variant, exported live, **beside** the kit's render of the same props;
- a **computed-style table** — Figma's `getCSSAsync()` against `getComputedStyle` on the element next to it, normalised so `oklch(0.392 0.098 244.9)` and `#004976` compare as the same colour, with the label's type settings pulled from the text node (`getCSSAsync` describes the frame, not the text inside it);
- a **Figma-da aç** button that moves the real Figma viewport onto that variant.

That is the comparison the parity page cannot make: parity says an axis exists, this says the line-height is 8px short.

It earned its keep on the first run, and the finding is worth repeating because of how it resolved. CBAR's **Button** set paints `colorPalette=primary` turquoise and `secondary` navy — the opposite of what this kit renders. The kit is not the one that is wrong: Figma's own variable table has `brand/primary/500` = `#004976` (navy) and `brand/secondary/500` = `#4BC7B5` (turquoise), `surface/colored/primary` aliases the first, and `IconButton` and `ProgressCircle` both draw their `primary` variants from the navy ramp. Button is the outlier — 160 of its 560 variants wired to the wrong surface role. **Do not "fix" it by swapping the ramps in `tokens.css`**: that would trade one wrong component for a whole kit that disagrees with the design system it is generated from, and `tokens.css` is machine-owned, so the next `figma-sync` would overwrite the edit anyway. The fix belongs in the Figma file.

**Findings are written down, not fixed in passing.** `#/parity?tab=findings` in the showcase (`showcase/src/pages/parity-findings.tsx`) is the report — the evidence for each discrepancy, which side has to change, and the commands to re-verify it. It sits behind a tab on the generated diff, so the two halves are one click apart: the **Report** tab says *where* the file and the kit disagree, the **Findings** tab says *why* and what to do about it. Add to it rather than fixing a design file from the code side.

**`/design-audit` is the skill that does that walk for you.** It reads the design file through the bridge, compares every variant axis and its default, the non-variant slots, and each variant's geometry, colour and type against what the kit renders — then gives each divergence one of five verdicts before proposing anything: `kit-defect`, `design-defect`, `deliberate`, `token-drift`, `not-comparable`. Only the first two of those produce work, and they produce it in different places: a `kit-defect` becomes a numbered item in a fix plan, a `design-defect` becomes a Findings entry and no code change at all. It writes both documents into `docs/design-audit/` with the date in the filename, so the next run reads as a diff rather than the same wall of text. Run it after a `figma-sync`, before a release, or whenever "does this match the design?" is about to be answered by eye.

The panel talks to `http://localhost:7332` directly; the bridge answers every request with `Access-Control-Allow-Origin: *`, so there is no proxy involved. Live lookups are **on by default in `pnpm showcase` and off in a `showcase:build`** — a built site has no business probing a visitor's localhost. The indicator in the toolbar is also the switch, and it is held in the hash as `?figma=live|off`.

Two things worth knowing if you extend it:

- `showcase/src/registry/<name>.tsx` is the only file a component needs. It declares the axes, the defaults, one `render`, and the matrices you want.
- `showcase/src/showcase.css` — and **not** `src/styles/globals.css` — is where the showcase's Tailwind `@source` lives. `scripts/build-css.mjs` feeds globals.css to the Tailwind CLI to produce the published `dist/styles.css`, so a glob added there would ship showcase-only classes to every consumer of your kit.

The showcase is dev tooling: `package.json#files` publishes `dist` only, so none of it reaches the package you release.

### The bridge itself — `tools/figma-bridge/`

Everything above that says "live" runs against a small loopback service that **ships with this kit**: `tools/figma-bridge/` was copied in when the project was scaffolded, and `.mcp.json` already registers it with Claude Code. It reads Figma through a plugin rather than the REST API — no token, no rate limit, works on a free plan — and it has no dependencies of its own, so there is nothing to install.

Set it up once:

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.
3. Open your file in Figma, run **Plugins → Development → CBAR Figma bridge**, and leave the plugin window open — closing it breaks the connection.

Check it with `figma_health` (or `node tools/figma-bridge/cli.mjs health`); the answer names the bridge, the plugin and the queue, and **plugin: connected** is the line that matters. The MCP server hosts the bridge in its own process, so `node tools/figma-bridge/bridge.mjs` is only needed when you use the CLI without Claude Code — or want to point the showcase at a bridge somewhere else (`FIGMA_BRIDGE_URL`).

Day-to-day commands are in `tools/instructions-mcp.md`; the protocol and the reason answers are chunked are in `tools/figma-bridge/README.md`. What it feeds: `/figma-sync` (reads the variable table directly, which is the only way to get ramp names right), `/design-audit` (the four-level comparison above), `pnpm figma:spec` (regenerates `showcase/src/registry/figma-spec.json`), and the showcase's live panel.

`tools/` is not in `package.json#files` either — like the showcase, it never reaches the package you publish.

## 4c. Publishing the showcase

The showcase is a static site. `pnpm showcase:build` writes `showcase/dist`, which you serve from anywhere — GitHub Pages, Vercel, Netlify, an S3 bucket, a folder on a share.

**Sub-path deployments need one thing: `SHOWCASE_BASE`.** The app routes on the URL hash, so every path is `index.html` as far as the server is concerned — there is **no rewrite rule, no `404.html`, no `try_files`** to configure. The only thing a sub-path changes is where the built assets are fetched from, and that is what `base` sets:

```bash
# served from the domain root (a user/org Pages site, most hosts)
pnpm showcase:build                       # SHOWCASE_BASE unset → "/"

# served from a sub-path (a project Pages site at <you>.github.io/<repo>/)
SHOWCASE_BASE=/my-kit/ pnpm showcase:build
```

**Look at the build before you deploy it.** `pnpm showcase:preview` serves `showcase/dist` on <http://localhost:5176> exactly as a host would — the dev server does not prove this, because `base` only applies to a built site. Give `SHOWCASE_BASE` to **both** commands, or preview will serve the sub-path build from the root and every asset 404s for a reason that is not the build's fault:

```bash
SHOWCASE_BASE=/my-kit/ pnpm showcase:build
SHOWCASE_BASE=/my-kit/ pnpm showcase:preview      # → http://localhost:5176/my-kit/
```

Two things to confirm there: the console is free of 404s (that is `base` being right), and the Figma panel reports no live connection (live lookups are `import.meta.env.DEV`-only, so a hosted showcase never probes a visitor's localhost).

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 to publish on every push to `main`. It computes `SHOWCASE_BASE` as `/<repo>/` for you; for a user/org site drop that env line. The header comment carries the Vercel / Netlify / S3 equivalents. Delete the file if you host elsewhere — it is a switch, not a mandate.

**A share link only reaches as far as the server behind it.** `pnpm showcase` binds `localhost` — on Windows that is the IPv6 loopback (`::1`) alone, so `http://127.0.0.1:5175` is refused and a browser profile that resolves `localhost` to IPv4 (a second window, an incognito one, a phone) reports a connection error on a link that works in the window you copied it from. `pnpm showcase:host` is the same dev server bound to every interface, IPv4 and IPv6: incognito works, and a teammate on the same network opens the machine's LAN address (`http://192.168.x.y:5175/#/button?p.variant=outline`) — Vite prints it as **Network** at startup. Bear in mind that this exposes the dev server, and with it the kit's source, to everyone on that network. For anything beyond a quick look over someone's shoulder, deploy the built site above and share *that* URL — hash and all, the link format is identical.

> **Windows note:** do not set `SHOWCASE_BASE` from Git Bash — MSYS rewrites a leading-slash value into a Windows path, so `/my-kit/` arrives as `C:/Program Files/Git/my-kit/`. Use PowerShell, `cmd`, or CI. If Git Bash is what you have, `MSYS_NO_PATHCONV=1 SHOWCASE_BASE=/my-kit/ pnpm showcase:build` turns the rewriting off for that command.

## 5. Adding a component

```bash
# Claude Code — scaffolds the folder, story, test, index.ts and regenerates exports
/ui-kit-component <name>
```

Or by hand:

```bash
npx shadcn@latest add dialog        # drops src/components/dialog.tsx
mkdir src/components/dialog && mv src/components/dialog.tsx src/components/dialog/
# add index.ts (with 'use client'), a .stories.tsx and a .test.tsx
pnpm exports:gen                    # regenerate package.json#exports
```

`shadcn add` writes a flat file; the kit uses folders. Moving the file and running `exports:gen` is the whole difference.

---

## 6. Build

```bash
pnpm build       # exports:gen → tsup → tailwind CLI
pnpm verify      # export map is current, every target exists, publint clean
pnpm test        # vitest, including the jest-axe smoke suite
pnpm typecheck
pnpm lint        # also fails on hardcoded colours and dependency creep
pnpm size        # per-subpath bundle budget
```

`pnpm lint` is where rules 1 and 3 above stop being conventions. Across everything under `src/` that ships, a `--ui-color-*` reference or a literal `#hex`/`rgb()`/`oklch()` is an error, and so is importing any of the packages the kit replaced — `clsx`, `class-variance-authority`, `lucide-react`, `cmdk`, `sonner`, `vaul`, `rc-*`, and any date library (`date-fns`, `dayjs`, `moment`, `luxon`). The motion and z-index primitives (`--ui-duration-*`, `--ui-ease-*`, `--ui-z-*`) are allowed directly — nothing re-maps them per theme, so there is no semantic layer for them to skip.

`pnpm test` runs the per-component tests plus `test/a11y.test.tsx`, which puts jest-axe over every component in a realistic composition — a control with its label, a table with its header — since the violations that matter (missing accessible name, orphaned label) only appear once things are put together. Contrast is not checked there: jsdom has no layout engine, so that job belongs to `@storybook/addon-a11y`, which runs in a real browser and is configured to fail the story.

`pnpm build` produces:

| Output | What it is |
| --- | --- |
| `dist/index.js` / `.cjs` / `.d.ts` | root barrel, ESM + CJS + types |
| `dist/components/<name>/index.*` | one entry per component |
| `dist/lib/index.*`, `dist/hooks/index.*`, `dist/icons/index.*` | `cn`/`cx`/`cva` + presets, the hooks, the icon set |
| `dist/styles.css` | compiled Tailwind — consumers need no Tailwind setup |
| `dist/tokens.css`, `dist/theme.css` | raw token layers for apps that do run Tailwind |

tsup is invoked through `scripts/run-tsup.mjs`, which only exists to raise the V8 heap ceiling. The `.d.ts` bundle is built in a worker thread that keeps the whole type graph — every entry plus Radix's type surface — in memory at once, and on a machine where Node sizes the default old-space below that (most CI runners do) the worker dies with `ERR_WORKER_OUT_OF_MEMORY` right after `CJS ⚡️ Build success`. It looks like a tsup crash and is only the heap. Set `--max-old-space-size` in `NODE_OPTIONS` yourself and the script leaves it alone.

### 6a. The git hooks

Three of those commands also run on their own, from `.husky/`:

| Hook | Checks | Cost |
| --- | --- | --- |
| `pre-commit` | branch-name gate, then ESLint on the **staged files only** | ~2–5s |
| `commit-msg` | the subject must be `<prefix>[(scope)][!]: <text>` | instant |
| `pre-push` | full `lint`, `typecheck` and `test:run` | ~70s |

The split is a cost rule, not a preference: 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 — they need `dist/` built first, and a hook that takes minutes gets bypassed with `-n` 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 — `chore feat hotfix bugfix reconcile fix docs refactor test perf ci` — so a branch reads `feat/progress-circle` and its commits read `feat: …`. `main`, `master` and a detached `HEAD` (rebase, bisect) are exempt from the branch gate.

```bash
pnpm check:staged   # what pre-commit runs
pnpm check          # what pre-push runs
HUSKY=0 git commit  # or: git commit --no-verify
```

**They install themselves on `install`, but only inside a git repository.** A kit that was just scaffolded is not one yet, so the first install prints `.git can't be found` and does nothing. Run `git init` before installing, or `npx husky` afterwards — `git config --get core.hooksPath` should then say `.husky/_`.

There is no `lint-staged` and no `commitlint`: the hooks are four shell scripts with no dependency beyond husky itself, and they are meant to be read.

---

## 7. Publishing

```bash
pnpm changeset          # describe the change, pick major/minor/patch
pnpm changeset version  # applies the bump + writes CHANGELOG.md
pnpm release            # guided publish
```

`pnpm release` checks the package name, the `LICENSE` placeholder, the working tree, and whether that version already exists on the target registry; then builds, verifies, prints the tarball contents, and asks you to type the version number before it publishes. Pass `--dry-run` to run every check and stop short of publishing.

For a private registry, uncomment the matching block in `.npmrc`. Never hardcode a token there — use the `${NPM_TOKEN}` form so the secret comes from the environment.

### From CI, to Nexus

`pnpm release` is the local path: it asks you to type the version before it publishes, which is a confirmation no runner can honestly give. `.gitlab-ci.yml` is the automated one — the same gate, then a publish to the Nexus npm-hosted repository named in `publishConfig.registry`:

| Stage | What runs |
|---|---|
| `check` | `lint`, `typecheck`, `test:run` — plus `brand:check`, reporting only |
| `package` | `build`, `verify`, `size`, then `npm pack` into an artifact, with the tarball contents printed |
| `publish` | uploads **that** tarball — nothing is rebuilt in between |

Set `NEXUS_NPM_TOKEN` (Nexus → your user → *NPM Bearer Token*) in Settings → CI/CD → Variables, masked and protected; `NEXUS_USERNAME` + `NEXUS_PASSWORD` work as a fallback. The credential is written to a job-scoped npm config file and deleted afterwards, so it never lands in the working tree.

Publishing runs on every push to the default branch and **skips silently when that version is already on the registry** — the same behaviour as NuGet's `--skip-duplicate`. So the pipeline is green on every commit and only does work when `package.json#version` moves, which means the release decision stays where it belongs: in `pnpm changeset version`. Set `SKIP_DUPLICATE: "false"` if you would rather a re-publish fail loudly. A prerelease version (`1.2.0-rc.1`) publishes under the `next` dist-tag, so a plain `npm i <pkg>` never resolves to it.

The GitHub workflows in `.github/workflows/` do the same job for npm.org with changesets and provenance. Keep whichever matches where this kit lives; they do not interfere with each other.

### Keeping dependencies fresh

```bash
/update-deps                      # Claude Code
node scripts/plan-updates.mjs     # the briefing on its own
pnpm audit:shipped                # which advisories reach consumers
```

**`audit:shipped` is the one that matters most, and it is new for a reason.** With `dependencies` empty, a consumer's `npm audit`, Dependabot and Snyk all report clean even when the Radix inside `dist` has a known vulnerability — they cannot see code they did not install. This repo is the only place it is still visible. The script walks the ~75 packages that actually end up in the tarball and separates them from the dev-only noise; a `⛔ SHIPPED` finding exits 1 and means the fix can only reach anyone through a new release of this kit. Run it before every publish, and on a schedule — an advisory needs no version bump to appear.

The briefing is read-only. It separates what is safe to batch from what is not: `radix-ui` and `tailwind-merge` are devDependencies now but still reach your consumers — one compiled into `dist`, the other copied into `src` — so both are pinned exactly and rejected in `.ncurc.json` alongside `typescript` and `lucide-react`; `tailwindcss` and its CLI/Vite plugins only work at matching versions — `ncu` never offers them, and hand-editing around that is how a build breaks a week later. `--apply safe` takes minors and patches; majors go one at a time, each with its own `lint / typecheck / test:run / build / verify / size` pass. Anything a consumer can observe belongs in a changeset **and** in §9 below.

---

## 8. Consuming the published kit

**No Tailwind in the consuming app** — import the compiled stylesheet once:

```tsx
// app entry
import '@your-team/ui/styles.css';

// anywhere
import { Button } from '@your-team/ui/button';
```

**Tailwind v4 already in the app** — take the tokens instead and let your own build generate the utilities:

```css
@import 'tailwindcss';
@import '@your-team/ui/tokens.css';
@import '@your-team/ui/theme.css';
@source '../node_modules/@your-team/ui/dist';
```

Either way, importing from `@your-team/ui/button` pulls in Button and nothing else. The barrel (`@your-team/ui`) works too, but subpath imports keep cold builds and type-checking noticeably faster.

**Dark mode** is class-driven — put `class="dark"` on `<html>` (or let `next-themes` do it).

**TypeScript** — also add `radix-ui` as a **dev** dependency:

```bash
pnpm add -D radix-ui        # npm install --save-dev radix-ui
```

The kit ships zero *runtime* dependencies, but the emitted `.d.ts` files still write `import * as DialogPrimitive from 'radix-ui/dialog'` to describe their prop types. Without the package on disk those types resolve to nothing, and Dialog, Tooltip, Select and fifteen others type-check as taking no props at all — `TS2559: Type '{ children: Element[]; }' has no properties in common with …`. It costs nothing at runtime: the JavaScript already has Radix compiled in, and the app's built bundle is byte-identical with or without it.

**Hand the consuming team the Claude Code skills too.** This repo carries `consumer-skills/` — `ui-kit-setup`, `ui-kit-usage` and `ui-kit-review`, written for an application rather than for the kit. Copy those three folders into the consuming project's `.claude/skills/` and Claude there reaches for a kit component instead of hand-rolling one, and can audit code that was written without it. They are not in `files`, so they travel by copy rather than through npm; `consumer-skills/README.md` has the command and the rationale.

---

## 8b. Using the kit without publishing it

Publishing is not a prerequisite for using the kit. `pnpm kit:local` builds it and drops the **same file set `npm publish` would produce** straight into another project's `node_modules`, so you can try a component in a real app before the package name, the licence holder or a registry has been decided.

```bash
pnpm kit:local -- --to ../my-app     # pnpm
npm run kit:local -- --to ../my-app  # npm
node scripts/link-local.mjs --to ../my-app   # neither: it is plain Node
```

It prints exactly what the consuming app has to change — the stylesheet import, the two root providers, and the one bundler setting that matters. Repeat `--to` for several projects at once, or list them in `.kit-local.json` at the kit root (`{ "targets": ["../my-app"] }`, gitignored) and run it with no arguments.

| Flag | |
|---|---|
| `--watch` | rebuild and re-sync whenever `src/` changes |
| `--no-build` | reuse the existing `dist/` |
| `--mode pack` | build a real tarball and install it (see below) |
| `--pm`, `--target-pm` | override the package manager on either side |
| `--force` | overwrite a destination this script did not write |

**Why a copy and not `npm link`.** A link is a symlink, and Node then resolves the kit's `react` from the *kit's own* `node_modules` before the app's — two React copies, and every hook throws `Invalid hook call`. A real directory has no `node_modules` beside it, so `react` always comes from the app. One React by construction rather than by configuration.

**Why `node_modules` and not a `vendor/` folder in the app.** The kit is consumed through 48 subpath exports, and subpath resolution runs through a package *name* found in `node_modules` — alias the name at a folder and `<pkg>/button` becomes a literal path that does not exist. Copying into `node_modules` is also what keeps the local drop honest: it is the resolution path a real consumer takes, so the app's import lines are already the ones it will use after a real install. The full argument, including yalc and the other alternatives, is §5b of the guide linked below.

**What a copy cannot do** is survive a later `install` in the target: the package is in no lock file, so a manager that prunes will remove it. Re-run the script, or use `--mode pack`, which builds a tarball and installs it as a real dependency — slower per iteration, but recorded, and the option to use for Yarn PnP or for handing the kit to a teammate.

Two things it does for you that are easy to miss by hand: it removes the destination before copying, so a renamed component cannot leave a stale module behind that still resolves; and it clears `node_modules/.vite`, because Vite keys its pre-bundle cache on the lock file rather than on mtimes inside a dependency and would otherwise keep serving the previous build. In a Vite app also add `optimizeDeps: { exclude: ['<pkg>'] }`, or every re-sync needs `vite --force`.

Everything in §8 above still applies — the stylesheet, the tokens route for a Tailwind app, dark mode, and the `radix-ui` dev dependency a TypeScript project needs.

The step-by-step guide, with the Vite and Next variants written out, the guards the script applies before it writes anything, and a troubleshooting table, is [`docs/kit-local-development/kit-local.md`](./docs/kit-local-development/kit-local.md) (Azerbaijani: [`docs/kit-local-development/kit-local.az.md`](./docs/kit-local-development/kit-local.az.md)).

---

## 9. Migrating

### 9l. Two controls that took focus invisibly now show a ring

Nothing breaks and no prop changes — this is here because it changes what you
see on screen, and because you may have patched around it.

Two elements wrote `outline-none` and put no indicator back, so a keyboard user
landed on them with nothing to say where focus was:

| Element | When you hit it |
|---|---|
| `TabsContent` | Radix gives the panel `role="tabpanel"` and `tabIndex={0}`, so Tab from the tab strip lands there |
| `TreeSelect`'s panel search field | with `showSearch`, the first control in the panel — the first Tab after opening |

Both now use the kit's `focusRing` preset, which is what every other control
already uses. It opens with `outline-none` itself, so the **resting appearance
is unchanged** and the ring appears on `:focus-visible` only — a mouse click
does not draw it.

If you overrode either element's `className` to add your own indicator, you now
have two. Remove yours, or keep it and pass `outline-none` after it — `cn()`
puts your class last, so it still wins.

### 9k. The types your props already mention are now importable

Nothing breaks here — this section exists because the fix is one you may have
worked around.

Seven components declared a type, referred to it from an exported `Props`
interface, and did not export the type. The name was visible everywhere except
where you needed it: in the props table, in your editor's hover, in the error
message when you got it wrong — but not in an `import` line. Typing a callback
meant reaching for the shape through the component:

```tsx
// what you had to write
type ProgressHandler = React.ComponentProps<typeof Upload>['onProgress'];
const onProgress: ProgressHandler = (event, file) => { /* … */ };

// what you can write now
import type { UploadProgressEvent, UploadFile } from '<pkg>/upload';
const onProgress = (event: UploadProgressEvent, file: UploadFile) => { /* … */ };
```

Eighteen names were added, each on the subpath its component already ships from,
and each also reachable from the root barrel:

| Subpath | Added |
|---|---|
| `<pkg>/upload` | `UploadFile`, `UploadRequestMethod`, `UploadProgressEvent`, `UploadRequestError`, `UploadRequestOption`, `UploadRequestHandle`, `BeforeUploadResult`, `uploadVariants` |
| `<pkg>/command` | `CommandFilter`, `CommandInputProps`, `CommandGroupProps`, `CommandItemProps` |
| `<pkg>/range-slider` | `RangeSliderMark`, `RangeSliderClassNames` |
| `<pkg>/drawer` | `DrawerDirection` |
| `<pkg>/data-table` | `DataTableExpandable` |
| `<pkg>/accordion` | `accordionVariants` |
| `<pkg>/breadcrumb` | `BreadcrumbListProps` |

`DataTableExpandable` is the one worth calling out: it is generic over your row
type (`DataTableExpandable<Order>`), and without the export there was no way to
write it down at all — the indexed-access workaround above cannot recover a type
parameter.

Any workaround you already wrote keeps compiling; the indexed-access form
resolves to the same type. There is nothing to migrate, only something you no
longer have to avoid.

**`xhrRequest` is deliberately still internal.** If you need the default
transfer, `customRequest` receives it as its second argument
(`info.defaultRequest`) — that is the supported route, and a second export
pointing at the same function would be a second thing to keep working.

### 9j. Toast draws CBAR's own glyph and ink, and `--ctl-icon` is a new role

The design file distinguishes its five toast states by **colour alone**: all
five variants of the `Toast` set (`2414:10758`) instantiate the same
`Solid/Status/Info-circle` and only recolour it, close cross included. The kit
substituted a glyph per kind — a tick for `success`, a warning triangle for
`warning`, an octagon for `error`, and nothing at all for a plain toast. It now
follows the design file.

What you will see:

- Every kind draws the same info-circle. `loading` is the one exception and
  keeps its spinner — it has no counterpart on the canvas, and a static circle
  would throw away the one thing a pending toast has to communicate.
- `toast('…')` — the `default` kind — now carries an icon where it carried none.
- The glyph and the close button are painted with a new `--ctl-icon` role.

If you preferred the per-kind glyphs, pass your own; the prop is unchanged:

```tsx
<Toaster
  icons={{
    success: <CircleCheckIcon className="size-5" />,
    error: <OctagonXIcon className="size-5" />,
    default: false, // suppress it entirely
  }}
/>
```

**`--ctl-icon` is deliberately lower-contrast than `--ctl-fg`, and must never be
used for text.** It exists because CBAR paints a status glyph brighter than it
sets status text, and it is bound to the ramp steps measured off the canvas:

| palette | `--ctl-icon` | on its own `--ctl-subtle` | `--ctl-fg` there |
|---|---|---|---|
| green | `green-600` `#16A34A` | 3.00:1 | 6.18:1 |
| red | `red-600` `#F00000` | 3.65:1 | 6.86:1 |
| yellow | `yellow-ink` `#865F28` | 5.31:1 | 5.31:1 |
| secondary | `secondary-500` `#4BC7B5` | **1.74:1** | 8.34:1 |
| black | `neutral-800` `#222222` | 15.11:1 | 15.11:1 |

Every label still uses `--ctl-fg`, which did not move and clears AA everywhere,
so no text lost contrast. Matching the design system was chosen over the number
for the glyph — the same call already recorded in §9c for white ink on `secondary`
and `green` solid fills. Dark mode has no counterpart in the design file, so
there `--ctl-icon` simply follows `--ctl-fg`.

The measurements and the node ids are on the showcase's parity page, finding 8.

### 9i. `DatePicker` now renders Azerbaijani month and weekday names

`locale="az-AZ"` used to draw the calendar in English — a fallback so quiet it
looked like a kit bug. It was the platform: V8's bundled ICU carries no `az`
data, so Chrome resolves `az-AZ` to the CLDR *root* locale and returns `M01…M12`
for months and `Mon`/`Tue` for weekdays, while `resolvedOptions().locale` still
reports `az-AZ`. Node and Firefox ship the full set, which is why a test run
never saw it.

`src/components/date-picker/date-utils.ts` now carries the CLDR `az` month and
weekday strings and uses them for any `az` (non-Cyrillic) tag instead of asking
`Intl`. Every other locale is unchanged, and a runtime that *does* have `az`
data sees the same strings it would have produced.

Nothing to do on your side. If you had worked around this by passing month
names in yourself, you can drop the workaround. `az-Cyrl-*` is still left to
`Intl`.

### 9h. The kit now ships zero runtime dependencies

`package.json#dependencies` is empty. Where it used to declare `radix-ui` and
`tailwind-merge`, both are now dev-time inputs whose code reaches you inside the
build instead of beside it:

| | before | now |
| --- | --- | --- |
| `radix-ui` | your install resolved it | compiled into `dist` (`noExternal` in `tsup.config.ts`) |
| `tailwind-merge` | your install resolved it | vendored source at `src/lib/tw-merge/` |

**Nothing changes in how you import or use a component.** Every export, prop and
subpath is what it was. What changes is your lock file: after upgrading, `radix-ui`,
its ~60 `@radix-ui/*` packages, `@floating-ui/*`, `aria-hidden`,
`react-remove-scroll` and the rest are gone from it unless something else in your
app pulls them. `react`, `react-dom` and the optional `react-hook-form` peer are
still yours to provide.

Three things worth knowing:

- **If your app uses Radix directly, you now have two copies** — yours and the
  one inside the kit. They do not share context. That is fine as long as you do
  not compose across them: wrapping a kit `<DialogContent>` around your own
  `Popover.Trigger` from your Radix install may not dismiss the way you expect.
  Use the kit's components together, or your own together.
- **Bundle size did not grow.** `size-limit` was already measuring
  with-dependencies totals, so the published numbers moved slightly *down*
  (barrel 81.0 → 79.1 kB brotli, DatePicker 34.5 → 33.8 kB). Every budget in
  `package.json#size-limit` is unchanged.
- **Your scanners will not see Radix any more.** This is the honest cost of the
  change: `npm audit`, Dependabot and Snyk report on your lock file, and Radix
  is no longer in it. A vulnerability in the copy inside `dist` is invisible to
  you and can only be fixed by a new release of this kit. If that trade does not
  suit your organisation, the kit's maintainers run `pnpm audit:shipped` against
  exactly those ~75 packages — ask them to publish the result, or to run it on a
  schedule.
- **`NOTICE` is now load-bearing.** Inlining MIT code means redistributing it,
  so the licences for Radix, its transitive tree and tailwind-merge ship with
  every build. If you strip `NOTICE` from `files`, you break their terms.

If you maintain this kit rather than consume it, the two rules that keep the
arrangement working are in §2 rule 3 and `src/lib/tw-merge/README.md`.

### 9g. `AlertDescription` now sizes with the alert

CBAR's Alert set (`2446:316`) steps the description with the control and leaves
6px between it and the title. The kit pinned the description at `text-sm` (14px)
at every rung and used a 2–4px gap, so only the title followed `size`:

| `size` | title | description — was | description — now |
| --- | --- | --- | --- |
| `sm` | 14px semibold | 14px | **12px** (`text-xs`) |
| `md` | 16px medium | 14px | **16px** (`text-base`) |
| `lg` | 18px medium | 14px | **18px** (`text-lg`) |

The size is set on the alert, through the same
`*:data-[slot=alert-description]:…` selector the fixed-colour shorthands already
use; `AlertDescription` no longer carries a `text-*` class of its own. The gap is
`gap-y-1.5` (6px) on all three rungs. Padding, icon size, icon gap and title
weight were already correct and did not move.

This is visible in every `Alert` that has an `AlertDescription`, including the
four fixed-colour shorthands (`default`, `destructive`, `success`, `warning`) —
they take a `size` like any other alert, so at the default `md` their description
goes 14px → 16px.

To pin a description back:

```tsx
<AlertDescription className="text-sm!">…</AlertDescription>
```

The `!` is required. The size now arrives from a parent selector
(`.… > [data-slot="alert-description"]`), which outranks a bare class on the
child — and `cn`/`tailwind-merge` cannot merge two classes that sit on different
elements, so it never sees the conflict.

### 9f. `pnpm dev` now opens the showcase, and Storybook is optional

Nothing to do in your own code — this changes what the scaffolder produces, not
the kit's public API. It is recorded here because a kit scaffolded before this
behaves differently from one scaffolded after.

`pnpm dev` used to start Storybook. It now starts the **showcase** (`vite
showcase`, `→ :5175`), which is the primary surface and the thing you publish as
documentation ([§4c](#4c-publishing-the-showcase)). Storybook moved to its own
`pnpm storybook` script and became a scaffold-time choice: `create-reactivite`
asks *"Include Storybook alongside the showcase?"*, and `--no-storybook` strips
it entirely — `.storybook/`, every `*.stories.tsx`, and the five storybook
devDependencies all go, so a stripped kit installs lighter and still lints,
type-checks and builds.

If you have an **older kit** and want the new layout, edit `package.json`:

```jsonc
"scripts": {
  "dev": "vite showcase",              // was: "storybook dev -p 6006"
  "storybook": "storybook dev -p 6006" // new — keep only if you want Storybook
}
```

Keep Storybook or not; the showcase does not depend on it. If you drop it, remove
the same five devDependencies and the `.storybook/` folder, and note that
`eslint.config.js` already loads `eslint-plugin-storybook` through a guarded
`try/catch`, so its absence is not an error.

### 9e. `<pkg>/lib` now exports the palette and variant vocabulary

Nothing to do — this one is purely additive, listed here so the release record
stays in one place.

`colorPalettes`, `controlVariants`, `fieldHeights` and the types `ColorPalette`,
`ControlVariant`, `FieldSize` are now exported alongside `controlHeights` /
`ControlSize`, which was already public. They are the same objects
`src/lib/cva-presets.ts` has always defined and every component already reads.

They were internal for no good reason: a consumer building a control the kit does
not ship — a segmented toggle, a chip — had to retype the seven palette names and
the five treatments to stay in step with the design system, with nothing to catch
a typo.

```tsx
import { colorPalettes, controlVariants, type ColorPalette } from '@cbar/uikit/lib';

// `third` is the deprecated alias of `tertiary` and paints identically —
// filter it out of a picker or the same colour appears twice.
const PALETTES = (Object.keys(colorPalettes) as ColorPalette[]).filter((p) => p !== 'third');
```

### 9d. Button icons now grow with the button

`Button` used to draw every icon at 16px, whatever its size. CBAR does not: its
Button set draws a 16px glyph on `xs` and a **24px** one on `md`, `lg` and `xl`.
The kit now follows it, so an icon inside a `md`, `lg` or `xl` button is half
again as large as it was.

Nothing about the box moved — heights (32 / 36 / 40 / 48 / 56px), horizontal
padding, gap, type size and radius were already right and are untouched. Two
smaller corrections rode along, neither of which changes the rendered box:
`md` and `lg` now state CBAR's 28px line height (they were on Tailwind's 20px
and 24px defaults; the label is vertically centred either way), and every rung
states CBAR's 2px vertical padding, which a fixed-height box absorbs. Those two
exist so the showcase's live Figma panel stops reporting differences that were
only ever missing declarations.

Where you will see it: any button carrying an icon at `md` or above, plus
`Upload`'s `variant="button"` trigger, which composes `buttonVariants({ size: 'md' })`.

`sm` deliberately stays at 16px. CBAR draws no `sm` Button at all — the set jumps
`xs` → `md` — so the kit invents that rung at 36px, and 36px has no room for a
24px glyph.

Per-button escape hatch, unchanged and still the documented one — any explicit
`size-*` on the icon wins:

```tsx
<Button size="lg"><SearchIcon className="size-4" />Search</Button>
```

To put the whole kit back on a fixed 16px, point Button's two large rungs at the
small one in `src/lib/cva-presets.ts`:

```ts
export const iconSizes = {
  base: "[&_svg:not([class*='size-'])]:size-4",
  lg: "[&_svg:not([class*='size-'])]:size-4", // was size-6
} as const;
```

That file now splits what used to be one `iconSizing` string into `iconReset`
(no size) and `iconSizes` (the two rungs). `iconSizing` still exists and still
means exactly what it did, so every other control — menus, tabs, selects,
dialogs, sheets, command, pagination — is unaffected; only Button composes the
halves separately. All three are exported from `@your-team/ui/lib`.

The measurements are Figma's own, read live off set `2446:6959` rather than
inferred, and `src/components/button/button.test.tsx` now pins the ladder so it
cannot drift silently.

### 9c. Solid controls on `secondary` and `green` now carry white text

CBAR paints white on every solid fill except yellow. The kit used to disagree on
two palettes: `colorPalette="secondary"` (turquoise) and `colorPalette="green"`
took a near-black label instead, because white on those fills is 2.07:1 and
2.28:1 and WCAG asks for 3:1 on a control. Both now follow the design system.

Only `--ctl-solid-fg` moved, on `.palette-secondary` and `.palette-green`. The
fills are untouched, and `--ctl-fg` — the ink every *tinted* treatment uses —
still sits at the deep end of each ramp, so `subtle`, `surface`, `outline` and
`ghost` are unchanged and still clear 4.5:1.

What changes visually, wherever those two palettes meet `variant="solid"`:
`Button`, `IconButton`, `Badge`, `Alert`, `Avatar`, `Checkbox` (the checked and
indeterminate marks) and `Pagination` (the current page). Dark mode too — the
dark blocks leave `--ctl-solid` on the same 500 step, so the same white applies.

**If your product has an accessibility target, this is the one to look at.** Two
ways back, both local to your own kit:

```css
/* src/styles/theme.css — restore the kit's previous ink */
.palette-secondary { --ctl-solid-fg: var(--ui-color-neutral-900); }
.palette-green     { --ctl-solid-fg: var(--ui-color-neutral-900); }
```

or keep the white and darken the fill instead (`--ctl-solid: var(--ui-color-secondary-700)`),
which departs from CBAR's brand colour rather than from its ink.

`test/contrast.test.ts` still measures both pairs and still prints the number;
their floor is 1 rather than 4.5 so the suite passes while the cost stays on
screen. The Figma evidence — every `solid` fill and label read off set
`2446:6959` — is in the showcase at `#/parity?tab=findings`, and the **Figma
layout** tab (`pnpm showcase` → Buttons) draws the whole set the way the canvas
does.

### 9a-i. Full Figma parity: `third` → `tertiary`, and the real yellow ramp

Two corrections landed once the Figma **Variables** became readable. The earlier
extraction went through the REST API, where `/v1/files/:key/variables/local` is
Enterprise-only and answers 403 — so it reconstructed names from *published
style* names and could not see the variable table at all. The local bridge plugin
reads it directly, and two things did not match.

**1. The cyan ramp is `tertiary`, not `third`.** CBAR's published style is named
"Third"; its variable is `brand/tertiary/*`. The variable is authoritative.

```tsx
<Button colorPalette="tertiary">Details</Button>   // use this
<Button colorPalette="third">Details</Button>      // deprecated, still works
```

Nothing breaks today: `--ui-color-third-*` now aliases `--ui-color-tertiary-*`,
`.palette-third` is a selector alias of `.palette-tertiary`, and `third` is still
a `colorPalette` value. All three go in the next major — rename at your leisure:

```bash
rg -l 'colorPalette="third"|palette-third|--ui-color-third-' src \
  | xargs sed -i 's/third/tertiary/g'
```

**2. Yellow was a Tailwind ramp, and is now CBAR's.** The Figma file carries two
yellow collections: `Color/Foundation → yellow/*`, which every `*/warning`
semantic aliases to, and a stale standalone `Yellow` collection that nothing
references. The kit had the stale one — nine of its ten steps were wrong.

| Step | Was | Now |
| --- | --- | --- |
| 50 | `#FEFCE8` | `#FEF8EB` |
| 200 | `#FEF08A` | `#F9E39A` |
| 300 | `#FDE047` | `#F5D96E` |
| 400 | `#FACC15` | `#F2C952` |
| 500 | `#EAB308` | `#F3C67F` |
| 600 | `#CA8A04` | `#D6AA57` |
| 700 | `#A16207` | `#CF9D2B` |
| 800 | `#854D0E` | `#865F28` |
| 900 | `#713F12` | `#5A3F15` |

(100 was already correct.) The new ramp is softer and browner, and it is **not** a
monotonic lightness walk: 500 is lighter and less saturated than 400, because
CBAR uses 500 as a warning *surface* rather than as the middle of a scale. That
discontinuity is CBAR's and is reproduced as-is.

This changes what a warning looks like: `colorPalette="yellow"`, `variant="warning"`
on Alert and Badge, `toast.warning()`, and `--ui-color-warning-*`. Nothing needs
editing — but if you hand-picked a yellow step to match the old ramp, re-check it.

`--ui-color-yellow-surface` and `--ui-color-yellow-ink` survive as role names.
They were off-ramp one-offs while the ramp was a Tailwind yellow; now they are
exactly steps 500 and 800, kept as aliases because `theme.css` reads them by role.

### 9a. From a kit scaffolded before the CBAR design-system sync

The tokens, sizes and icons in this kit are generated from the CBAR Design
System Figma library. Nothing about the *shape* of the API changed except where
listed here — most of the difference is that every control is now drawn at
CBAR's measurements.

**Colour is a second axis.** CBAR treats "how filled in" and "which hue" as
independent, so components take both `variant` and `colorPalette`. Any of the
five treatments combines with any of the seven palettes.

```tsx
<Button variant="outline" colorPalette="tertiary">Details</Button>
<Badge variant="subtle" colorPalette="green">Active</Badge>
<Checkbox colorPalette="red" />
```

The one-word names you already use still work — `default`, `destructive`,
`secondary` on Button; those plus `success`, `warning`, `ghost` on Badge. They
paint from the semantic roles rather than the palette, so each pins its own
colour and ignores `colorPalette`. Use `variant="solid"` with an explicit
palette when you want the two axes to compose.

**Sizes changed, and this is the point of the sync.** Named sizes now carry
CBAR's measurements, so existing markup gets taller controls without any edit:

| | before | after |
| --- | --- | --- |
| `Button size="md"` | 36px | **40px** |
| `Button size="icon"` | 36px | **40px** |
| `Input` / `Select` / `Textarea` `size="md"` | 36px | **44px** |
| `Avatar size="md"` | 32px | **40px** |
| corner radius on buttons and fields | 8px | **4px** |

The ladders also grew: `xs`–`xl` on Button, `xs`–`lg` on the fields and
toggles, `2xs`–`xl` on Avatar. Buttons and fields no longer share a height —
CBAR draws fields taller from `md` up (44 against 40, 56 against 48), which is
reproduced rather than smoothed. If a form row needs its input and button to
line up, size them from `controlHeights` explicitly.

Other API changes:

- **`controlHeights`** (from `<pkg>/lib`) went from `sm/md/lg` to `xs`–`xl`, and
  a second map, **`fieldHeights`**, carries the field ladder.
- **`Switch`** sizes are `xs/sm/md/lg`; the old `sm` is now `xs` in width.
- **`Avatar`** takes `variant`, `colorPalette` and `shape`. `AvatarFallback` no
  longer carries its own background — the root owns the fill so an image simply
  covers it.
- **`RadioGroupItem`** draws its selected state as a thickened border rather
  than a dot element, matching CBAR. Anything targeting
  `[data-slot=radio-group-indicator]` for styling should move to the item.
- **`Alert` now defaults to a filled alert.** Its default `variant` moved from
  `default` (a plain card with coloured text) to `solid` on the primary palette,
  and it gained a `size` prop (`sm`/`md`/`lg`, default `md`, from CBAR's
  `default`/`md`/`lg`). A bare `<Alert>` therefore renders navy-filled rather
  than neutral — pass `variant="default"` to keep the old look, or move to
  `variant="subtle"` with a palette, which is what CBAR actually draws for a
  status message. Status now travels on `colorPalette` (`red`, `green`,
  `yellow`, `secondary`, and `primary` for CBAR's `info-secondary`) instead of a
  `status` prop, and `AlertDescription` inherits the alert's colour rather than
  dimming to `muted-foreground`, which was illegible on a solid fill.
- **Toasts are tinted rather than neutral.** `toast.success()` and friends used
  to render a popover-coloured card with a tinted icon; each kind now selects a
  colour palette, so the fill, the 3px accent down the leading edge and the icon
  all move together — green, red, yellow, secondary, and the neutral palette for
  a plain `toast()`. The API is unchanged. The description no longer dims to
  `muted-foreground`, matching CBAR, and the corner radius drops from 12px to
  6px. Anything styling `[data-slot=toast]` by colour should read `--ctl-subtle`
  / `--ctl-border` / `--ctl-fg` instead of naming one.
- **`TabsList` gained CBAR's four strips and now defaults to `line`.** The
  variants are `line`, `subtle`, `outline` and `plain`; the kit's old segmented
  control is still there as `default`, but it is no longer what a bare
  `<TabsList>` renders — pass `variant="default"` to keep it. `TabsList` also
  takes `size` (`md` 36px, `lg` 44px) and `colorPalette`, and the triggers read
  all three back off the list, so nothing is repeated per tab.
  CBAR's third axis, `state`, is deliberately **not** a prop: its values are
  hover, active and disabled, which are CSS states. Exposing them would let a
  tab look selected without being selected, and assistive technology would
  disagree with the pixels.
- **New palette role: `--ctl-soft`.** It is the tint for hovering something that
  is not filled yet, one ramp step below `--ctl-subtle`. CBAR draws hover and
  selected identically, which makes hovering an unselected tab look like a
  selection; this separates them without leaving the ramp. If you have written
  your own palette class, add a `--ctl-soft` to it.
- **`Accordion` sections are cards now.** CBAR draws each section as its own
  bordered card with 16px between them, rather than flush rows sharing a rule —
  that is `variant="card"`, the new default. `variant="list"` restores the old
  layout. The root also takes `colorPalette`, which only the chevron uses: it is
  muted while closed and takes the palette's full colour when open, matching
  CBAR's icon swap without giving up the rotation animation. Title and body both
  moved from 14px to 16px on a 28px line.
  `AccordionProps` is now an intersection rather than an interface, so Radix's
  `type="single"` / `type="multiple"` union survives; nothing changes at a call
  site, but `Partial<AccordionProps>` will widen `type` back to the union.
- **`Tooltip` is smaller and has its own colour pair.** CBAR draws it at 4px
  radius with 8/2 padding and medium 12px text, so the bubble is noticeably
  tighter than before. `TooltipContent` gained `showArrow` (default `true`),
  matching CBAR's switch, and the arrow is a real 12×8 triangle rather than a
  rotated square. Two new semantic roles back it: **`--tooltip` and
  `--tooltip-foreground`** (`bg-tooltip`, `text-tooltip-foreground`,
  `fill-tooltip`), because a tooltip inverts against the page rather than
  sitting on a surface — CBAR draws it one step lighter than body text, and dark
  mode has to flip it or a dark chip lands on a dark page. Only `bottom` is
  drawn in CBAR; the other three sides are the kit mirroring it.
- **`BreadcrumbList` takes `size` and `colorPalette`, and the crumbs stopped
  being dimmed.** CBAR builds each crumb from its Button, so `xs` and `md` are
  button heights (32px and 40px) with matching padding, and the crumbs read them
  off the list through `data-size`. The bigger change is colour: every crumb is
  now body ink and only the current page changes weight, where the kit used to
  grey out the ancestors — that is CBAR's convention, not the usual one, so
  check it reads for your hierarchy. The separator takes the palette (turquoise
  by default) and deliberately does **not** scale with `size`.
- **`Pagination` is a strip of equal 32×32 squares, and `PaginationLink` lost
  its `size` prop.** CBAR draws pages, arrows and the ellipsis at one size
  rather than reusing the Button ladder, so the links no longer call
  `buttonVariants`. Two visible consequences: **the arrows are icon-only** —
  "Previous" and "Next" now live in `aria-label` rather than on screen — and the
  selected page is a filled turquoise square instead of an outlined one.
  `Pagination` takes `colorPalette`. Hover on an unselected page uses
  `--ctl-soft`, since CBAR draws hover identically to the resting state and
  leaves a page with no feedback at all.
- **`--card` is now a tinted surface and `Card` lost its shadow.** CBAR lifts a
  card with a fill one step off the page plus a hairline border, not elevation,
  so `--card` moved from white to the neutral ramp's lightest tint. Surfaces
  CBAR draws flat white — an accordion section is the one in this kit — use
  `--background` instead; the two roles used to hold the same value, and
  splitting them is what lets a card read without a shadow. If you have a
  surface of your own on `bg-card` that should stay white, move it to
  `bg-background`. Radius and border colour did not change: `rounded-xl` was
  already CBAR's 16px and `--border` already its hairline.
- **`Upload` gained a `variant`.** `dropzone` (the default) is the dashed drop
  target, redrawn at CBAR's measurements: 2px dashes, a 6px radius and a ~224px
  box, with the hint set at the same 14px as the title so the two separate by
  weight. `button` renders a plain trigger instead, and does not advertise
  itself as a drop target on drag-over. CBAR's other three `type` values —
  `input`, `clearable`, `paste` — are its Input with a file trigger attached, so
  compose those from `Input` and `Upload` rather than expecting a variant.
- **New component: `ProgressCircle`**, from `<pkg>/progress` alongside the bar.
  Sizes `xs`–`xl` (24–64px) with the stroke climbing 4→8, `colorPalette`, and a
  spinning quarter-arc when `value` is omitted. The centre label is on from `md`
  up and off below it — `xs` and `sm` leave 16px and 22px inside the ring, which
  a percentage overruns; pass `showValue` with a short `formatValue` to force
  it.
- **`Progress` gained `size` and `colorPalette`, and got thinner.** CBAR draws
  the bar at 6px (`md`, the new default) and 9px (`lg`); it used to be a fixed
  8px. Colours now come from the palette — the track is `--ctl-subtle` and the
  fill `--ctl-solid`, replacing `bg-primary/20` and `bg-primary`. On the default
  `primary` palette that is very close to the old look.
- **Icons** are CBAR's. The export names are unchanged, but most are now filled
  paths rather than strokes, and `Icon` takes a `filled` prop. `CircleIcon`,
  `CircleCheckIcon` and `Loader2Icon` keep their Lucide geometry — CBAR has no
  counterpart for any of them.
- **Fonts**: the kit asks for **DM Sans** and falls back to the system stack. It
  ships no font files — load the face in your app.

### 9b. From a kit scaffolded before the dependency cut

Skip this section on a fresh scaffold. It matters only if you already published a
kit from an older version of this template and are pulling the new one in.

The kit went from **17 runtime dependencies to 2**. Only `radix-ui` (the
accessibility contract for dialogs, selects and popovers) and `tailwind-merge`
(what lets a consumer's `className` override a built-in utility at all) survived.
Everything else is now kit-local code under `src/` that you can read and change:
icons, the variant builder, the class composer, the command palette, toasts, the
drawer, the calendar, the data table, the tree, uploads, the number input.

Removed: `class-variance-authority`, `clsx`, `cmdk`, `date-fns`, `lucide-react`,
`rc-input-number`, `rc-picker`, `rc-slider`, `rc-table`, `rc-tree`,
`rc-tree-select`, `rc-upload`, `rc-virtual-list`, `sonner`, `vaul`.

### Breaking changes

**`RangeSlider`** — rebuilt on Radix's Slider, which derives the thumb count
from the value, so `range` is gone.

```diff
- <RangeSlider range defaultValue={[20, 70]} onChange={next} marks={{ 0: '0' }}
-   ariaLabelForHandle={['Minimum', 'Maximum']} />
+ <RangeSlider defaultValue={[20, 70]} onValueChange={next}
+   marks={[{ value: 0, label: '0' }]} thumbLabels={['Minimum', 'Maximum']} />
```

Single values are arrays too: `defaultValue={[40]}`. The `classNames` slots are
now `track` / `range` / `thumb` / `mark`.

**`CommandItem`** — `value` is required. cmdk inferred it from the rendered
text, which hid what filtering actually matched on. `keywords` covers synonyms
the label does not contain.

```diff
- <CommandItem onSelect={run}>Sign out</CommandItem>
+ <CommandItem value="Sign out" keywords={['logout']} onSelect={run}>Sign out</CommandItem>
```

**`Toaster`** — `theme` now defaults to `'inherit'`: toasts portal onto
`document.body`, which is inside `<html>`, so a `dark` class on the root element
already reaches them. `expand` and `richColors` are gone — toasts render as a
plain vertical list rather than a collapsed stack.

**`Drawer`** — built on Radix's Dialog. `snapPoints`, `nested` and
`shouldScaleBackground` are gone; `dismissible` and `showHandle` are new.

**`DatePicker` / `DateRangePicker`** — `picker` covers `date`, `month` and
`year`; `quarter`, `showTime`, `presets` and `panelRender` are gone. Values are
plain `Date` objects and locale is a BCP 47 string.

```diff
- <DatePicker onChange={(d) => set(Array.isArray(d) ? d[0] : d)} locale={deDE} />
+ <DatePicker onChange={set} locale="de-DE" />
```

The date maths ships with it — `formatDate`, `parseDate`, `addMonths` and the
rest are exported from `<pkg>/date-picker`, so formatting the value elsewhere in
your app needs no date library.

**`TreeSelect`** — nodes are keyed by `value` (`Tree` still uses `key`), which
separates the option's identity from the node's. rc-specific props
(`treeNodeFilterProp`, `showCheckedStrategy`) are gone.

**`Tree`** — `onCheck` always reports an array of checked leaf keys, plus
`halfCheckedKeys` in its info argument.

`DataTable`, `VirtualList`, `Upload` and `InputNumber` keep their APIs.

### New

- `<pkg>/icons` — the kit's own icon set, matching Lucide's geometry.
- `cx`, `cva` and `VariantProps` from `<pkg>/lib`, for building your own variants.
- `src/styles/rc.css` is gone; every component now styles itself with `data-slot`
  and utility classes.
