# `@charcuterie/ui`

The components. Look from `@charcuterie/tokens`, state from `@charcuterie/logic`, and ARIA
that Playwright and an AI agent can actually drive.

M3 ships the **P0 pure-presentation** set: `Spinner`, `Skeleton`, `Button`, `IconButton`,
`Badge`, `ProgressBar`, `ProgressCard`, `EmptyState`, `Card`, `LiveStatusIndicator`, `MediaTile` — plus
`VisuallyHidden`, which the first three of those need. M4 adds the overlays: `Modal`,
`Popover`, `Tabs`.

**M5 adds two, and they came from the first consumer rather than from the plan** —
[the rule](../../docs/decisions/2026-07-30-a-consumer-milestone-adds-components.md):

- **`Alert`** — rip-deck spells this shape four times, two of them carrying a
  byte-identical `TONE_CLASS` map of hardcoded hexes. It is the app's largest single
  duplication and no P0 component is a banner.
- **`SegmentedControl`** — `SinglePicker` + `RovingFocus` with the panels taken away, which
  is a composition that could not be expressed until
  [`Tabs` moved onto `SinglePicker`](../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).

**M6a ships the nine P1 components**: `Select`, `Field`, `Accordion`, `LogViewer`, `Menu`,
`Tooltip`, `SortableTableHeader`, `Toast` (with `ToastRegion`), and `FileDropZone`. Twenty
five components in all. Four of them changed rules this package had already written down —
a menu is [named by its trigger](../../docs/decisions/2026-07-31-a-menu-is-named-by-its-trigger.md),
an accordion panel is [a `group` rather than a landmark](../../docs/decisions/2026-07-31-an-accordion-panel-is-a-group-not-a-landmark.md),
[`Select` owns no state at all](../../docs/decisions/2026-07-31-select-is-uncontrolled-because-the-platform-owns-the-value.md),
and [not every boolean is a state kind](../../docs/decisions/2026-07-31-not-every-boolean-is-a-state-kind.md).
Full write-up: [M6a handoff](../../docs/2026-07-31-m6a-the-p1-components.md).

**The link family** — `TextLink`, `ButtonLink`, and `UnstyledLink` — closes the gap that had seven repos
hand-rolling a `←` back-link, plex-channels navigating with
`<button onClick={() => navigate(…)}>`, and mux-magic reloading the page through 14 raw
`<a href>`. Buttons are for on-page actions; links are for navigation, and both of these
render a real `<a href>` so middle-click, ctrl-click, open-in-new-tab and copy-link-address
work. The first two differ in **paint, not semantics**. `UnstyledLink` owns no paint and
preserves an established app-owned treatment while still using the same router seam
([decision](../../docs/decisions/2026-08-10-buttons-are-actions-links-are-navigation.md)).
The router is **injected** rather than depended on — `RouterLinkProvider` at the app root,
with `@charcuterie/ui/react-router` as an optional subpath adapter and a plain `<a>` when
nothing is provided. Recipe: **Guides/Routing** in Storybook.

**`Board` is the first component whose own operation is a write**, and the first to declare
two nested containers. Lanes with honest counts, priority bars, per-card footers for a live
run line, real empty states and a `+ n more` overflow — `laneLayout="columns"` places workflow
lanes beside each other, while `laneLayout="rows"` makes every project pool a horizontal band
with a responsive card grid. Each lane's box decides whether a card is two lines, one line,
or a card.
There is **no media query in it**, because a lane in a three-up board is ~500px on a maximised
1600px window and a browser at 175% zoom reports ~860 effective pixels for a 1500px one.
Moving a card takes **no drag-and-drop dependency**: one handle per card, a `Menu` of the
other lanes as the primary path, Pointer-Event dragging at 1.4 KB gzip beside it
([decision](../../docs/decisions/2026-08-19-the-board-owns-the-move-and-takes-no-drag-and-drop-dependency.md)).

**`LaneTimeline` is the first component that folds on a measurement of its own box.**
One horizontal lane per group across a date axis, with anything lasting more than a day drawn
as a **bar** — the shape a month grid cannot show, and the only one that makes load asymmetry
visible, because overlapping items pack into stacked rows and a busy lane is literally taller.
`end` is **inclusive**, said four times over, because an off-by-one on a date range is
invisible in both directions. The Narrow View **abandons** the axis rather than panning or
squeezing it. And the fold is measured rather than queried for a reason no other component
here has: the question is not how wide the box is but how wide one *day* is, which is the
width divided by a number that lives in the data — 30 columns in 900px is a readable month
and 365 columns in the same 900px is a smear
([decision](../../docs/decisions/2026-09-18-a-timeline-span-ends-on-its-last-day-and-the-axis-folds-on-measurement.md)).

**`DropRail` is the move control for a page `Board`'s `Menu` cannot serve.** A menu of the
other lanes is right at three lanes; it is wrong at thirty-four groups, where it becomes a
scrolling list inside a popup and a drag becomes a scroll with the button held down. The
rail pins every destination to the top for the length of a move — no travel, nothing off
screen — as a `listbox` driven by pointer, tap **or** the arrow keys, all committing through
one `onPick`. It reads `event.buttons` to tell a drag from a tap, so a host says only
whether a move is in flight. It holds the same line on dependencies: `elementFromPoint` over
its own chips is the only geometry a rail needs. Not to be confused with `Rail`, the app
shell's side landmark — `FileDropZone` is the precedent for the prefix carrying the
distinction.

**`ReorderList` is the same shape a fourth time, so it is the last time.** A vertical list
put in a different order by two buttons or by dragging a handle. Docket had written it
twice — a subtask checklist on HTML5 `draggable`, which does not fire on touch at all, and
a phase queue with the buttons and no drag — and `Board` had written it once correctly. So
this component **calls** `useBoardDrag` with a single registered lane rather than forking
its geometry, the way `VirtualizedGrid` calls `useAdaptiveColumns`; the snapshot, the
threshold and the pointer capture exist once. The two move buttons are the PRIMARY path and
the drag is the enhancement, per WCAG 2.5.7 — a host that draws no handle is supported. It
renders no row, no handle and no buttons: `renderItem` receives `moveBy` and `gripProps`,
which is what lets one component serve three CSS grids that have nothing in common.

**`GroupedList` is rows under group headings, always open.** castkit's page picker, tally-marks'
card list and whereabouts' nested holdings each drew it by hand: a heading, then rows of a
label, a muted line, some badges and a button. Data-driven — `groups` of `items` with `label`,
`description`, `badges`, `content` and `actions` — and the app keeps the grouping, the search
and the filters. The heading labels the `<ul>`, not a `<section>`, because one named section
per group is a dozen landmarks on one screen. An empty group is skipped, and `emptyState`
draws when every group is empty. A group you can close is `Accordion`.

**`BadgeButton` is a `Badge` you can press**, and it came from a consumer rather than from a
plan: QueuePilot has six pill-shaped controls — per-entry setting tags, an Edit chip, two
start-point chips, a group chip, a pool's Exclude chip — that were hand-rolled
`<button className="badge …">` because `Badge` is a `<span>`. A sibling component, exactly
as `ButtonLink` is to `Button`: one paint through `useBadgeShape`, compared as **computed**
styles in the test, and a real `<button>` for focus, Enter, Space, `:disabled` and a role a
screen reader announces. `onClick` is required, so a pill nobody can press is a `Badge` by
construction
([decision](../../docs/decisions/2026-08-22-a-pressable-badge-is-a-sibling-component-not-a-prop.md)).

**`ActionTiles` is the tile that presses**, and it is the case the
[choice-tile record](../../docs/decisions/2026-08-25-a-choice-tile-is-a-radiogroup-shape-not-a-third-component.md)
named as out of scope: mux-magic's "Pick a tool" tiles are `<a href>` and points-market's are
`<Link>`, and a link is not a radio. That record's test was *only the box changes*, and here
it is false — no role to borrow, no roving tabindex, no selection-follows-focus, no
read-only, no pending value — so the two share **the box and nothing else**, through
`tileStyles.ts`, and the test compares *computed* styles rather than class names. It exists
because QueuePilot's queue-type chooser was two `Button`s with `height: auto`: a `Button`
carries **no block padding at all**, so the override computed to `padding: 0` down the block
axis and shipped a card whose title sat flush against its top border. Nothing could report
it — the class really was in the DOM, tsc never reads the CSS, and unstyled markup passes
axe
([decision](../../docs/decisions/2026-09-01-a-tile-that-acts-is-its-own-component-and-shares-only-the-box.md)).

**`TimecodeInput` is the first field the fleet could not write down twice the same way.**
QueuePilot is adding "play only this section of a video", and the *printing* half of a
timecode already exists five times — queuepilot's `clock` and `toClock`, castkit's
`formatTime`, and mux-magic's two modal formatters — with three different answers to whether
the hour is shown and two to whether the minute is padded, so the same position renders
differently in two panels of one app. The *reading* half exists nowhere. It is a text field
rather than an `<input type="time">` for the reason `Select` is deprecated and `Slider` is a
`div`: the platform widget is OS-painted, and it cannot carry a millisecond anyway. The
grammar is ordered, anchored and total — `90` is ninety seconds, `90:00` is ninety minutes,
and `1:90` is refused by name rather than carried into `2:30`, which is the `1:60` trap
mux-magic wrote down in 2023. `isRange` edits a section whose **two ends are independently
optional**; an inverted section swaps, and a zero-length one is refused, because a section
with no length plays nothing
([decision](../../docs/decisions/2026-09-01-a-timecode-is-milliseconds-and-a-section-is-a-mode.md)).
**`RangeSlider` is the two-thumb range**, and it exists because
[the Slider record](../../docs/decisions/2026-08-22-the-slider-is-a-div-with-role-slider-not-an-input-range.md)
said one would be a different widget: QueuePilot plays *only a section* of a video, so a
user drags two handles over a film's runtime and reads the result beside a `TimecodeInput`.
`Slider` puts `role="slider"`, the tab stop and the pointer target on the **track**, and two
values cannot live on one accessible object — so here the role moves to the thumbs, the
track becomes a `role="group"` carrying the label, and one tab stop becomes two. That is why
it is a component rather than an `isRange` mode: both halves of the test that made
`Combobox`'s `isMultiple` and `DatePicker`'s `isRange` modes — *the role does not change,
and the shared part is everything* — are false here. The thumbs **clamp** rather than swap,
so the handle under the pointer keeps its identity and each thumb reports the other as its
own `aria-valuemin`/`aria-valuemax`. What is genuinely shared is the arithmetic
(`Slider/sliderValue.ts`, called rather than copied) and the bar (`sliderStyles.ts`, the
same extraction `tileStyles.ts` is), compared as **computed** styles in the test
([decision](../../docs/decisions/2026-09-01-a-two-thumb-range-is-its-own-component-and-shares-the-bar.md)).

**`CommandPalette` is the launcher** — a search field over a grouped list in a `Modal`, built
here first because a start page, Docket and Folio all want one and nothing in the fleet had
one. It lists the caller's `suggestions` before anything is typed, which is the defect it
exists to fix: the third-party launcher it replaces showed nothing until you typed. Typing
matches **word prefixes and initials, never subsequences** (`ha` finds `Home Automation`,
not `Chat`), through `rankWordPrefixMatches` in `@charcuterie/logic`. A short prefix and a
space (`au lego`) routes the rest of the query to a caller's target, and the caller's
fallback row ends every other list. The library owns the shape and no site names. The global
keys are an opt-in hook, `useCommandPaletteHotkeys`, and `useCommandPalette()` assembles them
with the open state
([matching](../../docs/decisions/2026-10-16-a-command-palette-matches-word-prefixes.md),
[keys](../../docs/decisions/2026-10-16-command-palette-hotkeys-are-an-opt-in-hook.md)).

**The unified app shell adds four**: `Shell`, `Header`, `Rail`, `Main` — the fleet's largest
single duplication, where ten of twelve UI repos hand-roll the page chrome and three of them
have a file named `AppShell.tsx` (two of those headers are a *byte-identical* class string,
arrived at independently). One `contentWidth` on `Shell` feeds both the header row and the
content column, `isSticky` writes `position` and `z-index` together, and a `Rail` collapses
by restyling rather than by rendering itself twice.
[Decision](../../docs/decisions/2026-08-10-the-app-shell-is-shell-header-rail-main.md);
`Shell`'s docs page carries three copy-wholesale templates.

## Install and wire

```ts
import { Button, Card } from "@charcuterie/ui"
```

```css
@import "tailwindcss";
@import "@charcuterie/tokens/theme.css"; /* colours, type ramp, radii, motion */
@import "@charcuterie/tokens/fonts.css"; /* Baloo 2, Outfit, Victor Mono */
@import "@charcuterie/ui/styles.css";    /* the four looping affordances + `.charcuterie-scrollbar` */

@source "../node_modules/@charcuterie/ui/dist";
```

That last line is not optional. Tailwind v4 scans **source text** for complete class
strings, and it does not scan your dependencies by default — without it every component
renders unstyled, with no error.

**Both package names, and the CSS line above is why.** The token *types and values* are
re-exported at `@charcuterie/ui/tokens`, so your TypeScript only ever names one package —
but the *stylesheet* is imported from `@charcuterie/tokens` directly. A `./tokens.css`
export pointing inside this package resolved to nothing on the first real consumer, because
a hoisting linker puts `@charcuterie/tokens` at the project root rather than in here, and a
CSS `@import` that misses fails **silently**: no error, no utilities, an unstyled app.

The testing gates are at `@charcuterie/ui/testing`.

## Detect a new deployment

`useDeploymentUpdate` detects that a static SPA has been replaced. It works with
`createStaticHandler` from `@charcuterie/server`, which provides a no-cache build marker
and an SSE marker stream by default.

```tsx
const { isUpdateAvailable, reload } = useDeploymentUpdate()

return isUpdateAvailable ? (
  <Button onClick={reload}>Update this app</Button>
) : null
```

The hook never reloads without the user's action. A page can have unsaved state. If the app
already owns an SSE connection, set `isEventSourceEnabled: false` and call
`checkForUpdate()` after that source reconnects.

### While the packages are unpublished: three lines, and you need all three

A consumer links by `portal:` until this publishes
([decision](../../docs/decisions/2026-07-29-consumers-link-tokens-by-portal-until-publish.md)).
M5 spent an hour on the second and third of these, so M5b and M6 do not have to:

```jsonc
// the app package
"dependencies": {
  "@charcuterie/tokens": "portal:../../../charcuterie/packages/tokens",
  "@charcuterie/ui": "portal:../../../charcuterie/packages/ui"
}

// the PROJECT ROOT — `ui` declares its siblings as `workspace:*`, and that
// descriptor cannot resolve outside charcuterie's own workspace. Bare keys, not
// `@charcuterie/ui/@charcuterie/tokens`: the scoped form yields a different
// locator string from the app's own dependency on the same directory, and pnpm
// rejects the pair as conflicting.
"resolutions": {
  "@charcuterie/logic": "portal:../charcuterie/packages/logic",
  "@charcuterie/tokens": "portal:../charcuterie/packages/tokens"
}
```

```ts
// vite.config.ts AND vitest.config.ts
resolve: { dedupe: ["react", "react-dom"] }
```

**That last one is the expensive one.** A `portal:` is a symlink, and Node and Vite both
resolve a symlinked module from its **real path** — so a component living in this package
resolves *its own* `react` by walking up from here, landing on charcuterie's copy while
your app renders with yours. Every component with a hook fails at once with:

```
TypeError: Cannot read properties of null (reading 'useRef')
```

which mentions neither symlinks nor React identity. Keep the line after publish: it costs
nothing with one copy, and it is the difference between a working `pnpm link` session and
an hour of confusion.

## How a component is put together

| Layer | Where it comes from |
| --- | --- |
| Colour | `intentStyles.ts` — six intents x four appearances, written out in full |
| Size | `controlStyles.ts` — `h-(--control-height-md)`, so `[data-density]` decides |
| Type | `text-sm`/`text-md`/`text-lg`, which are **ours**: `theme.css` bridges `--text-*` onto the density-scaled `--font-size-*` |
| Motion | `styles.css`, at `--duration-loop-*`, switched off under `prefers-reduced-motion` |
| Scrollbar | `styles.css` — add `charcuterie-scrollbar` to any scrolling element for a token-tinted bar (rounded thumb on Chromium/Safari; thin standard match on Firefox) that flips with `[data-scheme]` |
| Accent edge | `Card/cardAccentEdge.ts` — `<Card accentEdge={{ categorical }} \| {{ color }}>` draws a bar down the leading edge from a pseudo-element that **inherits the card's radius**. A border cannot follow a corner, which is why three apps had a square bar on a round card |
| State | `@charcuterie/logic` — `useUniqueId` for label wiring, `useStatus` for `MediaTile`'s three image states |

**Class names are never interpolated.** `` `bg-intent-${intent}-solid` `` generates nothing
at all and fails silently, so `intentStyles.ts` is 48 literals rather than a loop, and
`tailwindCandidates.test.ts` compiles every literal in the package through the real Tailwind
and fails on any candidate Tailwind cannot generate.

## Tests

```bash
pnpm vitest run --project ui         # Node: class maps, status switches, clamping, boundaries
pnpm vitest run --project ui-dom     # chromium: Component.test.tsx — behaviour, keyboard, ARIA
pnpm vitest run --project storybook  # chromium: every story renders, axe at test: "error"
```

**Stories are demos and carry no assertions**
([decision](../../docs/decisions/2026-07-30-stories-are-demos-tests-are-tests.md), which
supersedes M3's). The DOM half lives in `Component.test.tsx` beside the component, and each
test mounts the **composed story** through `run()` rather than re-assembling the component —
so there is still only one rendering stack, and the subject of a test is the story a reader
sees.

Every component's test calls `expectAgentDrivable(canvas, { role, name })`, which is the
same query an agent will write; `data-testid` appears nowhere in this package and a test
enforces that.

Axe runs once per mount, when `run()` resolves — i.e. before the test has clicked anything.
A state you had to *drive* to reach (an open dialog, a shown popover) audits itself with
`expectNoAxeViolations(canvasElement)`.

Two rules about the docs panel are tests rather than conventions, because both fail
invisibly: `storyControls.test.ts` (a prop typed from another package needs an explicit
`argTypes` entry, or Storybook renders a `{}` textarea for it) and `mdxReferences.test.ts`
(an `.mdx` may not reference a story that no longer exists — `of` resolves at runtime, so a
rename breaks only the rendered page).

## Adding a component

Button is the first complete Gherkin conversion. Its behavior suite is
[`button.feature`](../docs/requirements/button.feature), with the original assertions
mapped in the [requirements guide](../../docs/gherkin-requirements.md#button-coverage).
The sibling `.test.tsx` convention below still applies to components that have not migrated.

1. `src/<Name>/<Name>.tsx`, `.stories.tsx`, `.mdx`, `.test.tsx` — siblings, matching
   mux-magic. The story shows it; the test drives it. Neither does the other's job.
   **The story's `title` is `Components/<Group>/<Name>`**, and the group is one of
   `Actions` (it does something), `Controls` (it takes a value), `Overlays` (it renders
   over the page), `Layout` (it arranges other things), `Data` (it shows a set) or
   `Feedback` (it reports state). Nothing composable goes at the top level; a helper that
   is not a component — `VisuallyHidden`, `Scrollbar` — is `Utilities/<Name>`. Group order
   in the sidebar comes from `packages/docs/.storybook/preview.tsx`, and an unlisted group
   sorts last instead of silently displacing `Actions`
   ([decision](../../docs/decisions/2026-08-21-a-deprecation-is-a-badge-in-place-and-the-sidebar-has-groups.md)).
2. Colours from `intentStyles.ts`, sizes from `controlStyles.ts`. No hex, no `*-slate-*`
   (a test checks).
3. The five stories: `Playground`, `AllVariants`, `AllStates`, `Responsive` (three
   container widths, via `ContainerBoard`), `Interactive` (the complete keyboard path).
   **`Playground` is the only one that carries `parameters: playgroundParameters`.** The
   addon panel is closed on every story in this Storybook, and that line is how the one
   story whose `args` drive a single instance opens it again. The other four render their
   own matrix, so a control moves at most one cell of the board
   ([decision](../../docs/decisions/2026-09-13-the-addon-panel-is-closed-except-on-the-playground-story.md)).
4. Export from `src/index.ts` — the one sanctioned barrel. Components import each other
   directly, never through it.
5. **If it is an overlay, portal it to the body** — `FloatingPortal`, through
   `OverlayPanel`, never a hand-rolled `<dialog>` + `showModal()` or `popover="manual"`. The
   top layer wins paint order but is still laid out in place, so a `transform` or
   `overflow: hidden` ancestor still clips a fixed panel; a portal to `document.body` has no
   ancestor to be clipped by
   ([decision](../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md),
   superseding [M4's](../../docs/decisions/2026-07-30-overlays-use-the-top-layer-not-a-portal.md)).
   The old objection stands answered rather than waved: `useRole` writes
   `aria-controls`/`aria-labelledby` across the boundary, and the stories scope panel queries
   to `body`.
6. **If it is built on a registering kind, look at its first paint.** Members register
   from effects, so before those run a `RovingFocus` has no active value and a
   `SinglePicker` / `VisibilityGroup` has no selected key — only a pending one. Two of M4's
   four bugs were exactly that, and the isolated story runner saw neither —
   `pnpm smoke:storybook` did.
7. **If it declares `@container`, every `StoryCell` holding it needs `align="stretch"`.**
   `container-type: inline-size` forbids the element from being sized by its own
   contents, so a default (shrink-to-fit) cell collapses it to min-content and every
   line wraps after one word. Valid CSS, no error, nothing for axe to say — it only
   shows up in a screenshot, which is how it shipped in M3's `LiveStatusIndicator`
   board. `sourceRules.test.ts` now derives the container-declaring components from
   source and fails on this, so the component M4 adds joins the rule automatically.

**`Chart`** provides responsive bar and line plots, negative values, gaps for missing data, and an optional semantic values table. TanStack provides hover/touch tooltips, keyboard navigation, and transitions with reduced-motion support. Set `renderer="portable"` for the lightweight fallback. The `@charcuterie/logic/charts`, `/charts/preact`, and `/charts/react` subpaths expose static and framework-specific surfaces; `/core` remains dependency-free. Apps supply their own series, labels, and semantic colors.

**HTML notes** use optional `@charcuterie/ui/html-view` and `/html-editor` entries: `HtmlView` renders a safe document projection and `HtmlEditor` preserves original HTML until a real edit. The existing markdown editors keep their markdown-only storage contracts.
