<!-- https://workfile.illodev.com/docs/ui · generated from packages/workfile/docs/ui.md -->

# The interface

The UI is a React application in `ui/`, compiled by Vite into `dist/ui` and
served by the same `node:http` server that answers the API. It ships
precompiled: `files` publishes `dist`, so nothing the interface is built with
reaches a consumer's `node_modules`.

## Zero runtime dependencies is a published guarantee

`dependencies` is empty. `@types/node` is an optional peer dependency —
unpinned, and never installed for you — because two of the published `.d.ts`
files name Node types, and a consumer type-checking them with
`skipLibCheck: false` needs them. React, Radix, Tailwind, Lucide and the
shadcn tooling are all `devDependencies`.

This is enforced, not documented and hoped for. `test/dependencies.test.ts`
asserts that `dependencies` is empty and that the only peer is the optional
`@types/node`, and also that there are no `optionalDependencies`,
`bundleDependencies`, or install hooks that would smuggle a tree in past that
check. `test/design-system.test.ts` asserts the empty list a second time, from
the other direction.

The guard matters because `shadcn add` writes its imports into
`dependencies` by default. One un-corrected run would publish Radix, Lucide
and CVA into every consumer's install, and nothing about the repository
would look wrong. Install what a component needs yourself, with `-D`,
before running `add` — then check the test still passes.

`pnpm run smoke:package` goes further: it packs the tarball, installs it in
a clean consumer, and checks that React is absent from the resulting tree.

## shadcn/ui is the design system

The third migration (`ADR-0005`) put shadcn/ui on Tailwind v4 underneath
the interface — wholesale, zinc as published, after two reverted attempts
taught that adopting a framework means adopting its look. The bespoke
stylesheet is gone; `ui/src/styles.css` is now the token bridge:

- `@import "tailwindcss"`, the registry's shared utilities from
  `shadcn/tailwind.css` (scroll-fade and friends), and `typeset.css` — the
  styling system for rendered Markdown.
- The zinc palettes for `:root` and `[data-theme="dark"]`. Themes still
  switch on the `data-theme` attribute the app stamps; a `@custom-variant`
  bridges the registry's `dark:` utilities to it.
- The three semantic namespaces — `--status-*`, `--priority-*`, `--sev-*` —
  ported byte-for-byte from the system they outlived. They are the first
  named exception ADR-0005 allows, applied through the helpers in
  `ui/src/theme.ts` or the mapped utilities (`text-status-doing`).
- `--primary` and `--primary-foreground`, the second and last exception:
  the landing's brand blue rather than zinc's near-black (`ADR-0009`).
  Everything that means "the primary action" follows the token, so no
  component knows about it.
- `--row-h`, the single density token: 40px compact by default, 48px under
  `:root[data-density="comfortable"]`. Tables key their row height off it.
- Scrollbars, styled once on `*` in `@layer base` from `--border` and
  `--muted-foreground`. Declared there rather than as a class because a
  scroller that forgets the class is exactly the one that looks wrong.

`test/design-system.test.ts` enforces the direction: the framework imports
must be present, the registry must exist and stay free of application
imports, no component may speak the dead bespoke vocabulary or name a
colour literal, every `var()` referenced must be declared, and the dark
palette must follow `data-theme`. `test/tokens.test.ts` walks the theme
blocks with a real parser and fails if the dark palette drops a token the
light one declares.

### Where components live

- `ui/src/components/ui/` is the registry — generated by `shadcn add` and
  replaced wholesale on regeneration. Hand-edited only where a comment in the
  file says why; the control scale below is the one standing amendment.
- `ui/src/components/domain/` holds the virtual table, the kanban and the
  Gantt: Workfile's own decisions about how work is displayed, composed
  from registry parts.
- Everything else in `ui/src/components/` is application glue — the
  inspector, the editors, the palette, the settings dialog. `RecordDrawer`
  is the overlay a record is read in, and both the card inspector and the
  memory record go through it: one drawer, one set of dismissal rules.
- `ui/src/lib/utils.ts` carries `cn()`; `ui/src/hooks/` the registry hooks.

### Adding a component

```sh
pnpm dlx shadcn@latest add <component>
node --test test/dependencies.test.ts     # nothing reached dependencies
node --test test/design-system.test.ts    # registry discipline holds
```

The CLI resolves `@/` from the **root** `tsconfig.json` — its `paths` entry
exists solely for this, and removing it makes `shadcn add` write into a
literal `@/` directory beside `package.json`. App code resolves the same
alias through `ui/tsconfig.json` and the matching `resolve.alias` in
`vite.config.mjs`; the two must stay in sync. Note `ui/tsconfig.json`
declares `paths` without `baseUrl` — TypeScript 7 removed `baseUrl`, and
reintroducing it aborts the whole typecheck with TS5102 before a single
file is checked.

### Conventions the framework does not decide

- **Native selects in table rows** — never a portal select mid-row. The
  registry's NativeSelect restyles the real `<select>` the Explorer rows
  depend on.
- **Density is one token.** Components never hardcode a row height; they
  read `var(--row-h)`. The comfortable/compact switch is the `data-density`
  attribute on the root element, flipped from the settings dialog alongside
  the theme. Both are browser preferences the shell owns and persists in
  `localStorage`; `components/Settings.tsx` renders them and stores nothing,
  because a theme that needed a dialog mounted to exist would be worse than
  the two header buttons it replaced.
- **Controls share one height scale.**
  `ui/src/components/ui/control-size.ts` holds four rungs, 4px apart, and
  `Button`, `Input`, `InputGroup` and `NativeSelect` compose their variants
  from it — so `size="sm"` is 28px whichever of them you wrote it on, and a
  toolbar that mixes them cannot sit crooked. `default` is 32px, the second
  rung from the foot: this is a record tool, and before the scale existed
  twenty-one hand-written heights had already patched the registry's 36px
  down, which is exactly how the Memory field ended up one rung taller than
  the chips beside it. Filter strips ride `sm`; the shell header and the
  dialogs ride the default; Triage's decision row is the one deliberate
  `lg`, because it is the only place you sit and hit the same seven buttons
  card after card. A height class written onto a call site is the bug —
  `test/control-size.test.ts` fails on any rung height applied to a control,
  while arbitrary values are left alone, because `h-[22px]` on the Explorer's
  row select is how a view says "deliberately off the scale" rather than "I
  could not reach it".

  This is the one place the registry is deliberately not kept as generated;
  all four files carry a comment saying so, and the amendment has to be
  re-applied if `shadcn add` is ever run over them. Sizing is precisely the
  change you want to take every component at once — the mirror image of the
  chip's pointer rule below, which is kept *out* of `components/ui/` for the
  same reason read the other way.
- **Colours are tokens.** Status, priority and severity ride the semantic
  namespaces via `theme.ts`; everything else is a shadcn token utility. A
  literal colour anywhere in `ui/src` fails the suite — the brand mark in
  the sidebar strokes `currentColor` for exactly that reason.
- **Free text is one control that says what it matches.** Every filter bar
  renders `ui/src/components/FilterSearch.tsx`, and its two placeholders are
  the only place the match rule is written down. The record collections
  search on the server over id, title, metadata and body — the body by whole
  token, the title by substring — while the card views filter in the browser
  over identity and metadata, reaching prose only through `body:`. Two
  corpora, so two sentences, neither promising what the other does. The term
  rides the address bar like every other filter (`?q=` for cards, `?find=`
  for docs, history and memory). `test/filter-search.test.ts` fails if a view
  grows a box of its own or a wording of its own.
- **A filter that is not in the URL is a filter that dies on reload.** Every
  one of them is state the shell owns and `ui/src/query.ts` serialises — the
  card axes flat (`?status=`, `?area=`, …), the record collections' axes
  namespaced by view (`?docs-managed=1`, `?history-state=`,
  `?memory-collection=`, `?memory-status=`). The prefix is a rule and not a
  case-by-case choice: the obvious name for Memory's is `status`, which the
  card filter already owns, and the loser of a clash like that filters by
  nothing without saying so. A record view therefore takes its filters as a
  prop and reports changes as a patch, so its coupled pairs — picking a Memory
  collection clears the status that belonged to it — reach the address bar in
  one write. Same suite: it fails on a view that takes one back into a
  `useState`, and on a parameter that collides with a card axis.
- **A record opened from a list can be read as a sequence.** Every panel that
  reads a record — the card inspector, the memory panel, the generic record
  panel, and the readers Docs and History own themselves — renders
  `ui/src/record-cursor.tsx`, and the rule for where previous and next go is
  `recordNeighbours` in `navigation.ts`, beside the other navigation rules. The
  list is whatever the view was showing, in the order it was showing it, so it
  narrows when the filters do; each view publishes its own as the second
  argument to `onSelect`. **Absent, not guessed, where there is no list:** a
  `[[LRN-0004]]` in a body, a `related` row, the command palette, and a node of
  the Workflow graph all open a record with nothing behind it, and a force
  layout is not an order. At the ends of a real list the control renders with
  one half disabled, which is how a reader tells "no next" from "there was
  never a sequence here". It is a context rather than a prop for the reason
  `read-only.tsx` gives: the panels sit in three different places, and all
  three have to reach it.
- **The filter bar is one container, and it decides what may scroll away.**
  `ui/src/components/FilterBar.tsx` owns the whole bar in every view that has
  one — the shell, Docs, History, Memory, Workflow and the Gantt toolbar — and
  `FilterChip` and `FilterToggle` are declared there once rather than in each
  of them. Controls go in the strip, which keeps to a single line and scrolls
  sideways; the free-text field (`before`) and anything you have to reach in a
  hurry (`after`, the graph's Fit) stay outside it, because everything in the
  strip may scroll out of sight. That split is what T-0193 and T-0195
  disagreed about: `FilterSearch` is a control the bar positions, not a second
  container. The bleed classes cancel the bar's own gutter so the strip runs
  to the screen edge, which is why they are a written-out pair per gutter
  rather than a computed one — Tailwind reads class names as literals.
- **A chip in a strip opens on the click, not on the press.** Radix opens
  menus from a `pointerdown` handler, so on a phone a drag that started on a
  chip opened the menu instead of scrolling the strip. `FilterChip` cancels
  that press for touch and pen — the primitive composes its handler after the
  one it is passed and skips a default-prevented event — and opens from the
  `click`, which the browser withholds once the finger has scrolled. A mouse
  keeps the primitive's behaviour, where press-drag-release onto an item is a
  real way to use a menu. `touch-action: pan-x` on the scroller was the other
  candidate and it is neither necessary nor sufficient: measured in Chromium
  at 390 points with touch emulation, on its own the menu still opened and the
  strip still did not move. The rule lives in application code, never in
  `components/ui/` — that file is regenerated, and the change would take every
  menu in the application with it. `test/filter-bar.test.ts` pins the
  mechanism; only a browser can prove the behaviour.
- **The footer's claim area is one control.** The ledger strip and the
  compact badge beside the doctor chip are two triggers for the same popover,
  because the strip is `lg:` only and a narrower window would otherwise have
  no way in. What the popover says about staleness is `claim.state`, computed
  on the server from `cards.claimLeaseHours` — the interface never carries a
  second copy of that threshold, and `RuntimeSchema` deliberately does not
  publish the number. Its scope overlaps come from `activity.conflicts`
  (claimed cards, different actors, shared paths), not from `main.tsx`'s
  `scopeConflicts`, which pairs in-progress cards whether or not anybody
  claimed them and stays on its own work-view alert. Rows are ordered worst
  first in the ladder the Overview's verdict sentence already uses, so the two
  surfaces cannot disagree about which claim matters;
  `test/claim-ledger.test.ts` pins that order.
- **A collapsed rail names itself; an expanded one stays quiet.**
  `SidebarMenuButton` takes a `tooltip` prop for this and `main.tsx` does not
  use it. The prop renders the tooltip in both states and only marks it
  `hidden` while the rail is expanded, and hidden is not unmounted: Radix
  still opens it on hover, and an open tooltip is a dismissable layer that
  answers Escape in the capture phase — so a hovered rail would take the key
  off the shell for no reason the reader can see. `NavTooltip` mounts the
  content only while the labels are hidden, and keeps the `Tooltip` around
  the button in both states, because a wrapper that came and went would
  change the element type at that position and have React rebuild the button
  underneath, dropping keyboard focus on every toggle.
  `test/shell.test.ts` holds both halves.
- **Escape belongs to the topmost overlay.** The shell's global Escape
  handler is the floor under the Radix layers and skips a key one of them
  already consumed (`event.defaultPrevented`). Asking the DOM which dialog
  is open does not work: the layer that handled the key has already
  unmounted by the time a bubble-phase listener runs.

## Demo builds

`pnpm run build:demo` produces `dist/demo`, a static bundle that replays a
snapshot of this repository's own workspace with in-memory mutations. It has no
server behind it, so **every view must reach the network through `ui/src/api.ts`**.
A component calling `fetch` directly gets a 404 that its own catch swallows, and
the feature is simply absent from the hosted demo — which is how the presence
strip and the command palette were both silently dead there.
`test/demo-parity.test.ts` fails if any view does this.
