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 fromshadcn/tailwind.css(scroll-fade and friends), andtypeset.css— the styling system for rendered Markdown.- The zinc palettes for
:rootand[data-theme="dark"]. Themes still switch on thedata-themeattribute the app stamps; a@custom-variantbridges the registry'sdark: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 inui/src/theme.tsor the mapped utilities (text-status-doing). --primaryand--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 basefrom--borderand--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 byshadcn addand 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.RecordDraweris 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.tscarriescn();ui/src/hooks/the registry hooks.
Adding a component
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 thedata-densityattribute on the root element, flipped from the settings dialog alongside the theme. Both are browser preferences the shell owns and persists inlocalStorage;components/Settings.tsxrenders 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.tsholds four rungs, 4px apart, andButton,Input,InputGroupandNativeSelectcompose their variants from it — sosize="sm"is 28px whichever of them you wrote it on, and a toolbar that mixes them cannot sit crooked.defaultis 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 ridesm; the shell header and the dialogs ride the default; Triage's decision row is the one deliberatelg, 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.tsfails on any rung height applied to a control, while arbitrary values are left alone, becauseh-[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 addis 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 ofcomponents/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 inui/srcfails the suite — the brand mark in the sidebar strokescurrentColorfor 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 throughbody:. 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.tsfails 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.tsserialises — 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 isstatus, 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 auseState, 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 isrecordNeighboursinnavigation.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 toonSelect. Absent, not guessed, where there is no list: a[[LRN-0004]]in a body, arelatedrow, 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 reasonread-only.tsxgives: 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.tsxowns the whole bar in every view that has one — the shell, Docs, History, Memory, Workflow and the Gantt toolbar — andFilterChipandFilterToggleare 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:FilterSearchis 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
pointerdownhandler, so on a phone a drag that started on a chip opened the menu instead of scrolling the strip.FilterChipcancels 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 theclick, 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-xon 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 incomponents/ui/— that file is regenerated, and the change would take every menu in the application with it.test/filter-bar.test.tspins 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 isclaim.state, computed on the server fromcards.claimLeaseHours— the interface never carries a second copy of that threshold, andRuntimeSchemadeliberately does not publish the number. Its scope overlaps come fromactivity.conflicts(claimed cards, different actors, shared paths), not frommain.tsx'sscopeConflicts, 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.tspins that order.A collapsed rail names itself; an expanded one stays quiet.
SidebarMenuButtontakes atooltipprop for this andmain.tsxdoes not use it. The prop renders the tooltip in both states and only marks ithiddenwhile 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.NavTooltipmounts the content only while the labels are hidden, and keeps theTooltiparound 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.tsholds 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.