CLAUDE.md@webapp · diff
git:20260908.bbb0fcb to git:20260917.fdcbb8c
10 added, 0 removed. Audit A to A.
# 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.
**NAMING THE npm DOES NOT CHOOSE THE NODE THAT RUNS IT, and that is the whole trap.** Every `npm` is a three-line `#!/usr/bin/env node` shim, so a Node 22 npm invoked from a shell whose PATH leads with Node 20 *executes under Node 20* — `npm -v` reports the new npm while `process.versions.node` is the old runtime. `make webapp-build` inherited this and built the app on Node 20 for as long as the host's nvm default was 20: 17 `EBADENGINE` warnings, **a build that succeeded anyway**, and a `.next` then served by `grove-webapp.service`, which pins Node 22 in its own `ExecStart`. Build and runtime silently disagreed while every step reported success. The Makefile's `WEBAPP_NPM` now prepends the chosen npm's own bin dir to `PATH` so the shim resolves its sibling `node`; `WEBAPP_NODE_BIN_DIR` had been computed for years and used only by the precheck and the unit template, never by the recipes that actually build.
**The diagnostic that settles it is `process.versions.node`, never `node -v`** — the latter reads the ambient shell and will happily confirm the version you hoped for while npm runs under another.
Two guards now make the requirement real rather than tribal: `engines.node: ">=22"` in `package.json`, and `engine-strict=true` in `webapp/.npmrc` — because `engines` alone *warns*, and a warning is what let the wrong-Node build ship. Mutation-tested: with only Node 20 on PATH `npm ci` now exits `EBADENGINE` instead of printing a wall and continuing. **A version requirement that only warns is not a requirement**, and the wall of warnings is worse than useless — it trains you to scroll past the one line that matters.
The remaining `ERESOLVE` was a *different* cause wearing the same wall: `@lobehub/ui` peer-requires React `^16.8 || ^17 || ^18` against this app's React 19. An `overrides` entry pinning it to `$react` silences it and is verified by the lockfile holding **exactly one** `node_modules/react` — the check that matters, since a second copy is the null-hooks-dispatcher failure documented above.
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.
## What actually costs the transcript its frames (measured 2026-09-15)
Profiled against the DEPLOYED app with a 372-entry transcript, isolating one mechanism per run. **The reported symptom was "scrolling and actions feel slow, and the loaders freeze"; none of the four causes was the one the symptom pointed at.**
- **The shimmer is the single most expensive idle animation, by a wide margin.** `tw-shimmer` animates `background-position` across a nine-stop `color-mix(in oklch)` gradient CLIPPED TO TEXT, so every frame repaints the glyphs. Disabling it removed **653 ms of main-thread task time per 6 s of idle** and took slow frames 2 → 0 — against **753 ms for hiding the entire transcript**. It is ~87% of what the transcript costs while nobody is touching it.
- **A crossfade between a string and itself still pays for both layers.** `SwapLabel` animates BETWEEN two labels; the timeline summary passed the same text to both slots, so 78 groups each carried a hidden duplicate under a permanent `blur(2px)`. **A filter on an invisible layer is not free, and nothing about the rendered page shows you the second copy** — the census is `document.querySelectorAll('[class*="blur-"]')`, which read 128 inside the viewport.
- **A shimmer over an EMPTY label repaints nothing, forever.** The vendored `GenerationLoader` renders its label element unconditionally and `WorkingMark` deliberately passes `""`, which left three shimmers animating on measured 0×0 elements. The vendored component exposes no prop for this, so the fix is `:empty` at the theme boundary — it names the condition rather than a caller.
- **THE FROZEN LOADER IS NOT A LOADER BUG.** The clock is healthy in isolation: probed directly, it animates, suspends on `visibilitychange`, and resumes on return (37 → 1 → 40 distinct frames). It is a `setInterval` driving React state, so **every long task freezes it**, and the component whose entire job is to say "something is happening" is the most visible thing that looks broken. Measured a 1,160 ms task on load. The cells now carry a compositor-driven `opacity` breathe on top of whatever position the clock last set, so the mark survives a blocked main thread; the clock still owns the pattern.
**Icon artwork is bundled, not fetched.** `AppIcon`'s Iconify loader batches and dedupes, but its cache is module memory — measured, three cross-origin requests to `api.iconify.design` on every load, repeated identically after a reload. `scripts/build-icon-bundle.ts` freezes the catalog's 31 icons (~16 KB) into a committed JSON the lazy chunk registers with `addCollection` before the first `Icon` renders: **67 tool icons, 0 requests, 0 fallbacks.** An uncatalogued slug — a user's own MCP mapping — still resolves through the API, so this narrows WHICH icons need the network rather than removing the capability. `icon-bundle:check` is deliberately NOT in the gate: it needs the network, and a third-party outage must not fail CI.
**A/B on the same routed payload, median of 3:** idle task 2,615 → 1,177 ms, long tasks 2 → 0, blurred layers 128 → 2, icon requests 3 → 1 (the remaining one is the agent brand mark, which is not a tool icon). **Do not quote the load-time number from that table** — the two sides were different server processes, one of them competing with the live daemon, so it is not a controlled comparison.
## 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".
**A nominal type floor does not survive a root-density change — and an inline px floor is the WRONG repair.** The 80% root turned the work tabs' vendored `text-xs` into 9.6px while the guide still said 12px, so a source assertion for the right class passed the wrong geometry. The first fix was `style={{ fontSize: "max(12px, …)" }}` at each composition seam, and measured on the built app it inverted the problem: the shell title, both tab strips, the terminal metadata and the diagram toolbar sat at 12px while the content they FRAME rendered 9.6–11.2px. **The chrome became the loudest text on the page, and a px value opts that surface out of the density lever, the reader's font size and zoom all at once.** Ask the vendor for a different ramp STEP instead — and where a variant welds type to height (assistant-ui's `TabsList` `size` is `h-8` *plus* a `group-data-[size=sm]` selector forcing `text-xs`), pick the size whose type is right and keep the explicit height the caller was already passing.
**Measure with the real vendor group selectors active.** Tailwind's `@theme inline` can emit the literal rem into those classes, so a descendant `--text-xs` override is inert; the trigger's own font-size is the effective seam, and scoping an override to the whole panel would resize every card beneath it.
**`size-6` IS NOT A 24px TARGET — it is 19.2px here**, so a compact control's hit area needs an explicit `min-h-[24px]`/`min-w-[24px]`. That pair is the one legitimate px floor in this file's story: it is a bound on a POINTER, whose physical size does not change when type gets denser. Text size and hit area are separate decisions, and conflating them is what makes a quiet mark quietly unclickable.
## 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.
- **`registry:refresh` is BROKEN and its refusal is correct, so adding one vendored component means appending one lockfile entry by hand.** Upstream moved the elements to `components/assistant-ui/elements/…` while this tree keeps them at `components/elements/…` (the install-path rule two sections up), so the refresh's coverage assertion finds ~40 files it cannot map and — rightly — refuses to rewrite a baseline from an incomplete read rather than silently dropping every entry it failed to resolve. Adding an entry by hand is `sha256` of the file on disk plus the registry `item` name; `registry:check` then verifies it against the real registry on the next run, which is what keeps the hand-written entry honest. **Do not "fix" this by relaxing the refusal** — a baseline written from a partial read is exactly the drift the gate exists to catch.
- **A vendored component's own install path is declared by its registry manifest, and this tree overrides it.** `elements-agent-handoff` declares `components/assistant-ui/elements/agent-handoff.tsx` and imports `./surfaces`; here that sibling lives at `components/elements/surfaces.tsx`, so the file goes beside it and the relative import resolves unchanged. Check where the siblings already are before trusting the manifest's path.
- **`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. It now strips a `variant="…"` literal for the same reason: **a vendored variant named after the look it produces is composition, and the rules match text rather than class attributes**, so `GenerationLoader`'s `variant="rounded"` read as a radius utility. The exemption is one prop name and a double-quoted literal only — `className` stays fully scanned, or the hole becomes the way around the linter.
- **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`/`CardRegion`/`CardCell`/`CardFields` are the vocabulary around them. **`CardRegion` and `CardCell` compose `CardShell` rather than drawing their own border, and that is the only way a nested boundary can exist here** — `lint:styling` forbids a radius utility under `components/grove/`, so a hand-rolled bordered box either has square corners inside a rounded card or fails the gate. 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.
**`CardScroll` bounds a LIST, and a tool response is not one.** `ToolBody` (`workspace/tool-call-part.tsx`) is the one body in the tree with no height bound at all, and the discriminator is how it is read: a list is scanned, so bounding it costs the reader nothing and the tenth row is worth no more than the first; a tool response is one artifact read end to end — the tail of a build log, a diff, a test summary — and it only renders inside a disclosure that is closed by default, so the reader has already asked for it. A 16rem porthole onto a 20 KB body, nested inside the transcript's own scroller, hid content exactly the way the daemon's since-removed character cap did, and added a scroll trap on top. **Ask what the reader is DOING with a surface before bounding it, not how tall it might get.**
**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.
**Self-framing vendored elements belong standalone, not inside Grove cards.** `TodoList` and `Timeline` supply their own padding and surface; nesting them duplicates the card's frame and heading. Inside a Grove card, compose divided rows instead. A chat greeting is likewise not a work-tab empty state; use the admonition contract in the design system.
**A diff disclosure owns one header and one frame.** The transcript composes `DiffViewerContent` and native `DiffViewerSplitLine` beneath `CardDisclosure`, with `computeDiff`'s paired lines serving counts and body. Keep the body lazy and its horizontal scroll region focusable. The body is the file's content, not another invocation, so it takes no nesting guide.
**Markdown fences share the code-text ramp.** The vendor’s `.aui-md-pre` carries a literal 13px while tool output and diffs use `text-xs`. Normalize its font size and line height at the theme boundary, including mailbox and plan Markdown; verify computed values at desktop and mobile widths rather than matching class names.
**Pixel-literal type ignores the density root.** A vendored `text-[13px]` stays 13px while rem-based neighbors shrink at the 80% root. Check actual units before adopting a component, then measure beside app text; arbitrary text utilities can also contain relative units, so the presence of brackets alone proves nothing.
**Govern vendored radii at the theme boundary.** The radius scale and slot block in `app/globals.css` own the roles, including narrowly named literal-class exceptions where upstream exposes no slot. Keep them out of call sites and vendored source. Unlayered rules outrank Tailwind's layered utilities; preserve `CardRegion`/`CardCell`'s `--radius-container` override instead of pinning every Card to the outer radius.
## 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` ↔ `RAIL_WIDTH`, 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.
**A sheet close control belongs in the brand band, not a full-height clearance column.** Compose `SheetClose` through the sidebar's header-action slot and disable the vendor's absolute close button. The list and footer then keep the same full-width gutter as the desktop rail. Touch headers must contain their 44px targets; shrinking the controls or clipping their overflow hides the mismatch rather than fixing it.
**Assign the shell's surfaces together.** Rail and gutter use `surface-sunken`, the clipping page panel uses `surface-base`, and cards use `surface-raised`. Promoting the panel to the card token erases the card/page distinction. The rail's right rule adds a boundary without changing those elevations.
- **The page panel is not a Card.** `shell-panel` supplies clipping and the page's corner, without a Card's padding or elevation. Its children own their scroll areas.
- **The page gutter is `px-2 pb-2 md:pl-0`.** Brand and page headers share `.workspace-header` and start at the viewport top. A top-only inset made centered controls look bottom-heavy against the full corner. At `md` the panel butts against the rail; the mobile sheet keeps the same top alignment.
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.
**Resize-sensitive tabs use the native `ui/tabs` line variant.** Its underline belongs to the trigger; `assistant-ui/tabs` instead caches offsets on selection changes and misses split dragging, font loading, and hidden-pane reveal. `AdaptiveTabsList` measures only label fit, never highlight geometry, including configured panel titles and touch targets. Keep Radix `TabsTrigger asChild` outside `TooltipTrigger`: reversing them overwrites the tab's `data-state` with the tooltip's open/closed state and erases selection styling. Border and label rules live in the design system.
The header carries the rail toggle, title, and page-supplied `actions`; the pane switcher rides inside that action slot rather than claiming another row. Measure that nesting separately from the stacked header, work tabs, and tab-specific sub-bar: treating the pane switcher as a third stacked band produces a false overlap finding. Band geometry and closing rules belong to [design-system.md](design-system.md).
**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).
## The pane tab is named for what it SHOWS
A native workspace's pane is Grove's worker printing protocol frames, so the tab reads **Stream** there and **Terminal** for the agent's own UI — `tabChrome(value, native)` in `work-panel.tsx`, keyed off the peek's persisted `state.native`. **The tab VALUE stays `terminal` in both modes**: it is the `TabsContent` key, the e2e census (`workspace-tabs.spec.ts`) and whatever a reader bookmarked, and a relabel that minted a second value would fork all three for a difference that is only a word. The sub-bar and empty state say "event stream (read-only)" so nobody reaches for `grove attach` expecting a prompt; the attach card's tooltip says the same. Pinned in `tests/unit/stream-tab-chrome.test.ts` as a source census, because the strip needs a query client, a peek and a live workspace to render.
## Terminal input and provider cancellation are separate operations
Native Interrupt is independent of assistant-ui `isRunning`, which this transcript runtime intentionally leaves false so follow-up Send stays enabled. Offer cancellation by native/live capability, not a possibly stale working-state observation. Live host-native Respawn uses explicit confirmation and preserves files/branch; native Pause/Resume are not supported. The Stream badge describes the pane feed, never provider health.
**The work panel's tabs split on READ versus ACT, and that is what decides where a control lives.** Info answers questions and holds no verbs; Controls holds workspace-level verbs (attach, send keys, share, lifecycle) above agent-session controls (commands, skills, MCP); model choice sits in the composer's own action toolbar. The tell that this drifted is a lone verb somewhere that otherwise only reports — Send Keys was a popover in the shell header beside the pane switcher, and Lifecycle was the last card on Info. A verb parked on a reading surface needs its own title and explanation, which is exactly the chrome a `SectionCard` header band gives free; when a popover exists mainly to supply that, the control wanted to be a card.
Send Keys names exactly one generated-schema enum member; the daemon owns terminal capability and target resolution. Workspace liveness (`active` / `idle`) is necessary, not proof of a pane: remote/paneless agents can be live without one. The Cancel turn preset retains `/interrupt` for those providers' native channels, while Escape is literal terminal input. Never turn a key delivery acknowledgement into a claim about the application's resulting state, and never add automatic mutation retries: a lost acknowledgement may follow a delivered key.
**A palette override does not remove a palette class.** AgentStatus hard-codes raw colors on its children, so a parent semantic override still fails a rendered-class census — the fix is composing a canonical `Badge` with semantic marks rather than overriding from above.
**AUDIT A VENDORED SURFACE BY WHAT IT RENDERS, NEVER BY THE TOKENS IT NAMES.** The terminal palette audit covered `fancy-ansi`'s 16 named ANSI colours, retuned them, measured them all clear of AA, and was wrong about the screen: the **256-colour greyscale** (SGR 232–255) was the most common ink in a real agent pane — 54 spans at **2.62:1** — because the vendor emits `var(--ansi-gray-N, rgb(…))` and nothing defined the variable, so the raw fallback won on 13 of 24 greys in light and 12 in dark. The named set was 100% compliant while most of the pane failed, and **nothing distinguishes that from a real pass except counting spans in the rendered DOM.** Same family as the assembled-surface rule in the root guide, applied to colour: the token table is a claim about names, and the page is a distribution over what actually got used.
Two seams come out of it, both worth knowing before touching terminal colour. A vendored `var(--x, fallback)` is **only** an override point once something defines `--x`; until then it is a hard-coded value wearing a variable's syntax, and it greps like the former. And `fancy-ansi`'s SGR 16–231 colour cube has no variable at all — it is a literal `rgb()`, unreachable from any stylesheet — so a light-mode terminal cannot be finished without porting the converter. Measured after the fix, one pane still carried cube green at 1.59:1 and literal white at 1.16:1. **Do not report a themed terminal as fixed on the strength of the token table.**
**The workspace header's own status pill is GONE, and where a claim renders is the reason.** It carried the agent's phase note through `useOverflowMotion`, which made a scrolling sentence the widest and most restless thing in a 32px band whose job is to stay still and be aimed at. The note was never lost — the same `PhaseView.note` renders on the Task card, which is the card that names its object (see the claim-ownership rule below). **A surface that must stay still cannot host a value that changes length**, and the motion machinery is not the thing to fix: `LoopingText`/`useOverflowMotion` still drive the rail, where looping is the point and `tests/e2e/sidebar-sessions.spec.ts` owns its coverage.
## A cue must not fire on a query resolving
The Task card's phase track flashes its current node when the agent reports a
new phase. The naive comparison — "did this value differ from last render" —
fired on **every page load** of a working workspace, because `phase` arrives
from a query: the first render is `null` and the second is the phase the agent
has been reporting for an hour, and `null → "verifying"` is the same shape as a
real transition. The card announced a change that had not happened, to every
reader, on every visit. So a null ADOPTS silently and only a
value-to-different-value step is an event.
This is the same trap as the diagram tab's "first value seen" guard, and it is
worth stating twice because the two look nothing alike at the call site: **an
unresolved query reads exactly like a fact**, and any guard whose input is
optional-chained through one is defeated by the loading state it cannot see.
Neither instance was visible in review; both were found on the built page.
**`agentIsWorking` in `workspace/selectors.ts` is a READ of the engine's answer,
never a second derivation of it.** `lib/grove/runtime/thread.ts` asks the same
question of the dashboard snapshot because that is the shape it holds; both read
`activity.state === "working"` and neither decides anything more. The engine
already promotes a session whose sidechain fleet is active to `working`, so
consulting `active_subagents` beside it re-opens the redundancy the working
loader's note exists to close. Absent activity is not working — a cue that
pulses on silence claims the opposite of what silence means.
## A plan is a document, so it cannot live in a card HEADER
`plan-approval.tsx` is a separate surface from `QuestionCard`, and the split is structural rather than cosmetic. That card renders its prompt as the `CardDisclosure` **summary** — i.e. inside a `<button>` — which is right for a one-sentence ask and impossible for an `ExitPlanMode` plan twice over: the plan is a multi-KB Markdown document, and **block-level Markdown (`<p>`, `<ul>`, `<pre>`) inside a `<button>` is invalid HTML**. Symptom before the split: a plan rendered as one run-on line, newlines collapsed to spaces, unbounded, in the thread footer — legible in the terminal pane beside it and illegible in Grove.
- **The Markdown seam is `AgentMessageBody`'s, reused verbatim**: `lazy` → `NewTabLinks` → `TextMessagePartProvider text={plan}` → `Suspense` (whose fallback is `whitespace-pre-wrap` plain text) → the unmodified vendored `MarkdownText`. It needs no `AssistantRuntimeProvider`. **There is exactly one Markdown renderer in the tree and it takes zero props** — always check the context providers before concluding a string is unrenderable.
- **`CardScroll` bounds the plan, which is the opposite call to `ToolBody`'s.** A plan is read *before* a decision, so the choices must stay reachable without scrolling past a document; a tool body is one artifact read end to end behind a disclosure the reader already opened. Same rule ("ask what the reader is DOING"), opposite answers.
- **The options are the agent dialog's real rows, and the click IS the answer.** No Confirm step, no text field, no Approve/Reject pair — approving a plan is a mode transition only that dialog can perform, and the wire carries an INDEX. The engine reasoning is in [agents](../src/grove/core/agents/CLAUDE.md); what matters here is that the old surface's "Approve" wrote the sentence `Yes, go ahead with this plan.` into a textarea, which the engine delivered as prose **after an Escape that the dialog records as a rejection**. A control that looks like a decision and delivers a suggestion is worse than no control.
- **Order is the contract; labels are not.** The answer is a position, so a surface that reordered or dropped a row would approve something the reader did not pick — pinned by a test asserting the rendered labels appear in wire order. The label text is Grove's own wording (the provider's drifted between two builds one version apart), so never assert on it as if it came from the agent.
- **`questionPresentation`'s `ApprovalCard` arm is NOT the home for this**, despite its name: that vendored element renders `command` in a `font-mono text-xs` block with no whitespace handling, capped `max-w-sm`, and has no slot for per-option consequences. Its output remains unrendered — its one live consumer is `pendingContentKey` as a memo key.
## A runtime-injected stylesheet is UNLAYERED, so a class against it is inert
Sonner injects its CSS with a `<style>` element at runtime. **An unlayered rule outranks every Tailwind utility regardless of specificity**, because Tailwind v4 puts its own output in `@layer`, and layered rules always lose to unlayered ones — so `toastOptions.classNames.title` is silently ignored, which is indistinguishable from a class that works. The seam that does reach it is `toastOptions.style`: inline style beats an unlayered rule, and sonner sets a font size only on the toast ROOT, letting the title, description and content inherit — so one declaration retypes the whole balloon.
Two general rules come out of it. **Check whether a vendored stylesheet is layered before styling against it** — `@layer` membership decides the outcome here, not the selector you write. And **give it a ramp TOKEN, never a px literal**: sonner's default is a flat 13px written for a 16px root, which is why a short delivery notice rendered larger than every surface behind it at this app's 80% root.
**Mount that host exactly ONCE, and pin the count as a cross-file census.** `app/layout.tsx` and `components/grove/providers.tsx` each rendered a `<Toaster>`; both were individually correct, so every toast rendered and was announced twice. Nothing caught it, because sonner draws no host at all until something is toasted — an idle page looks identical either way. The behavioural half (one real event, one visible toast) needs a browser; the source census is what stops a second mount returning.
## Four copies of a row is what licenses a shared atom
`SessionRegion`, `ControlLabel`, `ShareLabel` and `PolicyLabel` each spelled the same label-plus-help-trigger row, with the same `About <label>` accessible name, and each rendered the literal string `(i)` where the icon belongs. One `HelpLabel` replaces all four — and the bar is that there were four REAL callers already, not that a fifth might appear.
**Its `inherit` prop is not a styling knob; it names whose type the label is.** Two callers sit inside a `CardField`'s `<dt>`, which already declares the field name's size and tier, so re-stating them there would give one field label a rank its siblings lack. That nesting also forces every element in the atom to be **phrasing content** — the `<p>` the old copies used cannot legally sit inside the `<span>` that keeps the pair on one line.
**In a `CardFields` label column, truncation destroys the name and saves nothing**, because the column is auto-width: `Link expiry` shipped as `Link exp…` for one pass. Let a two-word label wrap and keep the trigger `shrink-0`, so the icon is never the thing that gives way — a mark squeezed below its target is unreadable *and* unclickable, where a wrapped label is merely two lines. This was found by looking at the built surface; no assertion saw it.
## Response links open in a new tab, and the seam is a REGION not an anchor
`NewTabLinks` wraps the assistant message's content element and the mailbox body, and gives every navigational anchor inside `target="_blank"` + `rel="noopener noreferrer"`. The reason it is a container: response content is not one renderer — Markdown, reasoning, tool cards, data parts — and the vendored `markdown-text.tsx` hard-codes `components={defaultComponents}` with no prop reaching its `a`. **Fixing the anchor per renderer would mean porting a vendored file for one attribute AND still missing every card**, which is the overfit; the requirement is about a region of the page, so the region owns it and a renderer nobody has written yet is covered for free.
Three properties are load-bearing. It sets ATTRIBUTES and never intercepts clicks — a real anchor is what preserves middle-click, ⌘-click, Copy link address, keyboard activation and a screen reader's link list, all of which `preventDefault` + `window.open` takes away while looking identical in a demo. It **allowlists** `http`/`https`/paths rather than denylisting `javascript:`, because a denylist is a list somebody has to keep complete; sanitization stays with the renderer above. And it observes the subtree, because response content streams and the anchors that matter mostly do not exist at mount.
**The accessible hint is attributes only (`title` when absent, plus `aria-description`).** The conventional answer — appending a visually hidden span — is unavailable here: these anchors are React-owned and the Markdown one re-renders on every streamed token, so an extra child is a reconciliation fault waiting for the next chunk.
## Mailbox messages are prose, not small tool logs
`AgentMessage` composes `CardDisclosure`/`CardShell` for incoming mailbox envelopes, task notices, and recognized outgoing send calls. Sender, recipient and subject come from protocol fields, never prose inference; missing identity stays explicit. Tool responses remain available and a completed call does not claim delivery. The body is lazy-mounted, full-length native Markdown at the reading size. **`TextMessagePartProvider` is the missing seam:** it gives the unmodified vendored `MarkdownText` a data-part string without another runtime or a syntax-highlighted Markdown surrogate. Always check context providers before declaring a string-only renderer unusable.
**EVERY envelope draws the vendored `elements-agent-handoff` — a peer delivery, an outgoing send and a harness notice alike — because they state the same shape of fact and a reader meets them within a few rows of each other.** The notice used to be a hand-composed row (mail glyph, two RAW ids either side of an arrow, subject beneath), which read as a different feature beside the handoff card and printed a 32-char task id whole. The kind now rides a testid (`agent-handoff-summary` / `agent-notice-summary`) and the shared `agentLabel` shortens both. **A second component is the wrong way to express a tier difference: the tier is the reason line and the recipient's tint, not a different anatomy.**
**A DELIVERY RIDES THE TOOL RUN; it does not flush it.** `entry.mailbox` used to call `flushToolRun()`, which closed the collapsible group, emitted a loose card and reopened a second group — so one continuous sequence rendered as group / card / group. The `todo` role already knew this. The envelope travels on the part's own `groveMailbox` key (never inside `artifact`, which is contracted as the wire's `ToolCallView`) and `ToolCallPart` reads it to draw the card in place, exactly as an OUTGOING send has always rendered inside a group. Pinned by a test that counts assistant messages, because the defect is a message COUNT and every individual card looked right.
**A real peer delivery draws it with `agent-handoff-summary`; a harness notice with `agent-notice-summary` — and the discriminator is the WIRE's `MailboxMessageView.kind`, never which fields happen to be filled.** A task notice carries a task id in `sender` and nothing in `recipient`, which is indistinguishable from a peer message whose recipient went unrecorded, so only the daemon can answer (engine side in [core](../src/grove/core/CLAUDE.md)). `handoff` is absent on an older daemon's payload and decodes as the conservative notice. An OUTGOING `SendMessage`/`grove_send_workspace_message` call sets it locally without consulting `kind`, because the tool NAME is already the protocol evidence `outgoingAgentMessage` matched on to get there. Ids are shortened by `agentLabel` to the same 10-char prefix every other surface prints — a 32-char hex id is the honest address and an unreadable pill — while an `id/agent` slot or a display name is left exactly as it arrived.
**A CONTRAST RATIO IS ONLY TRUE FOR THE BACKGROUND IT WAS MEASURED AGAINST, and measuring against the page when the element lands on a CARD is how a "fixed" pill ships unreadable.** The `settled` fix below was measured at 4.37:1 against the page's `oklch(0.155)` and reported as clear. The pill actually sits on `surface-raised` — `oklch(0.213)` in dark — so the real backdrop is lighter and the real ratio lower, and a reader reported the sender as unreadable with that fix already shipped. `handoff-agent` (in `globals.css`, scoped so no other `agent-handoff` is repainted) lifts the sender to the content-primary tier: measured on the card, **16.81:1** for the sender and 9.67:1 for the recipient. **Composite onto the surface the element actually lands on — `closest('[data-slot="card"]')`, not `document.body`.** It is on BOTH kinds: the vendored dim reads **4.33:1** there, under the 4.5:1 floor, so withholding it from notices would buy a tier distinction by making one sender unreadable. **A readability floor is never the thing that carries a hierarchy.**
**`settled` is passed FALSE, and it is a CONTRAST decision rather than a claim about delivery.** `true` reads as the obviously correct answer — a transcript records things that already happened — and it compounds the element's own `text-foreground/45` on the sender pill with a further `opacity-45`: **measured 1.78:1 against the app's real compiled theme, where the floor is 4.5:1**, so the pill naming who sent you the message was the one thing on the card a reader could not read. `false` leaves the single dim at 4.37:1 and keeps the vendor's intended hierarchy, the sender quieter than the recipient because attention belongs on whoever holds control now. The cost is stated rather than hidden: a Grove handoff never draws the in-transit treatment, which is honest, since Grove has no "delivery in progress" to show. **Two method notes worth reusing:** this was found by screenshotting the composed card, not by any assertion — and the first measurement was WRONG because the theme resolves to `oklab()`/`oklch()`, so parsing those components as RGB yields confident nonsense (it reported the readable pill as the worse one). Composite the colour onto the background with a canvas and read the pixel back; let the browser do the colour maths. The literal is pinned as a source census because the value is ours while the contrast that justifies it needs a browser.
**The reason line is Grove's own words about the DELIVERY, never the peer's body.** The envelope carries no subject — `subject` is the delivery intent — and paraphrasing a stranger's message would put their words in Grove's voice; the body is one click away in the disclosure. `carried` is empty for the same reason: the element lists context items carried across a handoff, and splitting a body into bullets would be Grove summarising a peer.
## Two composers, two attachment mechanisms, and the runtime is why
**The workspace composer supplies an assistant-ui `AttachmentAdapter`; the landing composer cannot, and must not be "fixed" to.** That route mounts NO `AssistantRuntimeProvider` — deliberately, because mounting one silently broke every client-side navigation away from the page (the bug is written up on `useLaunchSubmit`) — and `ComposerPrimitive.Attachments`, `ComposerPrimitive.AddAttachment` and `AttachmentPrimitive` every one of them read composer state off that runtime. So the landing surface stages files in plain React and sends the BYTES on the create request, while the workspace surface uploads to `/workspaces/{id}/attachments` and sends ids on a message. The engine reasoning for the split is in [contracts](../src/grove/core/contracts/CLAUDE.md).
**Clipboard files use those same paths.** `ComposerPrimitive.Input` already owns paste via `addAttachmentOnPaste`; adding a competing workspace handler would stage twice. The runtime-free landing textarea forwards clipboard files to its picker’s `addFiles`. Only a file-bearing paste is intercepted; plain text remains native. Both stage first and upload on send, with no clipboard permission request or alternate endpoint.
**ONE file card on every surface, and it is the `File` element, never the composer chip.** `components/grove/attachment-file.tsx` composes `File.Root/Icon/Name/Size` at `size="sm"`; the landing composer, the workspace composer and a sent turn all draw it. assistant-ui ships TWO file vocabularies — `ComposerAttachmentChip` (an icon tile, pixel-literal type, a blue progress bar) and `File` — and for a while the composers drew the chip while the transcript drew `File`, so the same file changed shape across a send and `globals.css` spent four rules re-tokenising a chip the transcript never used. `File`'s own page names it for a file on a message, so it wins and the chip has no caller. Removal still binds caller identity, never the filename (duplicates are valid), and the removal control floors its hit area at the call site because the vendored element gives that slot no name.
**The caption line is unconditional, and that is what `file-metadata` is for.** A card that dropped its caption when nothing had weighed the file changed HEIGHT between two identical-looking files; `Size not recorded` is a fact instead. An upload's status JOINS that line rather than replacing the size. **An absent size never reaches `File.Size`** — the vendored formatter renders `NaN MB` for one — so that card carries no `file-size` slot, and nothing ever stats a file to invent one.
**`TooltipTrigger asChild` MERGES its `data-slot` over the child's**, so wrapping `File.Name` in the trigger silently renames `file-name` out of the contract; the trigger takes a focusable wrapper span instead. **`File.Root` sizes to its content**, so a staged card and a sent one differed by the remove button only one has — a fixed width with `max-w-full` is what makes one file one width.
**The look is `.attachment-card` in `app/globals.css`, and `file-root` had to LEAVE the unlayered inner-cell radius block or that block beats the class.** Scoped to the class because widening it onto the slot would repaint every `File` in the app, invisibly to `registry:check`.
**A sent message's files are `message.attachments`, not `file` content parts, because the two render in different places.** A content part lands inside the user bubble after the text, where DELTA 3's six-line clamp hides it FIRST; `message.attachments` is what the user-message grid's first row is reserved for — above the bubble, outside the clamp — so every file stays visible while the prompt is collapsed. `adapters/attachments.ts::messageAttachments` builds the complete-attachment shape the composer's own adapter completes an upload into, so one file reads identically before and after the round trip, and the thread renders both through `MessagePrimitive.Attachments` / `ComposerPrimitive.Attachments` in `workspace/composer-attachment.tsx`. The byte count rides `content[0].providerMetadata.grove.size` — assistant-ui's file part has no size field and that map is the one slot the type reserves for a provider's facts — and `filePartSize` is the single reader; a file nobody weighed carries no entry, never a zero.
One dependency-direction consequence worth not undoing: `MAX_ATTACHMENT_BYTES` / `ATTACHMENT_ACCEPT` / `attachmentError` live in `adapters/attachments.ts` (pure) and are re-exported from `runtime/attachments.ts`, because `buildCreateRequest` refuses an over-size file and a pure adapter must not pull `groveClient` into its module graph.
## One model vocabulary, three pickers, and the seam is the options builder
Three controls read one catalog — the landing `ModelPill`, the workspace `ComposerModel`, the create dialog (now the vendored selector too, not a `Select`) — and each had its own idea of a label: the pill capitalised aliases, the composer folded a shared prefix, the dialog printed raw ids. **The vendored `ModelSelector` already takes `icon`/`description`/`keywords` per item, so the reuse seam is `modelOptions()` in `components/grove/model-option.tsx`, not a new selector**: `adapters/model.ts` (pure) decides the namespace fold, the label and the brand-icon slug, and every picker maps that output onto whatever vendored list it composes.
- **A NAME IS DECLARED, A WINDOW IS MEASURED, AND AN ID IS NEITHER — `GET /models` carries all three and every picker takes the same row.** A gateway publishes addresses (`anthropic-gpt-5.6-luna`), not names: measured across the reference host's 92-model catalog, **no endpoint publishes a display name at all**, so the name comes from `models.display_names` in the config cascade and is `null` when nobody declared one. The context window is the opposite — it IS published (`max_input_tokens`, 78 of 89 models) and rides the pricing snapshot that was already being fetched, so it costs no request, no credential and no second cache. `modelOptions` takes `ModelOptionView[]` **or** `string[]`, because a live session's switch vocabulary is strings the agent itself reported; a declared name wins, an undeclared one falls back to the id's own folded spelling, and an unpublished window renders **no second line rather than `0`**.
- **MEMBERSHIP AND DISPLAY ARE DIFFERENT QUESTIONS ABOUT THE SAME IDS, and only the composer has to keep them apart.** The landing pill and the create dialog get their rows FROM the catalog, so `modelOptions(catalog)` is right there. `ComposerModel` must not: its list is `SessionControlsView.models`, the agent's own `--model` vocabulary, and substituting the catalog would offer a model the session cannot switch to while dropping one it can. `enrichedCatalog(ids, catalog)` joins by id — ids decide which rows exist, the catalog decides only what they are called — which is why that surface renders 21 rows where the landing pill renders 22, and both are correct.
- **The trigger is the `outline` variant on all four surfaces.** `globals.css` already rebinds a resting outline control to `--edge-control`, so this is composition rather than styling and no call site names a colour. Verified on the built page: all five launch pills plus the workspace composer report a real `1px` `--edge-control` border and `box-shadow: none`. **A variant switch silently changes which theme rules match** — the toolbar's sibling rule keys on `button[data-variant="ghost"]` — so after changing one, census the selectors that matched before against the ones that match after, rather than assuming the slot-keyed rules cover it.
- **NO reasoning-effort control**: options carry no `efforts`, so the vendored effort row renders nothing. Pinned as an absence test, because the vendored component would happily draw it the day somebody passes the prop.
- **THE `[1m]` PAIR IS FOLDED ENGINE-SIDE AND THE MARKER IS STRIPPED CLIENT-SIDE, and the split is which question each layer owns.** A gateway publishes `x` and `x[1m]` because Claude Code needs them distinct; to a reader they are one choice twice (measured: all 7 pairs report identical rates and an identical `max_input_tokens`). `resolve_models` drops the plain half when both are present — one seam, so the webapp, TUI, CLI and MCP all inherit it — and `modelLabel` drops the SUFFIX from the label, because the marker is wire syntax and the row's own second line already states the window. The id is untouched in both directions: it is what gets sent, and it stays in `keywords` so a reader who knows the provider's spelling still finds the row. The literal is spelled in two files (`registry.py`, `adapters/model.ts`) under a **cross-language drift test** that reads the Python source — two short literals beat a wire field, but only while something fails when they diverge.
- **A ROW THAT GAINED A SECOND LINE NEEDS ITS FIXED HEIGHT REMOVED, NOT ADJUSTED.** The composer's item carried `h-[26px] min-h-[26px] … text-xs`, sized when a row was one line; with a context window underneath, that crushed two lines into one line's space. The row sizes itself now. **The cramping was mostly TYPE, not spacing** — the vendored `text-sm` resolves to **10.4px** at this app's 80% density root, with a 9.6px caption, both under §1's 12px floor on the one surface whose job is to be compared. Fixed by stepping the menu UP the ramp (`--text-lg` / `--text-base` → 12.8px / 11.2px), never a px literal, so it still tracks the density lever and zoom. The trigger is deliberately left at the toolbar's own size: it is chrome you aim at, not text you compare.
- **A BARE `ModelSelector.Trigger` RESOLVES ITS LABEL BY LOOKING `value` UP IN `models`, so a row composed separately is a row the trigger cannot name.** The create dialog renders its `Agent default` sentinel as its own `Item` above the namespace group — correct — and passing only the catalog to `Root` made the default selection render the vendor's `Select model` PLACEHOLDER: a control claiming nothing was chosen while the request it builds carries the resolved default. `models` must contain every selectable row, the sentinel included, even when the rendered list is composed by hand. **A source census cannot see this** (the composition reads correctly); a `renderToStaticMarkup` of the vendored trigger alone can, needs no dialog, and is how it was caught.
- **The namespace fold has a guard, and the guard is the whole design.** A gateway catalog is `anthropic-<model>` twenty times over and the repeated word belongs in the group heading; a Codex catalog is `gpt-6-astra`, `gpt-5.6-sol`, … and the same fold would print `6-astra`. The fold only fires when every remainder still starts with a letter. No vendor table, no adapter-kind switch — the catalog itself says whether it has a namespace.
- **A model's mark is per-VALUE data resolved from the id, never a fixed glyph.** The pill used to lead with `CpuIcon`, which said "a model" beside a value that already said which one. `modelIconSlug` maps the family word to an Iconify slug and `AppIcon` renders it; an unrecognised id gets `KeyRoundIcon` at the same size so a row never shifts, and "Agent default" — a VALUE, not a placeholder — wears no mark at all. **A single-black-fill logo must come from a `currentColor` set (`simple-icons`), not `logos`**: `logos:openai-icon` vanished on the dark ladder while every coloured mark beside it stayed legible, and nothing but a dark-theme screenshot shows that.
- **`AppIcon` is the only Iconify caller and it is `lazy`.** The prop surface is deliberately closed to `slug`, `className` and two labelling attributes: Iconify's `Icon` narrows several SVG attributes (`mode`, `rotate`, `flip`) to its own unions, so a blanket `SVGProps` spread does not type-check, and a later consumer — tool-call icons are the planned one — needs a validated slug and a fallback, not a passthrough. SSR always renders the fallback because the lazy module never resolves server-side; the unit test pins exactly that.
- **The composer control pill is a theme rule keyed by slot INSIDE `[data-slot="composer-toolbar"]`, and the expand control is matched by `button[data-variant="ghost"]`, not by slot** — `TooltipTrigger asChild` overwrites a `TooltipIconButton`'s `data-slot` (the same trap the outline-button rule in `globals.css` records). The model trigger is also a ghost button, so the icon-only circle rule excludes it by slot. Three composers mount these controls; the theme boundary is the only place one decision reaches all of them.
## The onboarding tour: reactour, styled at the theme boundary, driven by demands
`components/grove/onboarding/` is `@reactour/tour` mounted once in `(shell)/layout.tsx`, a step BUILDER, a store, and a DEMO WORKSPACE with the interceptor that serves it. Seven decisions carry it.
- **reactour is a library dependency, not a vendored component, and it is styled ONLY in `globals.css`.** It writes its look inline (white 353px popover, black 0.7 mask), so `.reactour__popover`/`.reactour__mask` take the overlay tuple with `!important` at the theme boundary. Its `styles` prop is used for exactly one thing, the mask cut-out's `rx`, because that is an SVG attribute a stylesheet cannot reach. The card body (`TourCard`) composes `Button`/`Kbd`/`TooltipIconButton` and names no colour, so `lint:styling` sees it like any other Grove file.
- **reactour measures its anchor on mount and on window `resize`, and on NOTHING else.** A chip staged by a demand, a pane opening beside the page, a route that finished rendering after the step began — all left the hole and the popover where the anchor USED to be until the reader resized or came back to the tab (which refetches and remounts). That is the "leave the tab and it fixes itself" report of 2026-09-14. `TourBridge` funnels a `MutationObserver` plus a 200ms poll into a synthetic `resize`, **gated on the anchor's rounded rect actually changing** — ungated, reactour's own popover restyles itself on every resize, the observer sees that, and the loop pegs the main thread (measured: the Next button never became clickable). `disableWhenSelectorFalsy` is passed through a cast because `TourProvider` spreads every prop onto `Tour` while its type omits it; with it, a step whose anchor is a frame away renders nothing rather than a hole at the origin.
- **THE TOUR WALKS ONE FICTIONAL WORKSPACE, SERVED FROM THE BROWSER, NEVER A REAL ONE.** "The most recent workspace" showed every reader a different page — an empty Info tab, no tickets, whatever the terminal was doing — and the diagram step could only ever *promise* a tab. `demo-workspace.ts` is one fully populated workspace typed as the generated wire views (changes, three commits, four dirty files, a `verifying` phase with a 3/5 checklist, an issue and a draft PR each with its own phase claim, a timeline, identity, controls with skills and MCP servers, one queued follow-up, and an OPEN diagram whose XML is the screenshot harness's `grove-stack.drawio` under `public/onboarding/`). `demo-interceptor.ts` answers it at **`GroveClient.basePath`** — the one `fetch` prefix every hook shares, so thirteen query keys, the SSE reducer and any hook written later are covered by one decision — plus a fake `EventSource` for the pane stream and a wrapper on `/events` that splices the demo project into every snapshot. Only paths naming `DEMO_WORKSPACE_ID` or `?repo=DEMO_REPO_ROOT` are answered; everything else, including a real workspace opened mid-tour, passes through. Installed on open, uninstalled on close (the route leaves the demo, its query keys are dropped, `/activity` is invalidated), and the originals are captured once so two installs cannot stack. **The demo repo root must be one no fixture or host uses**: the first cut reused the e2e fixture's `/home/demo/acme/widget` and `withDemoWorkspace` correctly declined to splice a project that already existed, so the rail never showed it. **`?repo=` is not a workspace scope**: the landing page's `/agents`, `/branches` and `/defaults` carry it too, and answering them from the demo turned the model pill into `agents.data?.find is not a function`.
- **THE SAMPLE IS WRITTEN INTO THE ACTIVITY CACHE ON OPEN; INVALIDATING IT WAS A NO-OP.** The daemon streams a full `snapshot` frame exactly once, on connect, and deltas after; while the stream is healthy the activity query's `refetchInterval` is `false`. So `invalidateQueries(activity)` on tour open changed nothing, `primarySessionId` stayed null, and step 13 showed "Pick a session to follow" — until some unrelated reconnect (a tab switch, the 20 s stale reflex) delivered a fresh snapshot the interceptor could splice, which is why it looked like a slow render rather than a missing write. `setQueryData(activity, withDemoWorkspace)` on open and `withoutDemoWorkspace` on close is the fix; the interceptor's splice still covers a future connect and the fallback poll. Pinned by a source census that fails if the write turns back into an invalidate (mutation-tested).
- **Steps are BUILT per run (`buildSteps(context)`), not a constant.** The context is pinned (`DEMO_WORKSPACE_ID`, `hasDiagram: true`) but the builder still carries both diagram variants — anchor to the tab when it exists, write a diagram query into the composer when it does not — and the unit census examines both. reactour keeps `steps` in its own state seeded once from the prop, so the bridge pushes the list in with `setSteps` and publishes it on the store for the card, which otherwise sees only titles. `ONBOARDING_STEPS` is the census's view, not what a user sees.
- **Steps anchor to `data-testid`/`data-pill`/`data-slot` attributes OTHER files already own, and `tests/unit/onboarding.test.ts` is the census.** A selector that matches nothing renders nothing at all now — no throw, no type error, the tour looks stuck. The test extracts every data attribute under `components/grove/` and `components/elements/` and fails a step whose anchor is missing (mutation-tested by misspelling `fleet-tree`). The rail nav rows gained `rail-nav-<label>` for this; the work-panel tab ids are interpolated, so the census asserts the template exists in `work-panel.tsx`.
- **The tour never touches page state directly; it posts a DEMAND and the page that owns it takes it.** `useOnboardingUi.demand` is the one field and `take(kinds)` the one consumer API: the landing page takes `sample-image`/`annotate`/`prompt`/`reset` through its own `addFiles`/`setPrompt`/annotation store (so the sample image meets the same refusals a picked file does), the workspace page takes `pane`/`work-tab` through its own selection state, and the workspace composer takes `workspace-prompt` through `composer.setText` — inline mount only, or the expand dialog's copy writes it twice. A demand nobody has taken waits in the store, which is what lets "navigate to the workspace and pick a tab" be one step. `reset` clears only briefs the tour wrote (`DEMO_PROMPTS`); the workspace query is deliberately left in place for the reader to send.
- **The highlight ring is an SVG `stroke` + `drop-shadow` on reactour's click-through rect, never a `box-shadow`.** reactour draws the cut-out twice — a hole inside the SVG `<mask>` and a transparent rect over it — and only the second is a rendered element positioned on the anchor, so `highlightedMaskClassName="tour-highlight"` is the seam and `globals.css` owns the look: `--primary` stroke, a slow `stroke-opacity`/filter pulse, static under reduced motion. `box-shadow` does not apply to SVG, which is the mistake this line exists to save. The sample workspace's title leads with `Sample ·` because the rail, the header and the tab all print it, and a real workspace can share the rest of the name.
- **Store → reactour open is mirrored back only after an open reactour PERFORMED.** The naive "if reactour says closed, close the store" effect fired on the render that set the store open and the tour closed before it opened. Nothing threw; the first-visit e2e is what caught it. Same family as the "first value seen" guard in the state section: a pending transition reads identically to a completed one.
**Verifying it needs the browser** (`tests/e2e/onboarding.spec.ts`): the first-visit auto-open is a `localStorage` edge, the deferred anchors only exist once a demand has run, and the mask hole is an SVG rect sized by inline style whose only honest reading is the rendered bounding box of the LAST `.reactour__mask rect` (the rects inside `<mask>` have no layout box). The spec asserts the hole sits on the anchor at every non-centred step and that the card stays in the viewport, which is exactly what the screenshots showed failing. A click on `Take the tour` before hydration is silently dropped — `openTour` retries until the card appears.
## Annotating a staged image: one host in the shell, one editor moved between two mounts
`components/grove/annotation/` overwrites a staged image with an annotated copy, client-side, on both composers. Four decisions carry it and each was paid for once.
- **`react-resizable-panels` writes `data-testid` FROM `id`, AFTER spreading the rest props, so a `data-testid` prop on a `Group`/`Panel`/`Separator` is silently replaced.** The annotation group carried `id="grove-annotation-split"` plus `data-testid="annotation-split"`, and the rendered element was `data-testid="grove-annotation-split"`: the tour's first cut watched that selector for mutations and observed nothing, and the screenshot driver timed out on the same one. Nothing failed, because an observable that matches nothing observes nothing and the unit census only greps the SOURCE for the attribute. The id IS the testid on those three elements; name it what the selectors want.
- **The split pane lives in the SHELL, not in either page.** `AnnotationHost` wraps the page slot in a `ResizablePanelGroup` whose first panel is the page and whose second exists only while a request is open. `children` keeps its tree position either way, so opening the pane remounts nothing — the transcript, its runtime, the draft and every staged file survive, and the landing route gets the split without a panel group of its own. Both composers reach it through the `useAnnotationUi` store for `create-store.ts`'s reason: their file cards sit in trees with no common parent below the shell. **`SplitHandle` is the one divider for every split in the app**; a second `ResizableHandle` call site is how two splits come to draw two dividers, and `app-shell.test.ts` pins that.
- **The editor is a web component appended BY HAND, and the module is imported INSIDE an effect.** `@markerjs/markerjs-ui` calls `customElements.define` at module evaluation, so a static import takes the route down on the server; and the element reads its `targetImage` PROPERTY synchronously in `connectedCallback`, one frame before React would set an unknown prop on a custom element. The app's theme class never crosses its shadow root, so the resolved theme is mirrored onto the element's own `theme` switch — pinned in the browser, because a static render cannot see a shadow tree.
- **A moved custom element REBUILDS ITSELF, so the maximize toggle would erase the draft.** The dialog follows the `ExpandedComposer` move-not-clone rule, and the cost is that `connectedCallback` runs again in the new mount. `ImageAnnotator` snapshots the marker state on unmount into a `WeakMap` keyed by the `File` and restores it on the next mount — the same map that lets a re-edit reopen the markers editable over the original pixels instead of drawing on a flattened copy. Keyed by `File` identity because a staged file lives exactly as long as the composer holds it, which is why `StagedAttachment` now keeps its `file` beside the base64 the wire wants: rebuilding a `File` from the bytes is a *different object* and the markers are gone. Nothing is persisted; the memory dies with the visit.
- **Save is REPLACE, and the two composers replace differently, so the request carries a callback.** `AnnotationRequest.onSave` hands back the overwritten `File` under `annotatedName`, which is idempotent and takes the bytes' extension rather than the source's.
- **The output's size is a function of the PIXEL COUNT, never of the input file — bound the pixels, then the codec.** Annotation is decode → raster → re-encode, so a 400 KB 3000x2400 JPEG is 29 MB of pixels the moment it enters the editor, and the first cut asked marker.js for `naturalSize` PNG: every pixel, losslessly — 19 MB out, and measured 31.6 MB from a 3.9 MB 12 MP photo. Two wrong seams were tried on the way here and are worth not re-trying: a per-source codec table ("follow the source") and a lossless-first/lossy-past-a-ceiling second render. Both attacked bytes-per-pixel while leaving the pixel count unbounded, so a big enough photo still came back larger than it went in. `annotatedSize` caps the long edge at 2048 (the consumer is a vision model that downsamples to ~1.2 MP anyway, so pixels past the cap are rendered for nobody; a screenshot's text stays legible), and `ANNOTATION_CODEC` is one lossy WebP at 0.9 — no table, no ceiling, no second render. Measured: 3200x2400 photo-like JPEG in, a smaller 2048x1536 WebP out. **The extension comes from the bytes PRODUCED (`dataUrlMimeType`), never the codec requested**: `toDataURL` silently substitutes PNG where a browser cannot encode WebP, per spec, so a browser without the encoder would otherwise ship PNG bytes under a `.webp` name.
**Verifying it needs the browser.** The unit suite pins the name rule and the card's Annotate gate; `tests/e2e/image-annotation.spec.ts` proves the rest — pane beside the page with the page's DOM identity intact, one editor across the maximize move with its marker count carried, the PNG signature of what the workspace composer actually uploads. The marker.js toolbar names its buttons (`aria-label`), Playwright pierces the open shadow root, and the editing target is an `<img>` inside it, not the `<svg image>` the markers are drawn on.
## A label is part of a control's identity, and the row is where collisions live
**"Repo root" and "Repository root" shipped as two options in one control row, meaning two different things.** One was the branch pill's placement (Grove cuts no worktree), the other the working-directory pill's starting directory. Both correct in isolation, adjacent on screen, and read as the same setting stated twice. The glyphs collided the same way: the branch pill's `FolderRootIcon` sat beside the project mark's `FolderGit2Icon`, so the one folder in an otherwise git-family list looked like the control above it.
**A label collision is worse than the `kind` collision this file already documents, in one specific respect: the control still works.** A dead pill gets reported; an ambiguous one just gets the wrong answer picked, quietly. Both are now pinned in `tests/unit/launch-control-row.test.ts` as a cross-FILE census, because each file is individually correct and only the relationship is wrong.
**The census normalizes before comparing, and that is the whole point** — exact string equality would NOT have caught this pair, since "Repo root" and "Repository root" differ as strings and not as meanings. It maps the abbreviations that actually collided rather than pretending to a general theory of synonyms, and it was mutation-tested: reverting the label and the glyph turns both assertions red. **A uniqueness guard that only catches exact duplicates is close to vacuous; check yours against the pair that motivated it.**
## 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 may only display a value something actually RESOLVED
Three defects, one shape: two surfaces inventing an answer, and one asserting an equality, each about a value only the daemon can supply. None throws, none fails `tsc`, and all look completely normal on screen. `tests/unit/displayed-defaults.test.ts` pins them as a source census, in `launch-control-row.test.ts`'s style — these are JSX inside components needing a query client, a popover and a live workspace, so no `node`-environment artifact exists to inspect instead.
- **`?? "host"` in the runtime pill was a promise nothing kept.** The composer's whole contract is that a pill shows what an untouched create will actually do, and the request omits an untouched field precisely so the engine resolves it — so a literal fallback is the client answering a question it does not own. It answered wrong: `container.enabled` defaults to **TRUE**, so every render between first paint and the `/defaults` response claimed Host, and submitting is one keypress on the first control of the page. The engine now resolves `defaults.runtime` too (see [core](../src/grove/core/CLAUDE.md)), which is the other half — but **the fallback would still be wrong with the engine fixed**, because "not yet known" is a real state and `null` is how a pill says it.
- **`?? catalog[0]` in the model pill is the same literal wearing a different disguise, and it is the one worth remembering.** It named `anthropic-deepseek-v4-pro` merely because that id sorts first in one gateway's configured list — and with no saved model the request omits the field and the engine appends **no `--model` at all**, so the pill promised a model that would never be used. A value read out of fetched data *looks* resolved in a way a string literal does not; the test is not "did this come from the server" but **"is this the answer the create will produce"**. The honest fallback is `null`, labelled `Agent default` — which is a VALUE (precisely what an omitted `model` yields), not a placeholder. **This one was missed on the pass that fixed and documented the runtime pill**, so the rule was written next to a test that named exactly one of the two siblings: when a rule is extracted from one control, take the census of its siblings in the same commit.
- **`id === current_model` compared two different namespaces and was false for every chip on every workspace.** `models` is the agent's `--model` catalog (tier aliases, or a gateway's ids); `current_model` is read off the transcript and is the API model the provider *reported*. Measured: 9 of ~10,900 real assistant messages carried a name the catalog also contained. A permanently-false selection reads as a control that does not respond, so the card states the reported model as a fact and no chip claims to be selected. **A selected state you cannot compute honestly is worse than none** — it teaches the user the control is dead.
- **Say "delivered", never "switched".** `POST /controls/model` answers 204 the moment the control reaches the pane; whether the agent accepts the id is the agent's own business and its own message, and on a gateway it routinely refuses (the reproduction lives in [agents](../src/grove/core/agents/CLAUDE.md)). The Terminal tab is where that answer appears, so the card points at it rather than implying a result it never observed.
## 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 configured gateway)`, and three anonymous icons beside two worded pills read as decoration rather than as controls.
Separate configuration from message actions instead of asking one toolbar to hold both. Landing owns an inset configuration shelf and the shared composer owns attach, model, expand and send. Long values remain readable in native pickers. Label every picker for its actual entity, including search and empty results. A generic picker must not announce every hidden search input as Model. Text fields inside a command popover must keep editing keys out of command selection without blocking Escape or Tab.
## 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 separate opaque `footer` region, 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 and footer share one width policy, not one scroll container.** `thread-width.ts` owns `THREAD_WIDTH` and `THREAD_INSET`, passed as the same pair to both column wrappers. The viewport is a shrinking flex sibling above an opaque `surface-base` footer containing loader, plan/queue, questions and composer. A tall footer is bounded and scrolls without eliminating the transcript. Keep `ThreadPrimitive.ScrollToBottom` inside the viewport provider; a sibling footer must be plain layout, because `ViewportFooter` registers a sticky content inset through that same provider. Verify the actual viewport/footer rectangles and the scroll button in the browser.
**In `split` the PANE IS THE MEASURE.** Upstream's centred 44rem is right for a chat that owns a window; here the reader has already chosen the width by dragging the handle, and capping it again spends that decision twice — measured on a 2540px screen, a 1531px transcript pane drew a 704px column between two 400px gutters. So `split` is `100%` with a flat `px-4`, and the handle is how you change it. **A column that fills has no gutters left over**, which is why its inset cannot use the container steps: at ordinary split widths `@6xl` resolves, and 80px either side of the text is the centred-third complaint wearing padding.
Two traps on the `full` side, 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, canInterrupt }` and works out its own tab set from whether it got one. **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 the lifecycle verbs without a record carrying `branch_provenance` (which the narrowed identity deliberately lacks), and the compiler enforces it. **When the bundle shrinks to ONE field, unwrap it**: `InfoTab` now holds no verbs at all, so its owner-only prop is a bare `identity?: WorkspaceStateView` — the full record is simultaneously the data and the permission, because the narrowed `WorkspaceRead.state` cannot satisfy it. A one-key object would be ceremony around a type that already carries the whole argument.
- **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`, and `view` does 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 work TAB is the counter-example, and it is the more interesting half of that rule.** It was persisted alongside the pane and has been taken back out: arriving at a workspace put you on whatever its terminal was doing days ago, when the question a reader actually has on arrival is *what is this*. So every visit — fresh, reloaded, deep-linked, or a client-side navigation to another id — lands on Info, and a tab clicked during a visit lives in component state for that visit only. **"A user would be annoyed to lose it" is not the same test as "a user wants it back"**: continuity is worth persisting across a remount *within* a visit, and a stale answer to a question nobody asked again is not.
The delete has to reach the validator. `storedWorkTab` was the only reader's only helper, and leaving it exported with no caller is how the key comes back; its absence is now pinned as a source census, because an absent mechanism has no runtime artifact to assert against.
**A "first value seen" guard is defeated by a LOADING STATE, and this is the shape to recognise.** The diagram tab auto-selects on a live open, guarded by a ref whose `undefined` means "not observed yet" — but the value came from `peek.data?.state`, and an unresolved query reads identically to "no diagram". Every fresh load of a workspace that already had one therefore looked like a `null → id` open and landed the reader on Diagram, with the guard's own comment claiming the opposite. Measured on the built app, not read out of the code. **Optional-chaining through a query is what turns "not answered yet" into a fact**; the observation must be gated on the query having answered.
## Tool timelines: presentation data, not provider inference
`adapters/tool-catalog.json` owns builtin names, verbs, icon slugs and argument keys. `toolPresentation` resolves known protocol namespaces before lookup; MCP function names never inherit a builtin's meaning. Unknown tools remain named, with a generic mark. Counts mean visible invocation steps, distinct explicit read paths and command invocations—not files inferred by parsing shell text. `compactToolTarget` is display-only: command previews take two whitespace words (or argv elements), file previews take the basename, and all previews have a character bound. Never derive identity, file counts or expanded inputs from that lossy preview; equal basenames can refer to different files. This is not shell parsing or a claim that a two-word prefix describes every command in a compound invocation.
**A FILE EDIT IS A TOOL CALL, so it rides the run rather than breaking it.** It used to flush the tool run and emit its own `data-file-edit` message, which split one continuous piece of work — read a file, edit it, run the tests — into a group, a standalone card, and another group. The diff now travels on the part as `groveFileEdit` (the same mechanism `groveMailbox` uses, and for the same reason: `artifact` is contracted as the wire's `ToolCallView`, so a second meaning there is how two readers come to disagree about what that field holds). The step names its file, reports `+n −n` from the diff, and expands into the native split viewer; `FileEditPart` survives for an edit that arrives with no run around it. **The per-file chips under the steps are summed from the DIFF, never from the tool name** — a tool name says an edit happened, never how much of the file moved, so an edit whose payload the provider did not report contributes a step and no chip. Reads and edits are counted separately (`1 file read · 2 files changed`): they are different claims about a file, and one number would state neither.
**A CREATION IS DRAWN UNIFIED, AND THE DISCRIMINATOR IS THE OLD TEXT.** Split view exists to put two sides beside each other; a new file has no left side, so a 200-line creation renders 200 blank cells and halves the measure available to the content. The vendor ships both `DiffViewerLine` and `DiffViewerSplitLine`, so this is a choice between two of its own rows, made once in `DiffBody` rather than at each call site — the standalone card and the timeline step must never disagree about how one edit reads. **Never branch on the tool's name**: `Write` routinely overwrites a file that had plenty of content (a real two-sided diff), and an `Edit` reporting an empty original is a creation whatever the provider called it.
**The source stack keeps artwork and surface state separate.** `iconTint` picks an accent from bundled SVG fills; it is not a dominant-colour measurement or a contrast guarantee. The container fades through its own pseudo-element when opened, preserving coin and glyph opacity and trigger geometry. Preserve compact coin overlap while painting glyphs above every rim; browser checks must verify glyph separation and stacking, not infer visibility from the circles alone.
**Card marks follow the subject, not the container.** Mailbox summaries and their cards resolve the same catalog icon. Queue, checklist plan, and plan approval keep distinct leading marks through their native header composition; do not replace an existing mark with a second adjacent one.
**An expanded edit is a card, not another disclosure.** Compose `CardShell`, the shared `FileRowSummary`, and native diff rows beneath the step's existing toggle. The header restores the directory path and counts that the compact trigger cannot show. Keep per-file totals at the bottom of the group.
**A live run ends at a transcript boundary, not a tool's completion.** The adapter flushes runs into separate assistant messages, so part indices cannot observe the next prose message. Use the runtime's `message.isLast` and latch a witnessed live run across completed-call gaps; historical tails still mount closed. Tests must refetch into the same mounted tree and pin its DOM identity. Reloading between fixture changes tests mount defaults, not disclosure transitions.
The current upstream `ToolTimeline` has no custom trigger or row-body slots. `workspace/tool-timeline.tsx` is a declared port preserving its anatomy while composing native per-call disclosures and a collapsed icon set. Request/response bodies remain whole and lazy; grouped calls retain assistant-ui identity and chronological boundaries. Provider status, not the presence of a result, controls running/error cues.
The optional web-server-owned `$XDG_CONFIG_HOME/grove/tool-icons.json` (default `~/.config/grove/tool-icons.json`) is a flat MCP server-name → Iconify `prefix:name` map. It carries no verbs or executable configuration. The authenticated shell validates the session before reading and serializing it; missing or malformed files use builtins, a full page reload rereads it, and public shares use builtins only. Do not fold this auxiliary display file into provider credentials or the engine config cascade.
## 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).
**The window is now the ONLY thing bounding a transcript read, and the browser must not re-introduce a second one.** The daemon used to cap per-entry text and tool bodies at 4 K / 16 K characters; it no longer trims anything, because a character ceiling is a bound the reader cannot see, ask about or splice. So the turn window carries the whole job — which is fine, because it is *visible*: "Load earlier messages" is a button, the count is a real unit, and nothing is missing from a turn that is present. **A `max-h-*` on a body is that second bound wearing CSS**, and `ToolBody` is where it was removed: see below.
- **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.**
## The working loader, and why it needed no new wire read
`WorkingLoader` composes the vendored `GenerationLoader` in the thread's footer
while the agent is working. Three things about it are worth not re-deriving.
**Sub-agents were already folded in, upstream.** The obvious implementation
reads `active_subagents` or `WorkspaceActivityView.fleet` beside the state — and
it is redundant: the engine promotes a session whose sidechain fleet is active
to `working` even when the orchestrator's own turn has closed (`end_turn` on the
tail), precisely so one field answers "is anything happening". A second read
would have been a client re-deciding a question the engine owns, and it would
disagree the moment either side changed. **Check whether the state you are about
to compose already absorbs the case before adding the field that names it.**
**`GroveThreadState.working` was `canInterrupt`.** Same derivation, renamed for
the FACT rather than for its then-only consumer: a name that describes one
caller's use makes the second caller look like it is asking about a capability.
The interrupt still gates on it, which is the point — one derivation, two
readers, and `working-loader.test.ts` pins the `agentIsWorking` call count so a
third reader cannot quietly become a third derivation.
**The caller owns the animation clock, and that is what lets it stand down.**
The vendored loader is a pure function of `tick`, so the component drives it —
and therefore suspends under `prefers-reduced-motion` and while the document is
HIDDEN (not blurred; an unfocused Grove is still being read, the same
distinction `overflow-text.tsx` draws). A CSS animation could not do the second
one. Mounting is the real gate: it renders only while the agent works, so an
idle fleet costs nothing. **One module-level clock serves every loader on the
page** — a rail listing ten working workspaces would otherwise run ten intervals
that drift out of phase, so ten copies of one mark would each show a different
frame. **This is still not `relative-time.tsx`'s clock** — that one exists so two
*durations* agree on an instant and ticks once a minute; this is a frame counter
carrying no time, which nothing outside the file may read.
**A SHARED CLOCK'S LISTENERS BELONG TO THE CLOCK, NEVER TO EACH SUBSCRIBER —
and `addEventListener`'s dedupe is what makes the naive version fail silently.**
Registering the module's one `sync` reference per mounting loader looks like
ordinary effect hygiene and is the bug: the registry keys on `(type, listener)`,
so ten loaders are ONE entry and the FIRST unmount's `removeEventListener` takes
it away from the nine still on screen. Nothing throws, nothing leaks, and the
clock simply never hears `visibilitychange` again — so the next tab switch
freezes every matrix in the rail and the transcript at once, until a reload.
The tell is that it always looks like a render problem: the components are
mounted, subscribed and correct, and the thing that broke is a registration one
of their SIBLINGS owned. Track whether the listeners are attached in a flag and
flip it on the subscriber set going empty or non-empty; gate that on membership
rather than on `wanted()`, or a hidden tab unsubscribes from the very event that
would tell it to come back. **The same call pattern hides a second, quieter
bug**: `window.matchMedia` returns a NEW object per call (measured — two calls
with one string are `!==`), so a per-subscriber query can never remove its own
listener and leaks one per mount. One query per module, memoised.
**Verifying any of this needs a browser, and the unit suite structurally cannot
do it** — `node` mounts no effects, so the clock never starts and every
assertion passes against the broken code for the wrong reason.
`tests/e2e/working-loader-clock.spec.ts` owns the behaviour (mutation-tested:
red on the old clock, green on the new), and the unit file pins only the SOURCE
shape — that the subscribe effect touches no listener and `matchMedia` has one
call site. **Count the mounted loaders rather than asserting a number**: a
working workspace draws a mark twice on `/fleet`, once in the rail and once on
its card, so a hard-coded count pins the page's composition instead of the
clock's contract. And drive the unmount with a real client-side navigation — a
hidden document does not refetch, so swapping the fixture never reaches the
page.
**Drive the vendored loader in STEPS, not ticks.** It positions itself at
`Math.floor(tick / 3)`, so a clock feeding a bare tick re-renders nine spans
twice for every change a reader can see, and its interval constant means a third
of what its name says — `TICK_MS = 150` was really a 450ms step. Feeding
`step * 3` makes one fire one visible change and the constant honest (`STEP_MS`,
measured 231ms against a 225ms target). **A comment asserting a timing
constraint is a hypothesis until measured**: the original note claimed a step
inside the cells' 300ms cross-fade would smear the matrix into grey, and that was
wrong — `opacity` retargets from wherever it currently is, so an interrupted fade
shortens rather than tears, and cells still sit at a settled value ~77% of
frames.
**`WorkingMark` is a second composition, not a `labelled` prop** — the design
system's §3 rule, the one `relative-time.tsx` follows with four functions rather
than a `precision` knob. A line in a footer stack has room to say "Working"; a
mark in a 28px rail title band does not, and the word moves to `aria-label` so a
screen reader still hears it.
**Sizing a vendored internal: `em` was the obvious mechanism and it is the wrong
one here.** The matrix shipped at 25.5px beside its own 12.8px label. `em` on the
grid changes nothing, because `text-sm` sits on the vendored LABEL — the grid's
SIBLING — so the grid inherits the ROOT and `0.5em` is `0.5rem` spelled
differently (measured 6.39px both ways, which reads as a stale stylesheet rather
than as the wrong element). Declaring a `font-size` on the loader root makes the
em resolve and then the label's own `text-sm` **compounds against it to 10.4px,
under the 12px floor** — one fix, two ways to be wrong. The answer is
`calc(var(--text-sm) * …)`: it reads the same ramp step the label reads, so the
two move together under the density lever and browser zoom, and neither can
shift the other. **Before reaching for `em`, check which element actually
carries the font-size you mean to be proportional to.**
## 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)
- **`AgentActivityView.context` is `null` until the harness has SAID, and the meter draws nothing until then.** Claude Code's statusLine payload carries `current_usage: null` until the first request completes; a Codex rollout older than 0.98 never carries the window at all. `ContextMeter` renders nothing rather than 0 %, because a bar at zero on a session one turn from compaction is the one reading the meter exists to prevent — the same rule as a ticket with no phase claim. Use `!= null`, not `!== null`: an older daemon omits the field entirely, and both are the same absence. (This replaced a `RuntimeFactsCard` that spawned an app-server subprocess per read for the same number.)
- **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 used to be narrower than its wire shape, and the narrowing lived a layer below the contract — that whole hazard is GONE, and the shape of it is worth keeping.** While an answer was typed into the provider's picker, the adapter accepted free text on `single_select` only, refused `confirm` and optionless `free_text` outright, and refused multi-select — so the UI had to gate its own affordances on rules written in a Python file nobody reading the contract would open, and the one it got wrong (multi-select rendered answerable, 422 on submit) shipped. Grove now dismisses the widget and restates the batch as prose, so **every kind is answerable, free text rides any of them, and a choice and a note compose**. `QuestionAnswerItem` says exactly that and is the whole truth again. **The durable lesson is the failure mode, not the fix: when a UI has to ask a non-adjacent layer what it may render, expect the answer to drift, and prefer removing the constraint to documenting it.**
- **A SENT message's attachments are recovered by PARSING Grove's own fence, and that is a coupling to know about rather than a smell to tidy.** `SessionTurnView` carries no structured attachment field — the engine appends `<grove-instruction kind="attachments">` to the human's own text, because an agent's only way to use a file is to be told a path it can open — so the transcript IS the record and `adapters/attachments.ts` lifts it back off. Parsing a fence *Grove publishes* is not the provider-boundary trap; parsing a model's words would be. Three properties keep it honest: it is **anchored to the end** of the turn text (or a reader who pastes the tag into their own prompt has their message rewritten), an **unparseable block is left in place** rather than silently dropped, and the file part is `sourceType: "id"` — literally true, since the path addresses the agent's namespace and not one this browser could fetch, which is what makes the vendored `File` draw no download link without being told to. The row now ends in an OPTIONAL `(<n> bytes)` suffix the engine publishes at store time; the reader parses it when present and states `size: null` when absent, so a transcript written before the count existed renders its files without a fabricated size. **The hazard is the seam, not the parse: change the row shape engine-side and both suites stay green while the rows silently vanish** (the Python suite is self-consistent, the vitest fixtures are written against the old output). The format is pinned in `tests/core/test_instructions.py` with this reader named; if the engine ever grows a structured field, delete `splitAttachments` and read it.
- **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.
- **`ticketRollup.fraction` is AVERAGE PHASE PROGRESS and `done` is COMPLETION, and both are true at once — never reconcile them by changing one.** Two tickets at `delivering` are at index 4 of six, so the mean of `index / (total − 1)` is 0.8 while `0 / 2 done` is equally correct. A bar labelled "complete" showing 80% is the bug; a bar showing 0% to agree with the count is the worse fix, because it throws away every claim the agents reported. `rollupCoverage` states the count and the silence beside the bar so neither number can be read alone.
- **A folded total and its own unfolding are never both shown.** `AgentActivityView.tokens_in` folds fresh input, cache read and cache creation, so `activityFacts` returns SIX facts with the breakdown and FOUR without — the exclusivity is in the selector, not in a render branch that two call sites could disagree about. An absent class is `value: null` ("Not measured"), which is a different fact from a reported `0`; synthesising the classes from the total would be the fabricated-zero mistake one level up.
- **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.
## A third-party editor's silence is not evidence
The Diagram tab embeds draw.io over one worktree file. Three properties are load-bearing and each was paid for:
**The absence of an event is not the absence of unsaved work.** draw.io emits no autosave while a label is being typed. On an external revision edge, commit with `resetEditor`, then correlate an XML export and compare it with the editor's last clean capture. Only a matching capture allows live adoption; otherwise preserve the draft as a conflict. Do not compare raw loaded XML: the editor rewrites metadata. This handshake belongs on a real change/stop edge, never on every timer tick. A UI stop whose flush times out must not pretend the latest label was persisted.
**"Saved" means the latest draft was acknowledged.** Serialize/coalesce saves and read `event.xml`, not the original load XML echoed in `message`. Cancel in-flight diagram queries around managed writes rather than guessing whether a content hash is old: hashes have no temporal order, and a legitimate revert reuses one. A late result must not reactivate a stopped generation.
**Client-wins is an explicit conflict decision, not a force-write protocol.** Fetch the backend version before confirmation, retain the draft separately, then use the ordinary revision-checked writer against that confirmed version. Never silently refresh the precondition after confirmation: a second backend edit must conflict again. A stopped/reopened identity remains fenced. Do not export the iframe to replace the recovery draft, because a remount may be showing the backend baseline instead.
**Export correlation is echoed on the whole request, not its `message` member.** Keep the preview identity at request top level and make browser doubles return the entire request as `event.message`, for PNG and XML alike. A double that extracts `request.message` invents a protocol: sender and receiver agree in tests while every real PNG is discarded. Real-editor probes must call the production adapter, not hand-write a similar request.
**An `xml` on an export request is a document RELOAD, not a render hint — never send one.** draw.io's export handler answers `{action:"export", xml}` by `setFileData(xml)` before it draws: pages rebuilt, viewport thrown to the file's stored position, a label typed since that XML overwritten. The preview used to carry the acknowledged XML "to be sure of what it rendered", and the symptom was the canvas scrolling itself to the bottom ~150ms after every acknowledged save — which reads as a fit or a layout bug and is neither. Measured by dropping messages one at a time in the frame: dropping `fit` changed nothing, dropping the PNG export removed the jump. The identity echoed back (`grovePreview`) is what fences a late image against a newer revision; the XML bought nothing the editor was not already showing. The e2e doubles never caught this because a double does not reload anything — **what an editor does with a field is only visible in the real editor.**
**The shapes panel starts hidden (`sidebar=0`), and that is a default, not a removal.** Inside a work tab the panel took ~232px of a pane that is already a third of the window, so `fit` centred the drawing in what was left and the right edge was off-screen. `sidebar=0` only zeroes the initial split; View ▸ Shapes / Ctrl+Shift+K still opens it. `libraries=1` stays, because it gates the custom-library buttons, not the panel.
**Fit the viewport, never reload the document to fix geometry.** Use draw.io's native `fit` after its load acknowledgement and a settled substantial iframe resize. One ResizeObserver covers split handles, sidebar transitions and window changes; ignore zero-sized/hidden panels and small changes so ordinary editing does not repeatedly reset zoom. Disconnect and cancel the debounce when the frame or selected tab changes.
**Keep editable drafts mounted, but explicitly hide inactive tab content.** Radix `forceMount` makes `Presence` true and therefore its built-in `hidden` false even when the tab is unselected; `inert` alone removes interaction, not layout. Bind `hidden` and polling to the resolved selected tab. Preserve the active editor iframe because an unfinished label exists nowhere else; read-only diagrams use ordinary mount-on-selection behavior. Browser tests must assert the inactive panel has zero layout area and the other tab fills the available height, not merely that one tab trigger is selected. Acknowledged drafts additionally survive page reload through session storage.
`NEXT_PUBLIC_GROVE_DRAWIO_URL` overrides the hosted editor. Read it as a literal member expression — Next inlines `NEXT_PUBLIC_*` by textual substitution, so `process.env[name]` reads `undefined` in the browser and presents as the operator's setting being ignored. A configured value that fails sanitization renders an error rather than falling back to the public host, and the resolved host is named on the surface because the default sends the document to a third party.
## The gallery: one frame, three verbs, and the editor's silence measured twice more
`components/grove/gallery/` is a browse surface over `/gallery` (see [core](../src/grove/core/CLAUDE.md) for the census and attribution). What is worth knowing here is the draw.io frame, because every lesson `DiagramTab` paid for applies and two more were found.
- **`useDrawioFrame` is the load/export/fit third of `DiagramTab`, with the collaboration machine left out, and it serves TWO mounts**: the lightbox the reader looks at and an off-screen renderer that draws previews for cards the daemon has none for (`useGalleryRenderer`, one job at a time, an export jumping the queue because a person asked for it). Off-screen means `fixed -left-[10000px]`, never `display:none` — a frame that does not lay out exports nothing.
- **A `load` posted before the editor's `init` is consumed silently and the `load` event it would have answered with never comes.** The first cut posted whenever `contentWindow` existed; the frame then read as never ready. Queue until `init`, post on `init`.
- **The editor emits `load` for the FIRST document a frame receives and not reliably for a later one, while the later one renders and exports fine.** Readiness is therefore sticky per frame, and a second `load` waits a short settle rather than the full timeout.
- **A callback ref written inline is a NEW function every render, so React detaches it (`null`) and re-attaches it each time — and `frame.ref(null)` resets readiness.** The viewer un-readied itself the moment `load` set it ready. `useCallback` the ref; this one was invisible by reading and obvious from a message trace (`configure → init → load → fit`, then a skeleton forever).
- **The chromeless embed (`chrome=0`) draws NO page tabs, and the protocol's `load` takes no page selector** — measured against the hosted editor, and the docs confirm `invokeAction` lists no page verb. So the lightbox draws its own tabs (the `ui/tabs` line variant, per the shell's own rule) and switches pages by handing the frame a one-page document (`galleryPageDocument`). The export therefore always answers for the page on screen, and the filename carries the page name.
- **The renderer's preview is posted under the content digest the DOCUMENT read reported, checked against the listing's** — a file that changed between the listing and the render is not filed under the old bytes.
## 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.
## Group the surviving workspaces, never the project census
The rail defaults to **grouped by project**; the fleet defaults to **flat**. Both offer `Group by project` in their own native `DropdownMenu`-based `FleetFilterMenu`, with arrow-key navigation and checkboxes that stay open for multiple selections. Offline, orphaned and paused workspaces sort after live ones (within each project when grouped); idle agent activity is not an offline lifecycle. `filter.ts` owns both admission and grouping: filter first, then group by `repoRoot` (nested project CWDs share a group; identical names on different roots do not). Project headings sort alphabetically, rows retain the existing minute-bucketed recency order, and empty groups never exist. This is what prevents the old viewport of "0 — No workspaces yet" headings. Grouping arranges rather than hides: it does not increment the active-filter count, and clearing a search/filter preserves it. Grouped rows/cards omit the repeated project label, not the branch.
**Project context is a visit-scoped filter over the daemon's configured project identity.** The searchable native picker sits above New workspace, begins at All projects, persists its selected CWD in `sessionStorage`, and scopes rows by the snapshot group's workspace IDs (not `repo_root`, which can conflate configured siblings). `AppShell` owns one controller for its desktop and mobile copies; storage persistence alone does not synchronize two mounted hook instances. A missing saved project is not All: show its empty state and recovery. The current workspace remains navigable outside the selected context, and the launch link carries the selected project as its validated query seed; neither routing nor a draft changes when the context changes.
**Scaling a vendored `Command` means scaling FOUR surfaces, and only one of them takes a class.** The search overlay reads at the app's reading step (`text-base` rows and query, `text-sm` heading and project name) at `sm:max-w-2xl`, and getting there needed the dialog's `max-w`, the input's own class, the row's class — and a slot selector from the `Command` root for the input BAND, because `CommandInput` renders its wrapper div itself and hard-codes `h-9`/`gap-2`/`size-4` on it with no prop reaching them. **`CommandEmpty` takes no `className` at all**: it spreads props over a hard-coded string, so a class there REPLACES rather than merges and has to restate the centering it is scaling. The filter trigger deliberately does not move with any of it — a pointer sitting on its 24px floor is the §1 "text size and hit area are separate decisions" rule, and a mutation that let it scale to 32px is one of the four this is pinned against.
**A source assertion cannot see this surface, so `sidebar-search.spec.ts` measures the rendered geometry.** Tailwind derives spacing from rem too, so `h-12` is **38.4px** at the 80% root and `text-base` is 11.2px — the figures only exist after the density lever resolves, which is exactly what a class name cannot tell you. Mutation-tested: reverting the width, the row class, the heading step, the slot selector, or freeing the trigger's floor each turns it red.
**A source census that pins a whole JSX line pins its WHITESPACE.** Two tests asserted the literal `"<Command shouldFilter={false}>"`; adding a `className` to that element broke both while the contract they name — the title-only matcher stays off — was untouched. They assert `shouldFilter={false}` now. Pin the token that is the contract, never the line that happens to carry it.
**Search and filtering have one shell-owned controller.** The brand search button and Cmd/Ctrl+K open the existing `FleetPalette`, composed from native Command/Dialog primitives; it and both rails consume the same scoped `filterRows` result. Keep `shouldFilter={false}` on Command so its title-only matcher cannot discard branch/project/ticket matches. The mobile Sheet closes before search opens, and explicit opener capture restores focus because an externally controlled Dialog has no native trigger reference.
**A Grove status report is `phase.note`, not `activity.current_task`.** The latter can be the last human prompt. Fleet cards use the reported phase word when its note is blank, and explicitly state an absent report instead of substituting a prompt. Keep the status text and its phase mark together; agent/runtime provenance belongs in the footer, and quantities in their own ledger.
**The fleet's git counters have different scopes.** `dirty_files` is current uncommitted paths, `diff_*` is branch delta since its diff base, and `base_ahead`/`base_behind` compares the base branch, not the remote upstream. They are not cumulative session edits or push history. `workspace-metrics.tsx` carries their shared rail/card labels and semantic figure tones; unknown queue data is omitted rather than invented as zero.
**A FIGURE IS NEVER THE THING THAT GIVES WAY, and the rail learned this the expensive way.** The session row shipped with eight equal grid tracks so the counters would line up down the column — a real problem, correctly diagnosed, solved by clipping every value it was aligning: `+11.2K` rendered `+11.`, and the marquee built for over-long *names* started scrolling four numbers a second. **An alignment that costs the values it aligns has bought nothing.** The branch yields now — one `min-w-0 flex-[0_1_auto]` branch group before intrinsic attention and phase groups, then a `min-w-0 flex-1` ledger against its `shrink-0` creation age — and the only alignment kept is the one that survives content-sizing, the ledger's cluster flush right. The general rule, which is not about this row: **when a layout has to choose what to sacrifice, sacrifice what can be recovered.** A clipped name is still identifiable from its head and its full text is in the marquee and tooltip; a clipped number is a different number.
**The rail row has three authored lines: title, context, ledger.** `CardShell` supplies its raised body and `surface-header` title band; compact padding and ordinary `text-base`/`text-sm` ramp steps keep navigation quieter than the workspace, without a fixed-height crop. Context is intrinsic branch → attention → phase, with `gap-2` between groups and `gap-1` inside glyph-label pairs. Done remains a labelled phase; phase-blocked is a warning flag independent of destructive agent attention. Never reserve absent marks or use `flex-grow`/`space-between` to manufacture context gaps. Selection emphasis is independent of lifecycle: the visual contract defines its padding and restrained unselected-card tone. The options button alone reserves its own title corner so it cannot cover text. The compact numeric ledger is static and tabular. A single native row tooltip carries all exact values and independent claims for pointer and keyboard readers; nested value triggers would open competing popovers. Creation age is labelled there while ordering remains last activity.
**Density is measured on the built surface.** A reserved `min-h-12` under every status and a two-row metric grid doubled card height without improving scanning. Status and checklist share one mark row; the report is one overflow-readable line; labelled metrics flow horizontally. Do not animate the whole row or move its click target.
**`overflow-text.tsx` holds TWO idioms and the difference is the surface, not a setting.** `OverflowText`/`useOverflowMotion` is a bounded pass over a line you read once — the fleet card. It had a second consumer, the workspace header's status pill, and that pill is gone (see above): a bounded pass is right for a line you stop on, and wrong for chrome you aim at. `LoopingText` is the sidebar's continuous `text • text • …` marquee, and it only loops inside a `ScannedTextScope`; outside one it is an ordinary truncation. **That provider is the scope, which is why `LoopingText` is safe inside shared atoms** — `entity.tsx`'s labels use it, so the rail's group heading loops and the identical label on a card does not. Both idioms measure real overflow, suspend outside the viewport or in a hidden document (never on window blur), and stand down for reduced motion. The rail's rules are in [design-system.md](design-system.md); what belongs here is that a third idiom would be the drift, not a third component.
**Task reports are latest claims, not history — and the BAN survives while its reason has changed.** `PhaseView.note` is the workspace claim; `PhaseView.tickets` carries independent latest ticket notes. Earlier overwritten phase messages are still not on that wire and must still never be reconstructed from transcript prose. What changed is that they are now RECORDED: the engine's durable store captures each claim as the ~1 Hz tick reads it, so `GET /workspaces/{id}/history` is a real source and `WorkspaceHistoryDialog` renders it. **Reconstruction from prose stays forbidden; reading the recorded history does not.** Every workspace predating that store has none, which is why the dialog's trigger does not render at all rather than opening onto an empty panel.
**History merges observations, not inferred transitions.** The pure history adapter interleaves progress, name snapshots and first ticket observations. Stable sorting preserves wire order for equal timestamps without claiming causal precision. Ticket `last_seen` remains metadata rather than another event. The store deduplicates identical claims, so this is not a complete phase transition log. Search and type filters run over all records before the rendering batch is selected.
**Historical vocabulary stays open.** Phase marks reuse the live card's tables, but an unknown recorded phase displays its original text and a generic mark. Neither an unfamiliar phase nor an invalid timestamp may erase a record. A missing current phase must not hide recorded history, including after a workspace has been paused.
**`CardRegion` BAKES IN `flex-col`, so `items-center` without `flex-row` centres a vertical stack.** `tailwind-merge` resolves a conflicting utility, not an absent one — there is nothing for it to override, so the class is simply additive and the row silently becomes a column. Invisible to `typecheck`, `lint:styling` and every unit test; a screenshot of the real page is what showed it, and the row went from ~100px to 36px. **A vendored-or-shared wrapper's own layout classes are part of its contract: read them before passing alignment.**
**An SSR test cannot see inside a Radix dialog, and asserting there passes for the wrong reason.** `DialogContent` mounts only while open and a static render is always closed, so a content assertion is absent for a reason unrelated to whether the content is right — five of them "passed" on a first run by never rendering. The SSR file owns the decision it CAN answer (does the trigger render at all, which is the half with the real failure mode and is pure data) and says so in its own header; it also pins `data-state="closed"` so a later reader does not re-add a vacuous assertion.
**Each claim renders on the card that owns its OBJECT, and getting that wrong is how one ticket ended up on two cards.** `PhaseView.note` is the workspace's own claim and belongs on Task; `PhaseView.tickets[n].note` belongs on the ticket ROW that names that ticket, joined by `provider:id` with no fetch; and the average over `ticket_refs` belongs on the Tickets card, because an aggregate over those rows is not a second workspace phase. The Task card previously carried all three, which put a `Ticket reports` list directly above a Tickets card listing the same tickets — and the note lived only in the copy nobody scrolled to. **The tell is a card rendering a claim about an object it does not name.** The task track uses the card's own named query container; panel width, not viewport width, decides when six checkpoint labels fit — and only the LABELS collapse, never the track (design-system §6).
## 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, `ageToken` for the same age with the tense dropped where a glyph beside it already says "time" and the cell is ~17px wide, `PreciseAge`/`preciseAge` (two units, up to years) for one opened deliberately to READ, `Uptime` for a duration still accruing. Separate functions rather than a `precision` prop, per the design system's §3 rule — and `ageToken` earns its place on width rather than taste: `"2h ago"` in the rail's created-age track does not fit, so the suffix is the difference between a value that sits still and one that has to scroll to be read. 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.
- **`app-logo.tsx`** — the same logo on its TILE, and the pair splits on GROUND rather than on size. `BrandMark` is correct wherever Grove's own surface is already behind it (rail, print header, beside the wordmark); `AppLogo` is correct where the logo stands alone and must supply its own backdrop — the landing welcome block, the login front door, and the landing route's `loading.tsx`, which must draw the same one or the skeleton visibly flips logo when the real surface arrives. It points at `public/icon-512.png`, the *same file* the manifest and `app/icon.png` ship, because the squircle's curve and the tile's cream are the icon pipeline's values and a second hand-drawn copy is how one picture becomes two.
**A PATH COPIED WITHOUT ITS `fill-rule` IS A DIFFERENT PICTURE, AND EVERY GATE STAYS GREEN.** The mark is one path whose spokes, rim and hub are interior subpaths, so under the SVG default (`nonzero`) all of them fill and the wheel renders as a solid terracotta octagon — the wrong logo, in the rail, the login page and the landing page at once, for as long as nobody compared it to the favicon. Nothing could catch it: the `d` attribute is byte-identical to the correct artwork, so a source census over the path data passes; there is no class for `lint:styling` to see; and `fill-rule` has no visual analogue in the file that would make its absence look like an omission. **The general shape is that an SVG attribute can be load-bearing geometry rather than styling** — when lifting path data out of an asset into JSX, diff the *element's attributes*, not the `d`. It is pinned in `tests/unit/brand-mark.test.tsx` as a rendered-output assertion plus a cross-artifact census against `docs/img/grove-logo.svg` (mutation-tested: dropping the prop turns exactly that one test red).
+ **The mark's WEIGHT is a stroke on the fill path, not a redrawn outline, and `tools/app_icons.py` renders every destination from that one file.** The wheel is hairline at small sizes (rim and spokes ~10 units in a 409-unit box), and doubling it by stroking the same path in the same colour dilates each edge by half the stroke width — so `stroke-width="10"` gives ~20 while `d`, the silhouette and the centre are untouched. That is what keeps `brand-mark.tsx` and `docs/img/grove-logo.svg` one picture across a weight change; redrawing the path would have made them two. `stroke-linejoin="round"` is load-bearing (the default miter spikes the spokes' acute tips at this width), and the stroke has the same silent-drift exposure as `fill-rule` — a component that lost it renders a *thinner* logo beside icons rasterized from an SVG that kept it, which no diff shows. Same cross-artifact census in `tests/unit/brand-mark.test.tsx`, mutation-verified on both arms independently.
+
+ **A `MARK_FRACTION` is about INK, and a viewBox is not its ink.** The artwork draws to 91.6% of its own box and is not centred in it, so scaling the raw render by 0.705 seated the mark at 0.641 — visibly smaller than the tile it replaced, from a constant asserting otherwise, and a defect no test and no green gate can see. The renderer crops to `getbbox()` and re-pads to a square (squaring at the crop, not at the seat, or the resize stretches the aspect) before anything scales it. Measure a regenerated tile's mark span and centre against the set it replaces; "it looks right" does not catch 9%.
+
+ **The two tile shapes are a SPEC requirement and four shipped files had it backwards.** A standard icon is composited as-is so its corners must be cut; a maskable one is cropped by the launcher to a shape it chooses, so it must be FULL BLEED — transparent corners are holes wherever the launcher's mask is wider than our radius — with the art inset into the 80% safe circle instead. **The two masks are different shapes, so the safe test is not shared:** Android guarantees a CIRCLE of 80% diameter, so what binds a maskable is the ink's max *radius* from centre, while iOS rounds to a SQUIRCLE at ~22.4% of the side and cuts far less. Verifying `apple-icon` against Android's circle reports a failure on a tile no iOS device would clip — which is exactly what happened when the mark was enlarged, and it cost a round trip before the right mask was measured.
+
+ **The mark spans 0.846 of a standard tile and 0.677 of a maskable, and the pair is not one number scaled twice.** Both were raised 20% from the set's original 0.705/0.564, which left the wheel floating in cream and reading small in a dock. The standard tile is bounded by its straight edges (~7.7% margin at 0.846) because the rounded corners cut the diagonal, where the wheel's silhouette is already inset. The maskable is bounded by radius instead: its nodes sit ~0.461 of the span from centre, so 0.677 puts the outermost ink at 0.340 against the 0.400 limit. Raising it to match the standard tile would put that at ~0.390 and a circular launcher would clip the rim nodes off. `apple-icon` is full bleed for a different owner's reason: iOS rounds it itself and composites onto BLACK, so a transparent corner is a black corner on the home screen. Both maskables and the apple icon shipped with cut corners before the generator existed. The tile's own curve was *recovered* rather than chosen — sweeping the radius against `icon-512.png`'s alpha minimises at 89/512, error 0.0015 — because a regeneration should not also be a redesign.
+
+ **Quantize these PNGs; the set already was.** The tiles this replaced held 9 unique colours, so writing truecolour is a silent ~2x weight gain on files served on every page load. The art is two flat colours, so a 64-colour octree palette (Pillow offers only octree/libimagequant for RGBA) with NO dithering measured 18.0 KB against 42.7 KB at a mean 0.93/255 on `icon-512.png`; dithering a flat two-colour mark adds noise to solve banding it does not have. Every regenerated icon came out *smaller* than the file it replaced.
+
**The icon artwork has ONE source and several destinations, so adding a surface means shipping the file, not redrawing it.** `app/favicon.ico` (a real multi-size ICO: 16/32/48/64), `app/icon.png`, `app/apple-icon.png`, the manifest's four `public/icon-*.png` entries and the docs site's `docs/logos/grove-logo.png` are all the same tile. **A bare `.svg` at `app/icon.svg` OUTRANKS the `.ico` in every modern browser**, so a tab showing the wrong mark while `favicon.ico` is correct means the SVG is the thing being served — that file is now gone deliberately, and re-adding one silently takes the tab back. `purpose: "maskable"` is a separate manifest entry rather than a flag on the standard pair: Android crops a maskable icon to the launcher's shape, so its art is inset for a safe zone and looks small and lost wherever it is *not* cropped. Both must exist.
**One mark must not describe two axes.** The sidebar formerly chose its glyph from phase but its tooltip and red hue from agent attention, so a red half-circle meant implementing visually and waiting verbally. Attention and phase now render separately, each with its own label/title and token-table Lucide icon; phase blocking is a warning flag, not agent-state blocked. `phaseGlyph` returns the shared icon component, never a font-dependent character. Render only the marks reported; do not reserve empty mark slots or invent a placeholder for an absent claim. Tooltip values belong to the real glyph/control with keyboard reachability; do not duplicate them with `title`.
**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, or SAY WHICH ONE IT IS SHOWING.** The rail orders rows by `lastActivityIso` — the newest of the sessions' `last_event_at`, `updated_at` and `created_at` — and `lastActivityAt` derives from it so the two cannot drift. What the row's age cell now renders is the workspace's **creation**, by explicit request, which makes the first half of that rule false here on purpose: the ordering was deliberately NOT changed to match, because newest-created-first was proposed on a design board and never approved, and quietly re-sorting a navigation rail as collateral to a visual change is the larger harm. **The obligation the rule was protecting is discharged by attribution instead** — the cell reads `Created: …`, its `title` carries the exact instant, and the row's own tooltip states creation *and* last update. An age printed beside an order it does not explain is a broken clock; an age that names itself is a second fact. If the sort is ever revisited, revisit it as its own change with its own approval.
**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.
**The expanded footer is two destination columns, then identity and settings together, then a separated service baseline.** Fleet and Usage share the first row; version and uptime are quieter properties of that footer, not a third destination row. The collapsed gutter is coarse-pointer-safe and preserves the 44px target.
**One age in the rail, both in the Info tab — and the rail's one is now CREATION.** The half of this rule that held is the budget: two equal-weight timestamps cost a third line on every row, on the narrowest surface in the app, so the row shows exactly one and the other lives in its tooltip. Which one it shows was reversed on request; see the sort rule above for why the ordering did not move with it, and why the cell has to name itself as a result.
<!-- 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 -->