# webapp — the assistant-ui-native front end

> ↑ [root](../CLAUDE.md) · visual contract: [design-system.md](design-system.md) — **this app's**, not the TUI's ([docs/design-system.md](../docs/design-system.md) is that one, and the two are deliberately unmerged).

Grove's one web front end: talks to the daemon over the same OpenAPI as everything else, with none of the bespoke component layer an earlier, since-deleted dashboard carried. That predecessor is why the rules below exist — see "Two things deliberately not ported" and the vendored-verbatim rule. Built under epic #469.

## The one rule

**Every visual component here is generated by `assistant-ui add` or `shadcn add` and committed verbatim. Grove code composes them and never restyles them.**

`components/{assistant-ui,elements,ui,icons}/` is upstream source we happen to store. Hand-editing a file there is the failure mode this whole app exists to prevent — you lose upstream fixes silently, and the styling drifts back toward the thing nobody liked. If a vendored component is wrong, change the composition around it or raise it upstream.

`components/grove/` is the only hand-written component tree. It composes; it does not style. No colour, radius or shadow utilities live there.

**Take the census of `components/elements/` BEFORE composing anything — the vendored tree is bigger than anyone remembers, and the failure is silent.** Two complete components sat unused while work was planned around their absence: `model-selector.tsx` (a Popover + cmdk combobox with per-item icon/description/keywords/disabled and every sub-part exported) and `composer.tsx` (664 lines: `Composer`, `ComposerBar`, `ComposerToolbar`, `ComposerActions`, `ComposerAttachButton`, `ComposerSend`, `ComposerMenu`, slash/mention items, `ComposerModelTrigger`, `ComposerContext`, `ComposerVoice`). The landing epic was filed asserting *"the vendored composer's action row exposes no slot"* — `ComposerToolbar` is that slot, `justify-between`, arbitrary children, shipped the whole time. That false premise nearly bought a `PORTED_FILES` exemption for a copy of a file already in the tree.

The trap has a shape: **`components/assistant-ui/` is where people look, and `components/elements/` is where half the vocabulary lives.** Reading the one you know is what produces "there is no seam for this". `ls components/elements/` costs a second; `curl -s https://r.assistant-ui.com/registry.json` lists all 139 items, most of them not vendored yet. Do both before concluding anything is missing.

**[design-system.md](design-system.md) is the prescriptive companion to this rule** — the type ramp, the three content tiers, mono semantics, colour/button/badge/icon taxonomies and the required states, each named as a token or a class, plus the per-surface audit map the incremental PRs are cut from.

## The finish bar

**If it looks like the default, it wasn't a decision.** Every defect in the 2026-08-11 design review was the same defect: the browser's own rendering of our data, shipped as though it were a design. An unstyled scrollbar. A table that is rows laid down after parsing. A placeholder logo. `Grove · main`, where a project and a branch are the same pixels and you recover the types from word order. None of these are bugs in the sense of something failing — that is exactly why they survive review, and why the rule has to be stated as a look rather than a behaviour.

**Flat is not clean; flat is undesigned.** "Modern and clean" is layering, spacing rhythm and hierarchy, not subtraction — a rail and a content area meeting at a 1px seam is not minimal, it is two things that were never given planes. Depth is what makes a surface read as a surface. Note the measured corollary in the shell-panel token: the tint step that produces depth **reverses between light and dark**, so elevation is a relationship between two layers, never a fixed colour.

**Type the data, and type it identically everywhere.** An entity should be identifiable at a glance rather than read left to right; `components/grove/entity.tsx` exists so a project, a branch or a location is the same object in the rail, in a card and in a table cell. Two typed entities also need no separator — the middot in `Grove · main` was punctuation standing in for the type information the line never carried, so the glyphs replace it rather than joining it.

**A screen is finished when its STATES are, not when its data renders.** Hover, keyboard focus, truncation-with-tooltip, loading, empty, and error. Empty-after-filtering is a *different* state from empty, and it needs a way back out; conflating them is how a filter becomes a trap. Navigability is a requirement, not a stretch goal: a scrolling table freezes its header, a list that can grow gets search that genuinely narrows it, and anything clickable looks clickable.

**Review by comparing finish, not inventory.** Put the screen beside its reference and ask "does this feel like the same level of finish", never "does it have the same elements". Element parity is what the happy path already gives you; the gap is always in the states and the layering, which is precisely what an element checklist cannot see.

## Layout, and why

```
app/            routes only — thin, no logic
components/
  assistant-ui/ ┐
  elements/     ├ VENDORED, verbatim
  ui/           ┘
  grove/        the only hand-written components
lib/grove/
  api/          generated types + typed client — ALL network I/O
  adapters/     PURE: wire shape → assistant-ui props
  runtime/      ExternalStoreRuntime + thread-list wiring
  hooks/        react-query + the SSE subscription
```

Dependencies flow inward: `app/` → `components/grove/` → `adapters/` (pure) → `api/` (edge). The adapters are pure because that is what makes them testable without a daemon — it is the acceptance criterion for that layer, not a stylistic preference.

## Hard-won specifics

- **Two registries, not one.** `assistant-ui add <x>` only resolves names in `r.assistant-ui.com`. Bare shadcn primitives (`breadcrumb`, `card`, `table`, …) need `npx shadcn@latest add`. Reaching for the wrong CLI fails with "item not found", which reads like the component does not exist.
- **`elements-surfaces` and `elements-range` must sit in `components/elements/`.** They are declared `registry:lib`, so shadcn's alias routes them to `lib/` — but all 40+ elements import them as `./surfaces` / `./range`, and their own registry manifest declares `components/elements/`. Installed to the default location, the whole elements tree fails to resolve. Move them after any re-add.
- **The CLI needs Node 22.** This host defaults to 20; `. "$NVM_DIR/nvm.sh"; nvm use 22` first or npx dies on an engine mismatch.
- **`withAui()` in `next.config.ts` is not a component alias.** It is the `"use generative"` compiler that splits a tool's `execute` (server) from its `render` (client). It does nothing for component resolution, so the scaffold's `@/components/assistant-ui/thread` import genuinely requires vendoring.
- **The scaffold writes `"latest"`** for the `@assistant-ui/*` specifiers. Pin them; a team cannot reproduce a build against a moving target.
- **Next 16 blocks `/_next/*` dev resources cross-origin, and counts a bare IP or hostname as cross-origin.** Opening the app anywhere but `localhost` serves the HTML, 403s a few script chunks and leaves the page silently unhydrated — no visible error, just a dead page. `allowedDevOrigins` in `next.config.ts` is the fix; the dev-server log names it exactly, which is the fastest way to diagnose it.

## When a vendored component is buggy, fix the composition

`components/ui/sidebar.tsx` computes `SidebarMenuSkeleton`'s width with `Math.random()`, so server and client never agree and React logs a hydration mismatch. It is shadcn's bug, and the tempting fix — edit the file — is exactly the one the epic forbids, and `registry:check` fails it.

The rule that resolves this: **change what you render, not what they shipped.** The account menu stopped rendering that skeleton during SSR. A vendored component's bug is worked around at the call site or raised upstream; it is never patched in place, because a patched file silently stops receiving upstream fixes and nothing warns you.

**`Progress` consumes `value` and never forwards it to the Radix root.** It uses `value` only for the indicator's `translateX`, so every meter renders as `aria-valuenow`-less and reads *indeterminate* to a screen reader while looking perfectly filled to everyone else. The caller supplies `aria-valuenow` itself — Radix spreads caller props last, so this works without touching the file. Assume nothing about a vendored component's a11y from the fact that it looks right.

**`opacity-0` + `pointer-events-none` hides a popup from the mouse and from nobody else.** `ComposerMenu` closes that way, so a closed control pill still exposed all six agents to the accessibility tree and the tab order — five pills' worth of options readable at all times on a surface whose whole point is that it is quiet. `inert` is the one attribute that removes both, and it spreads through the vendored component's own prop pass-through, so it goes at the **call site** and the upstream file stays untouched. The a11y snapshot is what finds this; the rendered page looks perfect.

**An SSR render test finds this class of bug in seconds; reading the code does not.** `renderToStaticMarkup` needs no DOM, so these stay in the `node` environment alongside the pure adapter tests. Pin the rendered *attribute* (`aria-valuenow`, a CSS custom property, the absence of an upstream hex) rather than the component's internals — that is what actually regresses.

## Deployment

```
make webapp-build
WITH_WEBAPP=1 WEBAPP_NPM_BIN=<node22 npm> make systemd
systemctl --user enable --now grove-webapp
```

`WEBAPP_NPM_BIN` is separate from the root `NPM_BIN` because this app needs **>= 22** (Next 16), which is often newer than the shell default's Node.

This serves a **pre-built** `.next`: a merge is invisible until `make webapp-build` plus a `systemctl --user restart grove-webapp`.

**Pairing persists to `~/.config/grove/webapp-sessions.json`.** The `grove_session` cookie maps to a bearer through that file — treat its format as a contract other tooling may read, not as this app's private state.

**TypeScript is pinned to the 5.x line, deliberately.** The assistant-ui scaffold ships `typescript@^7` (the native port), but `openapi-typescript` peer-requires `^5.x`, so a clean `npm ci` from the committed lockfile fails outright with `ERESOLVE`. Incremental `npm install` hides this — only a from-scratch install shows it, which is exactly what a new engineer and CI both do. Do not bump to 7 until `openapi-typescript` accepts it.

## Measuring the running app without breaking it

**`next build` is not read-only.** It writes the same `.next` the live `grove-webapp` serves, and a build that fails type checking leaves the directory half-written with no `BUILD_ID`. The running server keeps serving from memory, so nothing looks wrong until the next restart — at which point it 500s every route. Worse, a *successful* rebuild under a live `next start` also 500s every dynamic route immediately: the running process holds the old manifest and the content-hashed chunks it names have been deleted. The console says `ChunkLoadError` and nothing anywhere says "someone rebuilt under me". **One owner builds and restarts; everyone else asks.**

**A route's reachability can only be proven against the BUILT artifact, never `next dev`.** Middleware matcher behaviour differs between the two, so the check that counts is one `curl` against the deployed port after a real deploy — not a request against the dev server, which can pass a route that 307s once actually built.

**To measure a change in isolation, hard-link a scratch copy OUTSIDE the repo.** `cp -al node_modules /tmp/<name>/node_modules` — half a second, no extra disk, and a real directory. Then restore every file you did not touch from `HEAD`, so you measure your change against a clean base rather than three agents' in-flight edits.

Three traps, all paid for:
- **A symlinked `node_modules` does not work** — Turbopack rejects it outright (`Symlink [project]/node_modules is invalid, it points out of the filesystem root`).
- **Never run any `npm install`/`npm ci` in the scratch dir.** With a symlink npm follows it and materialises a whole second tree at the other end — `node_modules/node_modules/` with a second copy of React. Two Reacts means a null hooks dispatcher, and every `renderToStaticMarkup` test dies with `Cannot read properties of null (reading 'useState')` while nothing is wrong with the code.
- **A scratch copy built from a hand-written file list is not the application.** One run copied `app components lib next.config.ts tsconfig.json package.json` and measured the login page there — `middleware.ts` was not on the list, so the copy had no auth gate at all, a route that 307s on the real build passed clean, and the result was reported as "verified end to end". The omission is invisible precisely because everything that WAS copied works fine. **Derive the file list from `git ls-files`**, never type it by hand.

**Patch `window.fetch` / `window.EventSource` via `addInitScript`, before app mount** — patching after navigation reads zero, because the app already captured its references.

**`addInitScript` accumulates on a browser context — a fresh context per measurement, or counts inflate.** A probe read 4 → 6 → 8 → 10 across successive runs against one unchanged page and one unchanged real call: each run's wrapper stacked on the last context instead of replacing it. The asymmetry that makes this dangerous: zero is immune (nothing to double), non-zero is not — an absence claim survives a stale context, a count claim silently doubles.

**An affordance keyed on `:focus-visible` can only be measured through the input modality that triggers it.** A probe using `element.focus()` read `boxShadow: "none"` with the element focused and `element.matches(":focus-visible")` false — the pseudo-class is gated on the browser's own heuristic, and a programmatic focus does not satisfy it. Real `Tab` presses flipped it. **The failure mode is indistinguishable from the feature being absent**, which is how a fix gets reported as broken and a defect gets reported as fixed.

**StrictMode double-invokes effects in dev — a connection count of 2 there is 1 in production.** Verified by reverting the change under test and re-measuring the baseline, rather than explaining the doubled number away as something else.

**A `tinypool ChildProcess.onUnexpectedExit` in vitest output is a killed worker, not a test failure.** Under a saturated host use `npx vitest run --pool=forks --poolOptions.forks.singleFork`. Reading that crash as a failure is how a green branch gets held for no reason.

**Know the noise floor before quoting a number.** On a busy host, three runs of the *identical* build spanned 5.65–8.32 s (±30 %). Anything smaller than that band is not measurable, and a difference inside it must be reported as "no measurable effect", not as a win.

**A bench control that stabilises an input also switches off every mechanism downstream of it — say which, next to the control.** `STABLE_FP=1` exists to isolate refetch cost, and it silently made the whole `turns → messages → converter` chain untestable in the same run: with no refetch, `messagesFromTurns` never runs and `store.messages` is already referentially stable, so any hypothesis about that chain is unfalsifiable under that flag. Three wrong explanations were produced before anyone noticed the control had disabled the path being argued about.

**Count in MESSAGES, not turns.** A turn carries ~100 messages here, so a "48-turn" transcript is ~4,800 components. Every cost that scales with transcript length scales with that number, and quoting turns under-states it by two orders of magnitude.

**If two different approaches measure identically, suspect the server before believing the result.** A preview dev server silently stopped recompiling after its first edit and served a stale DOM through hard reloads and cache-busted URLs — three genuinely different CSS approaches measured byte-identical, which is the only reason it was caught. `rm -rf .next` and restart between changes.

**Count invocations, not milliseconds.** On a host whose noise floor is ±30 %, a timing comparison cannot resolve anything smaller — but a **deterministic counter** can. Four candidate mechanisms for one cost were argued from timings and none was settled; a selector-call counter answered it in a single run, and re-running an earlier "no measurable effect" with the counter showed **byte-identical counts (116,400 both ways)**, proving that null was a true negative rather than an artefact. Reach for the counter first.

The answer it gave is worth keeping: the transcript's message components **do not re-render at all** (0 renders across 30 frames) while every one of their `useAuiState` selectors runs exactly **once per message per frame** — 3,880 messages, 3,880 calls a frame. The cost is pure JavaScript with **4 layouts and 81 style recalcs in 30 s**, which is why every fix aimed at stopping a re-render measured as noise: the re-render was never happening.

**Reading a `dist` file is a hypothesis, not a finding.** Three separate mechanisms were derived this way from `@assistant-ui/core`'s external-store runtime and all three were refuted by measurement — including two that had already been written into comments and test names. When the question is "where does the time go", instrument it: a render counter inside one component discriminates, where testing a candidate fix can only ever say "not that one".

**An absence-check must report what it EXAMINED, not just what it found.** A glyph census returned "clean" twice for two different reasons — a single-line regex that never saw three multi-line imports, and a run that scanned zero files after a `cd` reset — and both looked identical to a real pass. Same rule from the SSE work: a probe claiming zero SSE connections must also show the real fetches it DID record, or a dead probe and a clean app report the same "0".

## Gates

`npm run gate` = typecheck → `check:ignored` → `registry:check` → `lint:styling` → vitest. `npm run test:e2e` is separate (it owns port 3005 and will refuse to start if a dev server already holds it).

- **vitest includes `tests/unit/**` and NOTHING else.** A `*.test.ts` written beside its source — the co-located convention most of the ecosystem uses — is collected by a direct `npx vitest run <path>`, passes, and is then **never run by the gate again**. Two launch test files lived at `lib/grove/adapters/launch.test.ts` and `lib/grove/runtime/launch.test.ts` and reported green locally while contributing zero coverage; moving them took the suite from 862 to 872. The tell is that the suite total does not move when you add tests. Same family as the environment-pragma trap below: a file that pins nothing looks exactly like a file that passes.

- **`registry:check`** re-fetches every vendored item and diffs it. This is the keystone: it is what stops the styling drifting back by a thousand small edits. It also makes instrumenting a **vendored** component impossible in-tree — a render counter inside `components/assistant-ui/**` fails as a drift violation, which reads like the wrong problem. **The ports are the seam that makes measurement possible at all**: `components/grove/workspace/thread.tsx` is skipped by `lint:styling`'s `PORTED_FILES` and never seen by `registry:check`, so a probe belongs there (in a scratch copy) rather than in the vendored original. That is the file the next person profiling the transcript will want.
- **`check:ignored`** fails if any source file under `webapp/` is invisible to git. It exists because `webapp/lib` was silently swept up by the repo's Python `lib/` rule (see below) and 19 files of a data layer read as committed while being untracked.
- **`lint:styling`** fails on colour/radius/shadow utilities under `components/grove/`. It strips comments first — before that it flagged a comment *explaining* why a radius utility had been avoided.
- **NEVER SPELL AN ENVIRONMENT PRAGMA IN PROSE.** Vitest greps the whole file for `@vitest-environment <name>`, so a comment *mentioning* the jsdom one — including one warning you not to use it — switches that file to jsdom. jsdom does not load on this host at all (`webidl.util.markAsUncloneable is not a function`, thrown from undici via `jsdom/lib/api.js`), so the file collects **zero tests** and reports an unhandled error, which reads as coverage while pinning nothing. **The tell is `setup 0ms` in the run summary** — the file died during environment resolution, before the setup file ran, so nothing in the test itself is at fault. Say "the jsdom environment" in words; never write the token.
- **`codegen` builds the schema from THIS CHECKOUT, in process, and must never go back to fetching a URL.** It used to read `127.0.0.1:7421/openapi.json`, which answers for whatever code the *installed* daemon booted with — so two contract fields added in the working tree came back **absent while the script reported success and rewrote 141 unrelated lines**. That is the shape to recognise: the file changes, so it looks like it worked, and nothing distinguishes "regenerated" from "regenerated against the wrong source". **`codegen:check` inherited the same flaw** and could pass green against a stale daemon, which means the gate built to catch drift was the thing hiding it. It runs `build_app(...).openapi()` with **bare defaults**, never the real config cascade, so a developer whose user config enables an extra surface cannot commit routes nobody else has.

## The gitignore trap

The repo's root `.gitignore` has a Python distutils `lib/` rule that sweeps up any `webapp*/lib`; `webapp/lib` is negated explicitly. **A new front-end app needs its own negation**, or `git add` will refuse its data layer without saying why and a bare `git add <app>` will skip the tree in silence.

## One card system: `components/grove/card.tsx`

`components/grove/card.tsx` is the ONLY file under `components/grove/` that imports the vendored `Card`. Three systems used to exist — a work-panel card, a `UsageSection`, and the fleet's hand-composed summary — plus five more places that open-coded the same `Card className="gap-0 overflow-hidden py-0"` or the same Collapsible-plus-chevron-plus-trigger row. Seven places decided what a card looked like; now one does. `CardShell` is the container (and the only place radius and elevation enter Grove code), `SectionCard` is the titled section over it, `CardDisclosure` is the collapsible row, and `CardGrid`/`CardScroll`/`CardStat`/`CardFields` are the vocabulary around them. They compose; they do not configure — a primitive with eight booleans would be worse than the three systems it replaced.

**A `SectionCard` is not for everything with a header: a section header names a TOPIC, a summary header names the OBJECT** — identity-plus-state in a tinted bar reads as a *title bar for a thing*, while the same bar over a topic label reads as a *section of a page*. So the fleet's workspace card IS a `SectionCard`, its title, repo/branch subtitle and status badge landing exactly on `title`/`description`/`action` with the agent's brand mark in `icon`, helped by context the chrome does not have to carry — it is a link among siblings in a grid. The usage page's stat tiles are NOT: one word, no action, so a tinted band plus a rule would outweigh the content, and they take `CardShell` and compose their own two rows — same principle, opposite answers, which is the test.

**Two scroll idioms is how a silent clip ships.** `CardScroll` bounds with `max-h-*`; the deleted `BoundedList` bounded with a radix `ScrollArea` at `h-*`. A caller that had passed `h-72` to the old one kept it against the new one's `max-h-64`, which would have clipped a list to 256px instead of 288px — invisible to the type system (different CSS properties, so `tailwind-merge` has nothing to resolve) and invisible to the tests. Bound with `max-h-*` and there is one idiom to get wrong.

**A test that asserts `data-slot="scroll-area"` is pinning the vendored container, not the contract.** The contract is "this list bounds its own height and scrolls internally", which `max-h-*` plus `overflow-y-auto` says exactly. Two usage tests failed on the idiom change while the behaviour they named was intact.

## The shell is the base demo's rail, NOT shadcn's `Sidebar`

`AppShell` is one `relative flex h-dvh` row: an `aside` that animates `w-12` ↔ `w-98` (392px), a `Sheet` for phones, and the page slot. **That width is the only rail width in the tree** — everything inside the rail is `w-full`, because a second copy is what let the docked measure leak into the mobile sheet once already. shadcn's `Sidebar`/`SidebarProvider` was removed deliberately — it makes **the rail** the floating, rounded, separately-elevated object, and the elevation runs the other way round (see below). The three things that primitive gave us free (width transition, collapse shortcut, mobile sheet) are a handful of lines each.

**The shell is TWO layers, and the elevated one is the CONTENT.** The rail and the page's gutter share one tint (`bg-muted/30`); the page sits on top of it as a `shell-panel` — `bg-background`, `var(--radius)`, `overflow: hidden`, and **no border and no shadow**, measured off the live base demo rather than guessed. So the rail has *no* `border-r`: a rule between two halves of the same layer says they are peers, which is the flat single-plane reading this replaced. Two consequences worth knowing before you touch it:

- **The tint step REVERSES with the theme, and that is the point.** `bg-background` is lighter than `bg-muted/30` in light mode and darker in dark mode, so the panel reads as depth rather than as a colour. `bg-card` is the wrong token for this and a `Card` is the wrong component: `--card` is *lighter* than `--background` in dark mode, so it inverts the cue, and it brings a border and a shadow the demo has neither of.
- **The gutter is `p-2 md:pl-0`, not `p-2`.** At `md` and up the panel butts against the rail; below it the rail is a `Sheet`, absent from the row, and the panel floats clear on all four sides.

Because the panel wraps `children`, the page's `ShellHeader` and its `actions` land *inside* it with no change to any page — a page is still a plain `min-h-0 flex-1` child, one level deeper. That containment is load-bearing rather than decorative: a page's scroll owner is now clipped by the panel, so its scrollbar is inset in the rounded corner instead of running down the window edge.

The header is `h-12`, **no `border-b`**, and carries only the rail toggle, a title, and a page-supplied `actions` node. There is no breadcrumb: a title plus one back-affordance is what the demo shows, and the pane switcher and agent status ride in `actions` rather than each claiming a row.

**Scrollbars are styled once, globally, in `app/globals.css` — never per surface.** The reference does this and Grove had simply never set it, so every scroller wore the browser default: a full-width track with stepper arrows. The block is upstream's, reproduced verbatim from the compiled stylesheet the demo serves — 6px, transparent track, `rounded-full` thumb that fades in from `--muted-foreground` on hover. Two things about it are not obvious. The Firefox path (`scrollbar-width`/`scrollbar-color`) is wrapped in `@supports not selector(::-webkit-scrollbar)` because a browser with the pseudo-elements must not *also* reserve Firefox's `thin` gutter. And it is unscoped on purpose: the rail, every bounded card list, the transcript viewport and the terminal each create a scroller, and a per-surface opt-in leaves whichever one nobody remembered looking like the old app. **Headless Chromium uses overlay scrollbars, so a Playwright measurement can never see this** — `offsetWidth === clientWidth` on a scroller either way. Diff the emitted CSS rules against the reference's instead.

**`absolute inset-0` inside a page is a trap.** The shell row is the nearest `relative` ancestor and it *contains the rail*, so a page that positions itself absolutely paints over the sidebar — the symptom is the page title rendering on top of the brand. Pages are plain `flex min-h-0 flex-1 flex-col` children; the bounded height they need is already there (`h-dvh` row, `h-full flex-1 overflow-hidden` slot).

## Expanding a surface: MOVE the component, never clone it

The landing composer's expanded writing mode is a controlled `Dialog` around the **same** `LaunchComposer`, rendered in exactly one place at a time (`{expanded ? null : composer}` beside `<DialogContent>{composer}</DialogContent>`). Two rules came out of building it.

**Rendering both copies is the obvious implementation and it is wrong in the accessibility tree before it is wrong anywhere else** — one draft with two editors, two tab stops and two identical accessible names. Moving is only affordable because the draft and every control value already live ABOVE the composer (`useLaunchSubmit`, `LaunchStateProvider`), so the remount carries nothing; a surface whose state sits *inside* the component would have to lift it first, and that lift is the real work. Assert it as "exactly one control named X", which is a statement about the live DOM — the unit suite is SSR-only and structurally cannot see a dialog that opens on click.

**A dialog's title becomes its accessible name, so it must not reuse a name something inside it already has.** Titling the dialog `Task brief` — the textarea's own `aria-label` — produced one screen reader announcing two different things by the same name, nested. Radix also restores focus to the trigger on close, and here the trigger lives inside the composer that just unmounted, so focus fell to the body: `onCloseAutoFocus` hands it to the restored inline editor instead. **Both defects were invisible in review and obvious in a browser**, which is the same lesson the shell-seam rule already states, applied to one component.

**assistant-ui ships nothing for this** (checked at `@assistant-ui/react` 0.15.13 and against all 139 registry items): its only `fullscreen` is MCP app display config, and `elements-mobile-composer` is a single-line input, not an expanded editor. That is what licenses a Dialog composition here rather than a vendored primitive — record the check, because the next person will reasonably assume one exists.

## A discriminator that is also a SHARED KEY must be unique per consumer

`LaunchPillKind` names a control *and* keys the row's single open-menu state (`open={openKind === kind}`), so two pills passing the same member is one dead control: both popovers open stacked and whichever renders second is unreachable underneath the first. The project and working-directory pills shipped that way. **Nothing detects it** — each file is individually correct, the union is exhaustive, `tsc` is happy, and the pill renders and highlights normally; it simply does not respond. Duplicate-as-a-mistake is invisible precisely because duplicate-as-a-value is legal.

Two rules follow. **Say at the type that the union is a key**, because a reader looking at one call site cannot see the constraint. And **pin it as a cross-file census** rather than a per-component assertion — the defect lives in the relationship, which is the same reason the assembled-surface rule exists in the root guide. A census must also assert it *examined* something: a uniqueness check that scanned zero files reports "clean" exactly like one that scanned them all.

The same shape produced the sibling bug beside it. Splitting one field into two — `projectCwd` (what the user picked) and `selectedProjectCwd` (the project's identity) — left the project pill reading the one that had moved out from under it, so it matched nothing after either selection. **When you split a field, grep its old name**: every reader is now ambiguous, and the compiler cannot tell you which meaning each one wanted.

## A control that hides its value behind a mark is not quiet, it is unlabelled

The launch row collapsed an untouched agent, runtime and branch to a bare glyph, on the argument that a control still sitting on the cascade's answer has nothing to say the mark does not. That is wrong wherever the mark is per-CONCEPT and the value is per-INSTANCE: one Claude glyph cannot distinguish `Claude Code` from `Claude Code (via KK Gateway)`, and three anonymous icons beside two worded pills read as decoration rather than as controls.

Width is what that traded for, and the cheaper currency is a **second row, authored rather than wrapped**. Free wrapping puts the break wherever the text happens to run out, which orphans a lone control the moment one pill is conditional; splitting by meaning (*where* the work happens, then *who* does it) keeps the shape stable across projects with different labels and different `agent_cwds`. Anchor the send affordance to the last line (`items-end`) so the corner it lives in does not move when a row appears.

## Ports: when a vendored component has no seam

Two files reproduce a vendored component's anatomy because it exposes no prop for what Grove needs. Both are registered in `scripts/lint-styling.ts`'s `PORTED_FILES` with their upstream, and the gate prints them on every run so the list cannot grow unnoticed. **It is a hand-maintained list, never a marker comment** — a `// @ported` anyone can type becomes a way to silence the linter.

- `components/grove/workspace/thread.tsx` ← assistant-ui's `base.tsx`. The registry `Thread` sets `--thread-max-width` *inline* and hard-codes `UserMessage`; its `components` prop reaches neither. Four labelled deltas: width as an input, a `footer` slot inside `ViewportFooter`, a clamping user bubble, and `turnAnchor="bottom"`.
- `components/grove/usage/activity-heatmap.tsx` ← `components/assistant-ui/heat-graph.tsx`. Its only prop is `data` and it closes over a blue `COLORS` const, but the `HeatGraphPrimitive.Root` underneath takes exactly the `colorScale` needed. Two deltas: the `--heat-*` ramp, and semantic tokens for two raw palette classes.

Start a port by **copying the vendored file**, not by rewriting from the upstream example — the vendored copy already compiles against what we actually have. Give it a header naming every delta, and keep it diffable line for line.

**The transcript's measure and its margin are one decision, on one element.** `thread-width.ts` owns both: `THREAD_WIDTH` (the cap) and `THREAD_INSET` (the gutter). They land on the same column div — the one that wraps *both* the message stream and `ViewportFooter` — so the stream, the plan card and the composer share one edge by construction rather than by three matching literals. If you need to move that edge, it is one constant, and a probe injected into the footer proves the plan card inherits it.

Two traps here, both paid for. **The cap alone is not a margin**: `min(100%, 78rem)` stops binding somewhere around a 1200px pane, and past that the column just fills, ending flush against the panel's rounded border — which is what "too wide" turned out to mean, not a measure that was too long. And **the inset must scale on `@` container variants, never on `md:`**: a split pane is roughly half the viewport, so a viewport breakpoint hands the *widest* margin to the *narrowest* column. The thread root already declares `@container`, so `@3xl`/`@6xl` read the thread's own width — verified at real layouts as 512px→16px (split), 932px→40px, 1172px→80px.

**Padding goes on the inner column, never on the scroll container.** The viewport is the scroller and stays flush to the panel, so its scrollbar sits inside the panel's radius; padding it instead would push the scrollbar off the panel edge and inset it into open space.

**`turnAnchor` is measured, not preferred.** Upstream anchors a turn's top because it streams one message into a conversation you read forward. Grove loads a complete transcript whose last turn is a whole agent run, so `top` opened ~330px above the tail with the scroll arrow already showing; `bottom` opens at 0. The arrow itself is untouched.

## The public share view: one component tree, two audiences

`/public/<token>` renders a workspace read-only for somebody outside the auth boundary. It reuses the workspace surface's components verbatim — no fork, no `readOnly` prop threaded through five files — and the two mechanisms that make that possible are worth copying rather than re-deriving.

- **NARROW A COMPONENT'S PROPS TO WHAT IT READS, and structural typing does the rest.** `InfoTab` and `ChangesTab` take `WorkspaceRead`/`ActivityRead` (`Pick`s in `workspace/selectors.ts`) rather than `WorkspacePeekView`, so the deliberately smaller public payload — which carries no `repo_root`, `worktree_path`, `tmux_session` or `container` — satisfies them without either side knowing the other exists. `adapters/branch.ts::baseBranchOf` had taken a `Pick` all along; this is that move applied to whole surfaces. **The narrowing is the mechanism, not a tidy-up:** widening one of those types back to the full view silently re-admits every host path into the one payload built to exclude them.
- **BUNDLE A CAPABILITY WITH ITS DATA IN ONE OPTIONAL PROP, AND DERIVE REACH FROM IT.** `WorkPanel` takes `privileged?: { peek, onKilled }` and works out its own tab set from whether it got one; `InfoTab` takes `lifecycle?: { state, onKilled }` and renders the Lifecycle card only then. **Do not add a `tabs` array or a `readOnly` boolean beside a handler** — those are two facts that can disagree, and the disagreement is silent. Bundled, a caller cannot ask for the Terminal tab without supplying the pane, cannot ask for lifecycle verbs without a record carrying `branch_provenance` (which the narrowed identity deliberately lacks), and the compiler enforces it.
- **Withholding a tab is CHROME, never the security boundary.** The public surface is safe because the daemon serves it three read-only routes under a namespace with no bearer — if the only thing stopping a reader were a missing `TabsTrigger`, the feature would be broken. Say this at the prop, because the next reader will assume the opposite.
- **`repoRoot: null` means "do not resolve tickets".** Resolving spends the host's own tracker credential, so doing it for an anonymous reader would let an unauthenticated request drive an authenticated outbound call. A disabled react-query is `isPending` FOREVER (see `useTickets`' own note), so the "resolving" state must be gated on the null too — otherwise every row spins for the life of a page that is never going to resolve anything.
- **NO SSE, and the poll is the freshness mechanism rather than a backstop.** `/events` is a host-wide fan-out and can never be exposed, so `backstopInterval` — which gates on that stream — would leave every shared page permanently stale. The transcript reuses `mergeTurns`/`turnCursor` unchanged, because the daemon's public turns route serves the identical cursor contract.
- **A single-bracket catch-all matches ONE OR MORE segments, so `[...path]` silently 404s the zero-segment URL.** The public BFF's overview is `/api/public/<token>` with no suffix, which needs `[[...path]]`. Nothing catches this: `tsc` is happy, the handler simply never runs, and the failure looks like a daemon problem. Same family as the middleware-matcher trap above — **route reachability is only ever proven against the built artifact.**
- **The unauthenticated BFF is its own route file, never an exemption in `app/api/grove/[...path]`** — the argument is written out in `app/api/version/route.ts` and it holds here too: that catch-all has one invariant (nothing reaches the daemon without a session) and punching a path-matched hole in it makes every future edit there a security review. This route can only ever assemble `/public/*`, attaches no `Authorization` header at all, exports only `GET`, and allowlists the subpath structurally.

## Printing an app shell, and the two traps under it

A screen built as a fixed-height, clipped, multi-scroller layout does not print. Both defects below were found by MEASURING the rendered page under `emulateMedia({media:'print'})`, and neither was visible by reading the CSS.

- **RELEASING `overflow` IS NOT ENOUGH — the ANCESTORS' HEIGHTS ARE WHAT CLIP.** The first print stylesheet unset `overflow` on the scrollers and looked complete; measured, `main` was still **900px** while the transcript inside it was **19,506px**, so everything past the first fold was thrown away and the PDF came out effectively blank. The fix releases `height`/`max-height`/`min-height` on the structural containers too (`main, section, article, nav, aside, div, ol, ul`) — **scoped to layout elements rather than `*`**, because `* { height: auto }` collapses any icon or avatar sized by a height utility. `display` is deliberately left alone, so cards keep their grid and badge rows keep their flex: the goal is to un-clip the page, not to re-lay it out. Measured after: 1,289px → **22,027px**, 2 clipped pages → 20 real ones.
- **`ResizablePanel` DROPS the `className` it is handed** (it owns its element's display and overflow outright), so a print-ordering class on a panel is silently inert — it reads correctly in review and does nothing. The panel GROUP forwards `className`, so print ordering is `column-reverse` on the group rather than an `order` on a child. Check which half of a vendored pair accepts styling before relying on it.
- **A print stylesheet cannot be iterated through the production build** — each change is a rebuild, and a rebuild under a live `next start` 500s every route. Inject the candidate with Playwright's `addStyleTag` against the DEPLOYED page, measure, and write the file once it is right.

## `overflow-hidden` does not clip an absolutely-positioned descendant

**A clipping ancestor only clips `position:absolute` children when it is ALSO their containing block — i.e. when it is itself positioned.** The public share page's root was `flex h-dvh w-full overflow-hidden` with no `relative`, so the transcript's own absolute overlays (assistant-ui's tool shimmers, Grove's `scroll-edge-*` fades) resolved against the initial containing block, escaped the clip at y≈27,000, and stretched the document 218px past the viewport — the whole page scrolled behind a shell that was supposed to contain it.

Two things make this expensive to diagnose from the code:

- **It presents as intermittent.** Those overlays exist only while a tool is running or a pane is actually scrollable, so an idle workspace looks perfectly fine and the report reads as flaky.
- **The obvious suspects are innocent.** Every element a naive "who overflows?" probe reports is a legitimate child of a legitimate scroller. The offenders are only findable by filtering for `position: absolute|fixed` whose rect escapes the viewport.

`AppShell` has carried `relative` from the start for exactly this reason — its docstring frames it as "the nearest `relative` ancestor", which reads as being about the rail rather than about clipping. **Any surface that rolls its own full-viewport shell instead of composing `AppShell` must carry `relative` on the clipping root**, and the reason belongs in a comment there, because nothing about `overflow-hidden` suggests it.

## Two things deliberately NOT ported from the old dashboard

The predecessor bespoke-component dashboard this app replaced (deleted, its history folded into this one) got a few things wrong. Do not reintroduce them:

- **Viewport arithmetic.** It subtracted a hard-coded header height (`3.25rem`) in two places that had to be kept in sync by hand. The shell here is one fixed-height flex row and every page is a `min-h-0 flex-1` child, so the constant does not exist. Do not reintroduce it.
- **The header portal.** It threaded a page title upward through a ref plus `createPortal`. Here a page passes `title` to `ShellHeader` directly.

## Decisions carried over from the old dashboard that are still true

- **`useExternalStoreRuntime` is the correct runtime**, because the daemon hands us complete message lists over REST + SSE rather than streaming tokens. This is also why the `with-external-store` example is the right scaffold.
- **The live pending-question group is a sibling of the message stream, never a message.** As a message it remounts every time the transcript grows.
- **One `EventSource` per APP, in `GroveStreamProvider`.** It was "one per route", which is not a property a per-route hook can enforce: the shell's rail and the workspace page each opened one to the same `/events`. SSE invalidation, not the react-query interval, is the freshness mechanism; the interval is a backstop, and `backstopInterval(connected, ms)` is what makes that sentence true rather than aspirational — every `refetchInterval` was running at full rate beside a healthy stream. **A hook that leaves its interval ungated must say why at the call site**, because gating a surface no event covers makes it silently stale, which is worse than the poll.
- **An `EventSource` does NOT reliably heal itself, and gating on it makes that load-bearing.** The spec auto-retries a dropped connection but fails the source PERMANENTLY on a non-200 — which is what the BFF returns for the whole time the daemon is down. Measured: stop and start the daemon and the stream never comes back. Harmless while everything polled anyway; with the stream driving freshness it strands the app on its backstop forever, so the provider schedules its own backoff reconnect.
- **Read-only is a runtime capability, not a hidden control.** A historical session mounts no composer.
- **Unmeasured usage is `unknown`, never zero.** Grove's usage data has real gaps and a fabricated zero misleads a user about their own spend.

## State management: the stack is already here, and it is not Redux

**There is no global client store and there must not be one.** Server state is TanStack Query and that is the whole answer — `staleTime` decides remount refetches, every interval is gated on the SSE stream by `backstopInterval`, and the transcript read is cursor-aware. A second store holding the same rows is the precise anti-pattern Query exists to remove, and the fleet snapshot already rides the query cache via context, so there is nothing left for one to own.

**What was actually missing was never a library — it was that EPHEMERAL UI state did not survive a remount.** Which pane you were on, which work tab, the split ratio, your scroll position: lose those on a navigation and the app reads as discontinuous no matter how fast the data is. So the rule is a split, not a stack: **server state in Query, UI state in component state, and anything a user would be annoyed to lose gets persisted per workspace.** The split ratio already did this through `react-resizable-panels`' own `LayoutStorage`; `view` and `workTab` now do too. Two hazards bind any addition: the server has no `localStorage` (reading it during render crashes the route), and restoring on the first client render is a hydration mismatch — so the restore lands after mount and goes **through** `visiblePane`, never around it, or a `split` persisted on a wide window leaves no tab selected on a narrow one.

## The transcript is a WINDOW, and three things follow from that

The first read asks for a tail (`INITIAL_TURN_WINDOW`, 40 turns) instead of the whole session; every read after it follows the daemon's `after_turn` cursor. The daemon already served both — see [daemon](../src/grove/daemon/CLAUDE.md) on the three bounding instruments.

- **A CURSOR OVER A WINDOW MUST BE ABSOLUTE.** `turnCursor` returned `held.length - 1`, which is the same number as `first_turn_index + length - 1` only while the client holds the session from turn zero — the only shape that existed before windowing. Windowed, it is a position *inside the window*, and asking for it would splice a hole between turn 0 and wherever the window began. `mergeTurns` places a window against a held range starting anywhere, and refuses (`refetch`) anything it cannot prove contiguous. The two window shapes take different branches on purpose: an **incremental** answer to a cursor is provably contiguous and splices, keeping the held prefix by reference; a **non-incremental** one (a `last` fetch, or a widen) carries no such promise and is taken wholesale — which is also why widening needs no special case, since its response already contains both the new prefix and the tail.
- **ANYTHING DERIVED BY SCANNING "the loaded turns" IS NOW UNSOUND.** The plan card was: `latestTodoFromTurns` walked the turns for the newest board, and a plan written 200 turns back simply is not in the data any more, so the card would blank on open and return only on the next write. It reads `GET /workspaces/{id}/todo` instead. **Before deriving anything from `turns`, ask whether the answer can live outside the window** — and if it can, the daemon almost certainly has a route for it already.
- **DO NOT AUTO-LOAD ON SCROLL-TO-TOP.** Widening is a button. Delta 10 in `thread.tsx` is measured evidence that this transcript corrupts its own scroll position when content resizes *above* the viewport; prepending on a scroll event re-creates that trigger at the one moment a reader would most notice the jump.
- **WIDENING RE-DOWNLOADS; IT DOES NOT PAGE BACKWARDS, AND THAT IS A KNOWN LIMIT.** "Load earlier" doubles `last` and refetches the whole window (measured: `last=40` 3.71 MB → `last=80` 4.61 MB, both `incremental: false`), so paging back N times costs an ever-larger blob rather than just the new slice. It is deliberate — a `last` response never claims contiguity with anything, and a "does this fully contain what I hold" splice check cannot distinguish a genuine widen from the daemon's own reset/fork case, which arrives identically and can coincidentally overlap old indices. Guessing there re-admits the silent-hole failure `mergeTurns` exists to prevent. **Fixing it properly needs a `before_turn` cursor on the wire, not cleverness in the client.**

## A pane is hidden, not destroyed

`{paneView === "work" ? workPanel : transcript}` tore down and later rebuilt the whole transcript on every Transcript/Work click — tens of thousands of nodes, which is what "choppy" actually was. Both panes now stay mounted once visited, behind React 19.2's `<Activity mode="hidden">`, **mounted lazily** so a fresh load still pays for exactly one.

The trade is real and worth restating before anyone "optimises" it: a hidden pane's nodes stay in the layout tree. That is only affordable because the transcript is windowed to its tail — the two changes are load-bearing for each other, and reverting the windowing alone would make this expensive. `display: none` does the accessibility work by itself (out of the a11y tree and the tab order), so no `aria-hidden`/`inert` is added on top. **Crossing into or out of `split` still remounts both panes**, because that branch changes the parent chain; it is left alone deliberately rather than fought.

## Navigation paints before it fetches

**Every navigable segment needs a `loading.tsx`.** There were none, so Next held the *previous* page on screen for the whole server round trip and every navigation read as a freeze — worst on `/w/[id]`, whose `generateMetadata` deliberately awaits `cookies()` plus a `no-store` daemon fetch to keep the tab title off the workspace id. That fetch is correct and stays; the fix is that the wait becomes visible instead of invisible. A `loading.tsx` renders the page's **real chrome** (the same `ShellHeader` and skeleton the page itself uses while pending), never a bare spinner, so nothing jumps when content lands.

**`app/manifest.ts` makes Grove installable, and there is deliberately NO service worker.** A cached shell serving stale workspace status is a correctness bug, not an offline nicety. `display: standalone` is the whole ask. The manifest's `theme_color` is a single value because the Web Manifest spec has no light/dark variant; the `<meta>` tag in `layout.tsx`'s `viewport` export is where that pair can actually be expressed.

## What the daemon actually reports (learned the hard way)

- **A quota window reports `used_percent`, and usually `used` / `limit` / `unit` are all `null`.** Gating a meter on the absolute triple made every window on a real host fall into "Not measured" — six live windows across two accounts rendering as nothing. Percentage is the primary representation; absolutes are the fallback. Say "not measured" only when *both* are absent.
- **`summary.tokens` being null does NOT mean there is no token data.** `/usage/activity` and `/usage/breakdown` carry real per-day and per-model tokens on the same host where the summary aggregate is empty. Scope an unmeasured note to the field that is actually unmeasured, or the page tells the user they have no data while holding 35.9B tokens of it.
- **`/usage/findings` returns thousands of rows** (2475 here). Every long list is a bounded, internally-scrolling container, and the bound is *stated* — "Showing 100 of 2.5K" — because a silent cap reads as "this is everything".
- **A session reports THREE durations and they are not interchangeable — never render one as "the" duration.** `active_ms` is a wall clock (the active intervals merged, concurrency once), `execution_ms` is labour (the same intervals summed across every sub-agent thread, routinely 2x+ larger), `elapsed_span_ms` is birth-to-last-event. The reducer semantics and the `active_ms <= elapsed_span_ms <= …` invariant are owned by [core/usage](../src/grove/core/usage/CLAUDE.md); what the browser owes is two columns, labelled, never a single number.
- **A quota-derived ceiling is routinely PARTIAL and that is the ordinary case, not an error.** An account at 0% used can never yield a token estimate — there is nothing to extrapolate from zero — so demanding that every account contribute drew no cap line at all on a real host. Sum what you can, state the coverage, and label the mark itself `partial cap`. The window duration to normalize against is the projection's own *resolved* span, not `QuotaWindowView.window_seconds`, which is null on exactly the provider that needs it (Claude publishes no duration; the operator declares it).
- **A question's ANSWERABILITY is narrower than its wire shape, and the narrowing lives in the keystroke grammar, not in the contract.** `QuestionAnswerItem` accepts indexes XOR text for any question, so the wire looks uniform — but on a pane runtime the answer is *typed into tmux*, and `ClaudeCodeAdapter._answer_ops` accepts free text on **`single_select` only** (the picker's synthetic "Type something." option, which exists nowhere else) and rejects `confirm` and optionless `free_text` outright. So the UI must gate the text box on the kind (`acceptsCustomText`) or it renders an affordance that 422s. **Reading the contract alone tells you the opposite of the truth here** — the constraint is in the adapter, one layer down. A *paneless* runtime skips that grammar entirely and can answer anything as plain text, which is why the rejection cannot be moved into the wire model.
- **A ticket's phase claim joins client-side by `provider:id`, needing no new fetch.** `ticketPhaseKey`/`ticketPhases` key `PhaseView.tickets` the same way `TicketRef` is already deduplicated, so the Info tab's ticket list and phase report — already both on the page — join with a pure `Map` lookup and no request, hook, or loading state of their own.
- **A ticket with no claim renders NO phase mark at all, never `scoping`.** Absence of a report is not step zero; inventing one would claim progress on work nobody said anything about. This is the same "None means not reported" rule the daemon's `phase.py` states for the workspace's own claim, applied per ticket.
- **`ticketRollup` scores an unclaimed ticket as zero and reports it via `unreported`, never excludes it.** Excluding it would let a workspace holding five untouched tickets and one finished one read 100% complete — the direction that misleads. Zero can only ever understate, which is the honest failure mode for a partial read.
- **Client-side validation of an answer's text is not belt-and-braces, it is the error message.** The daemon rejects any control byte because a stray CR/ESC desyncs the keystroke driver; a user cannot type one into a single-line input, but **pasting a wrapped line** is ordinary and turns into an opaque 422. `textError` mirrors the daemon's rule so the refusal lands beside the field. Write that character class as `\uXXXX` escapes and test it with `String.fromCharCode` — a literal control byte in source is invisible, which is the entire bug class.

## Terminal colour

ANSI reaches the browser intact; it is the rendering that drops it. `fancy-ansi` HTML-escapes the text and injects `<span style>` colour runs. Reuse it rather than reaching for `xterm` or hand-rolling a parser.

The Nerd Font is attached to the terminal subtree only, via `JetBrainsMonoNerd.variable` plus the `font-terminal` utility. Never put it on `<html>`: it is ~1 MB of icon glyphs that app chrome has no use for, and agent TUIs are the only thing that needs its powerline and private-use ranges.

## Lists are flat, not grouped

Both the rail and the fleet dashboard list workspaces **flat, newest first**, with project as a *filter dimension* and a line on the card — never a section header. Grouping by project spent most of a viewport on headings reading "0 — No workspaces yet", because most projects hold no workspace at any given moment. One filter vocabulary (`components/grove/fleet/filter.ts`) serves both surfaces; do not grow a second.

## Naming a thing, and dating it

Three shared atoms carry these, and none of them takes an `icon` or a format prop — a caller that can choose is a caller that can disagree with every other caller.

- **`entity.tsx`** — `ProjectLabel` / `BranchLabel` / `LocationLabel`. A middot between two untyped tokens was punctuation standing in for type information the line never carried; a leading glyph types it and makes the separator unnecessary. **The glyph is sized in `em`, not pixels**: these sit on a `text-sm` card and a `text-xs` rail, and "reads the same everywhere" has to mean the same *proportion* — a fixed 14px mark that suits the card is a smudge on the rail, and two of them on one 12px line is the noise the vocabulary exists to prevent.
- **`relative-time.tsx`** — `RelativeTime`/`relativeTime` for a surface that is SCANNED, `PreciseAge`/`preciseAge` (two units, up to years) for one opened deliberately to READ, `Uptime` for a duration still accruing. Three functions rather than a `precision` prop, per the design system's §3 rule. All are mount-gated because the clock is the one input a server and a browser never agree on. **The consequence is a layout trap: before mount they render the ABSOLUTE time**, so a narrow column must cap and truncate it (the rail uses `max-w-16 truncate`) or an unbounded `8/10/2026, 3:12:07 PM` crushes the row's title on every first paint.
- **`brand-mark.tsx`** — the fill is hard-coded and must stay that way. It replaced an `Avatar`-cropped lucide glyph: a placeholder in `currentColor` has no silhouette and needed a disc to look deliberate, but a real mark has one, and that disc was `bg-sidebar-primary` — near-black — which would have put brand terracotta on a dark circle. Pass `label` only where the mark stands alone; in the rail the enclosing link is already `aria-label="Grove"` in both collapsed and expanded states, so a label there would be a second name on an element whose name is fixed.

**A total state union needs one presentation table.** The rule — sentence case, word and mark together, and a glossary entry only where the plain word misleads — is documented on `fleet/tokens.ts`; the rail, filter, dashboard, palette and badges ask that table rather than naming a state or choosing a glyph locally. The table can intentionally collapse wire distinctions that do not matter to a reader (`active` and `running` both say **Active**) while the structural type makes a new real state impossible to forget. Runtime belongs beside those tables because it has the same one-word/one-mark invariant, but its fixed-property `outline` treatment remains distinct from state tone.

**The phase tooltip is a cross-surface composition, deliberately.** `workspace/selectors.ts` owns `phaseTooltip` and its structural input/output shapes; the workspace ticket row and fleet phase badge consume its separate claims. A fleet import from `workspace/` is correct here: duplicating a provider-attributed tracker sentence or phase meaning is exactly how the two surfaces begin teaching different vocabulary. The todo aggregate has its own tooltip because it is a magnitude, not a phase claim; do not force it through this seam.

**A list that SORTS by a value must DISPLAY that same value.** The rail orders rows by `lastActivityIso` — the newest of the sessions' `last_event_at`, `updated_at` and `created_at` — so it shows that, never `state.updated_at` alone. Showing the bare field would print ages that contradict the order they are printed in, which reads as a broken clock rather than as the deliberate fallback chain it is. `lastActivityAt` now derives from `lastActivityIso` so the two cannot drift.

**A destination's PLACEMENT is chrome; its SECTION is the route.** `NAV_ITEMS` stays the one census and every entry carries `placement`, so the rail and the account menu each render their own subset while `sectionFor` still reads the whole list. Deleting an entry to move it is the trap: Sessions leaving the rail that way would drop `/sessions/…` through to `Fleet`, and every session page would title itself with a section it is not in. One name lives in that list too — the menu item and the page header both read `label`, so a destination cannot end up called two things.

**Never say "up to date" when the check did not run.** `WhoamiView.latest_version` is null on offline / first call / error, and `update_available` is false in exactly that case too — so the false branch covers two different worlds. "No update information" is the honest third claim, and it is the one a user relies on when deciding not to upgrade. The release check is daemon-side and cached for hours, so the browser must never poll GitHub itself.

**Uptime is `started_at`, not `uptime_seconds`.** A count of seconds is correct only at the instant it was fetched and then decays silently; an instant is true forever, so `RelativeTime` re-renders it from the browser's own clock and the rail stays honest with no `refetchInterval` at all.

**One age in the rail, both in the Info tab.** Recency is the question a rail answers and it is also its sort key, so a right-aligned age column exposes the ordering instead of competing with it. Creation time is real but never the reason you scan a rail: it lives in the row's `title` beside the exact update time. Two equal-weight timestamps would have cost a third line on every row, on the narrowest surface in the app.

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
