---
name: companion-ui
description: >
  Vis Companion UI rules for apps/vis-companion — the closed ui.tsx control
  vocabulary, className-only-positions, tokens, header bands, touch density,
  fleet model, the dev server, RENDERED-and-ATTACHED design proposals, and
  proving a shipped screen with numbers off the live DOM. Use for ANY edit,
  review, design or proof of a Companion screen, component or style.
---

# Vis Companion UI

Repo-wide rules stay in the root `AGENTS.md`; this is the Companion's own
doctrine, pulled only when the work touches a screen.

## Companion web dev server

- For Companion development and browser inspection run exactly `npm run dev` from `apps/vis-companion` — never Vite directly, never hand-copied gateway URLs/tokens. It discovers every live entry in `~/.vis/gateway/registry/*.edn`, maps wildcard binds to loopback, and seeds both Companion storage mirrors before boot. Its token-bearing page defaults to `127.0.0.1`; pass `--host` only when deliberately exposing it. This is not permission to bypass `gateway-client/request!` for direct API calls.
- Report the URL only after it responds. **Reuse an already-running server** serving that app: a healthy user-requested dev server is persistent user infrastructure, not a disposable test process. Stop it only when the user asks, it is unhealthy, or it must be replaced — never as automatic REPL-lifecycle cleanup, and if a higher-level policy demands a stop, state that conflict first.

## Companion UI (`apps/vis-companion`)

One responsive web/iOS/Android product: phone and desktop, touch, overflow, safe areas, virtual keyboards, both themes. These rules say what the screen should FEEL like. The figures that prove a screen obeys them are never written here — they are read off the live DOM (see **Proving SHIPPED companion UI**) and pinned in `ui.test.tsx` and `scripts/touch-density.test.mjs`.

- **The control vocabulary is CLOSED and `src/components/ui.tsx` owns it** — `Button`, `IconButton`, `KebabButton`, `NewSessionButton`, `MachineProjectsButton`, `DialogClose`, `Modal`, `DialogFrame`/`DialogHeader`, `Menu`/`MenuHeading`/`MenuItem`, `SectionHeader`, `HeaderTitle`, `HeaderActions`, `HeaderMeta`/`HeaderTally`, `LiveCount`, `MachineRail`/`MachineMark`, `MachineSwitcher`/`MachineTab`. Use them by name; never hand-roll a `<button className="…">` that resembles one. Two controls that mean the same thing must never look like two different things. Grep `ui.tsx` first; at the second call site extract it in the same commit and pin it in `ui.test.tsx`.
- **A band's way out is the band's own CELL, not a mark floating in it.** `CloseButton isBand` is the ✕ that ends a header band (`DialogHeader`, `MenuHeading`): it stretches to the band's full height and stands as wide as the band is tall, so the hairline that welds it runs the whole band and the one gesture that leaves a screen is a 48×48 target. Every band that hosts it is therefore `min-h-12 mouse:min-h-9`. The same ✕ without `isBand` is the 32px MARK inside another control — a composer chip, a queued row, the search field's Clear — and that is the only other box it has. An opened surface that carries a ✕ carries the dialog BAND too, tone included; a ✕ standing on paper is the app's one white button and the report that follows will say so.
- **`className` at a call site may only POSITION** (`absolute`, `flex-1`, `shrink-0`, a grid cell) — anything you can SEE is a prop or a variant on the component, because competing utilities are settled by Tailwind's emission order and never by which call site typed them. Spacing belongs to the owner, outer edge included: `HeaderActions` pads the header's trailing side, so the header does not. `ui.test.tsx` scans every screen for a paint utility handed to a `ui.tsx` control and fails on it, so the rule is a test rather than a memo.
- **Tokens only.** Tailwind v4 utilities — no component CSS, CSS modules, CSS-in-JS or inline styles — and only the scale `text-chip`, `text-meta`, `text-ui`, `text-body`, `text-title`, `text-subhead`, `text-head`, `text-display`; no ad-hoc sizing, no `leading-*`.
- **A header is a BAND, and the list has one of it.** Machine header, project header and the loading skeleton are all `SectionHeader` — `tone` is the only difference, so scrolling past them feels like one rhythm rather than a stack of near-misses. The band owns its own height and paper; a control inside centres itself and never sets the row height, and the band never pads around a control that is already a full touch target. Neither side edge is spelled on the band: the leading one belongs to `HeaderTitle` so a name's hover reaches the screen edge, the trailing one to `HeaderActions`. A header REPORTS through `HeaderMeta` and counts through `HeaderTally` — never a fixed column inside the title, which starves the name the user actually came for to keep a tally whole. **A `HeaderTitle`'s qualifier rides UNDER its name, never beside it** — the trailing cluster takes its width first, so a name and the path that tells two checkouts apart were rationing one leftover line: the name capped at 60% of it and the path truncating mid-token (`~/vis/apps/vis-c…` on a 390px phone). Two lines, each truncating in the full column, and the loading skeleton stands in for both or the band grows by a line the moment data lands.
- **A field is tall enough for the line it holds.** Text must never be clipped inside its own box, and the box must not jump on the first keystroke.
- **An iPad is a wide TOUCH device, so density follows the pointer, never the width.** `sm:` answers "is there room" and owns LAYOUT — which columns exist, where the tab bar goes, how wide the paper is. Only `mouse:` (defined in `src/index.css`) may make a control TIGHTER, because only a cursor earns that rhythm; a trackpad earns it too. A wide screen may breathe — what it may never do is shrink what a finger has to hit. `scripts/touch-density.test.mjs` reads the component tree and fails on a violation.
- **A machine owns its projects.** The sessions list is a fleet of paired gateways (`src/lib/fleet.ts`): machine → project → session, and the same repo on two machines is two projects. Never merge rows by folder name across machines.
- **The cog is the door to the FLEET, and settings is one dialog with the machines first.** `SettingsDialog` opens on `Machines` — every paired machine as a `MachineRows` row (name, address, live verdict, `PRIMARY`/`CURRENT`), then `Add a machine` (pairing link, QR, URL + token), then the read machine's own panels — with `Application` (theme, page size) as the second column, stacked under it on a phone. Both machine pieces are `components/Machines` and are shared with `ConnectScreen`, which is now only the page for a device with nowhere to go (nothing paired, or the active gateway stopped answering); `useFleetHealth` is the one probe behind both. **Nothing about a machine is reached by leaving the dialog** — a verb that closes settings to navigate to a screen the app bar has no door to is the bug that was reported ("this should open when I click the cog").
- **The row above the list holds the switch and the machine's one verb, and the list card holds no chrome of its own.** The scope switch answers "which machine" **once** and narrows every count to that gateway; the machine's own control (amber, `MachineProjectsButton`) stands at the other end of the same row, so the card starts at the first project header. **The switch is ALWAYS rendered, a fleet of one included** — the tile is not only a choice, it is the LABEL of everything under it, in the machine's own hue and in the same place whatever the fleet size; hiding it made a solo user's list belong to nobody and made pairing a second machine rearrange the screen. There is **no `⋯` on this row**: the overflow held only `Manage projects` (the sheet that control opens) and `Machine settings` (the Machines tab and the app bar's cog). Nothing reclaims the gutter, so the switch's left edge and the verb's right edge keep the same inset. While a query is live that row reports the search (`searchTally`); nothing else is chrome.
- **`All` LEADS THE STRIP above a fleet, and it is the only view where the palette means anything** (`SCOPE_ALL` in `src/lib/fleet.ts`). A machine owns a hue, a rail and a section; scoped to one gateway, exactly one of those hues can be on screen, so a fleet of six painted one colour and the reader's report was "everything is the same colour". `All` stacks one named SECTION per machine, each under its own rail, and it is the default the moment a second machine is paired. A fleet of one never renders the tile — "every machine" and "this machine" would be the same list under two names — and the resolved scope of a solo fleet is always that machine. **A machine band exists in the fleet view and ONLY there**: `SectionHeader` with the machine's hue as its outgoing `rule`, `MachineMark size="banner"` in the glyph column, the address under the name, its shown tally, and its own `MachineProjectsButton`; scoped, the strip has just named the machine and a band repeating it is the second header this list was reported for. A machine that is not answering has no section here at all. The row above the card carries that control only while the list is scoped, because in the fleet view it speaks for no single machine. In the fleet view the search tally also says WHERE the query went — "across 2 of 3 machines".
- **ONE MEANING, ONE MARK — and a control that repeats is a MARK, not a word.** The screen carries three verbs and they must never share a glyph: the bare **plus** is a session in this project (`NewSessionButton`), the **folder** is that machine's project inventory (`MachineProjectsButton` — it is NOT a create; `openManageProjects` lands on `Projects · <machine>`, and the sheet's own foot holds `New project…`), the **folder with a fork in it** is that session in a draft. A word repeated once per project header was nine amber paragraphs of the same eleven characters and cost 83px of a 390px row whose name and path had 221px to share; the mark is 37px, the rank is unchanged (same amber, same compact box, same 44px touch target), and the name lives on `aria-label` and `title`. Two exceptions, both because a mark has nothing to say there: a create IN FLIGHT becomes the word button again on the one header that was pressed (`busyLabel`), and a machine whose section is empty carries `face="word"`, since it has no project row on screen to teach what the folder opens.
- **"New session" is a yellow BUTTON on every project header, immediately before that header's `⋯`** (`NewSessionButton`, inside `HeaderActions`): the verb the screen exists for costs no menu. It creates in the project whose header it sits on and sends that group's canonical workspace root, never the home-shortened display path; it NAMES its machine (`aria-label`) and puts the project on its `title`, because several machines are on screen at once. An unreachable machine offers no create button (`creatableMachines`). The draft half of that same split button asks the workspace question, and when `newSessionTarget` is `null` the menu asks "Create the session on" BEFORE it, since a workspace only exists on a machine — picking there ANSWERS which machine (`startFlowPick`) and the menu's Back takes that one answer back (`startFlowUnpick`) instead of ending the order.
- **A MACHINE THAT IS NOT ANSWERING IS NOT A PLACE TO GO — it is DRAINED, and its tile is the retry.** `SCOPE_ALL` is every machine that ANSWERS (`scopedMachines`), so a dead gateway has no section, no band and no rows in the fleet view: it was painting a named band whose whole content was its own failure in the middle of a working list, under a tab wearing the word "offline" — the only label on that strip that GREW when its machine got worse. Its `MachineTab` stays (a machine you paired never vanishes from the row that lists your machines) with `isDown`: hollow hue, hint ink, never the raised tile, no word, no `aria-pressed`, and the name plus the transport's own reason on `title`. `resolveScope` refuses to scope to it and falls back to `All`, including a machine that dies under the reading thumb. **The press is the one thing that machine can still do**: it retries THAT gateway and answers in the tile that was pressed — `reconnecting...`, then `no answer` if it came back dead, and nothing at all before the first press. Nothing answering at all is not an empty list: `fleetError` hands the whole screen to the shell's offline gate, which is the screen that can say which gateway failed and why.
- **A REACHABILITY VERDICT HAS AN AGE, and a memory is never painted as a fact** (`useFleetHealth` / `healthView` in `src/components/Machines.tsx`). Every `GwHealth` carries `at`, and `isFresh` (one sweep period + one probe deadline) decides whether the dot, the latency and the reason may be shown at all; past that the row says `Checking…` and prints nothing it measured. A closed laptop stops answering without telling anyone, so the last thing this app measured stays literally true and completely wrong — reported as "why are we telling it's green if it's not". The module-level `lastHealth` is a head start across mounts, never evidence. **The sweep must not be gated on `document.visibilityState`**: a resumed Capacitor webview keeps saying `hidden` while the reader watches the dots (see `src/lib/wake.ts`), so the probe interval runs unconditionally and `onWake` probes immediately — a frozen app runs no timers, which is what actually spares the battery. Recovery (`onRecovered`, the offline gate) is declared by a probe that answered, never by a remembered one.
- The filter is a FLEET question: title/project matching and server-side transcript search both fan out over `scopedConns`. While a query is live the row above the list reports the search (`searchTally`) — "178 matches across 2 of 3 machines" is the only proof the query left this gateway, and a machine with no hit says "No matches on this machine", never "No sessions yet".
- Verify with `npm run lint` and `npm run build` here. Never edit generated `ios/` or `android/`; native behavior goes in the idempotent `scripts/ios-prepare.mjs` / `scripts/android-prepare.mjs`.

## Companion UI proposals are RENDERED and ATTACHED (HARD RULE)

**A design proposal is a real, RESPONSIVE Tailwind page attached to the answer as an `.html` file with `vis_attach`. Never a PNG, never a screenshot, never ASCII, never in the application.** A picture cannot be resized, so it cannot prove a layout; box-drawing characters cannot show a token, a weight, a radius or a touch target. One `index.html` per answer holds every numbered version, and the human opens it and drags the window.

- **One shared `index.html`, attached as HTML.** `vis_attach(path, filename="index.html")` — the same filename every time, so each revision becomes that proposal's next version. Never attach a rendering as an image, and never split versions across files.
- **IMPORT the product, do not re-type it.** The proposal is a React entry that imports `apps/vis-companion/src/components/ui.tsx` and `src/index.css` DIRECTLY, built by Vite into one self-contained `index.html`. A hand-written copy of a class string is a fork of the design system that drifts the moment `ui.tsx` changes, which is exactly how a proposal ends up not looking like the app. The recipe, in a scratch dir outside the tree (e.g. `/tmp/vis-mock`): `node_modules` symlinked to `apps/vis-companion/node_modules`; a `mock.css` that is `@import '<app>/src/index.css'; @source '<app>/src'; @source './main.tsx';` — **both `@source` lines are required**, or Tailwind emits nothing for `ui.tsx` and every control renders unstyled; a `vite.config.mts` with `react()` + `tailwindcss()`, `assetsInlineLimit` huge and `cssCodeSplit: false`; then inline the built CSS/JS into `dist/index.html` and attach that one file. Only exported members are available (`LIST_EDGE`, `LIST_EDGE_END`, `LIST_FRAME` are; `HEADER_BAND`, `LIST_TRAIL` are not) — compose with the exported components rather than re-typing a private constant.
- **Responsive BY DEFAULT, phone first.** The page is authored at 390px and must stay correct — no clipping, no horizontal scroll, no shrunken touch target — from 320px up through desktop, using the product's real `sm:` and `mouse:` rules. Versions stack in one column on a phone and sit side by side on a wide screen; a fixed-width mockup canvas is a rejected proposal. Check it at 320, 390, 768 and 1440 with `spel set viewport` before attaching.
- **Zero repo files.** The proposal HTML lives in a scratch directory outside the tree; it is never committed, never a variant component, a dev route, a gallery, a fixture screen or a screenshot script. `spel` is only for CHECKING it (`spel open`, `set viewport`, `set media dark`, `eval-js`); the artifact reaches the human through `vis_attach`.
- **Use the components by NAME, with their real props** — `SectionHeader`, `HeaderTitle` (`name`/`qualifier`), `HeaderActions`, `HeaderMeta`/`HeaderTally`/`LiveCount`, `NewSessionButton` (`machine`/`where`/`onPress`), `KebabButton` (`label`/`isOpen`), `MachineSwitcher`/`MachineTab`/`MachineRail`/`MachineGap` (rail colour from `lib/machine-colors`), `ListRow`, `Modal`/`DialogFrame`/`DialogHeader`, `Button`, `BackButton`, `Pager`. A control invented for the mockup is a proposal about a product that does not exist; a variant that does not exist yet is a NEW component named in the answer, written beside them in the same file, and it is the thing the human is accepting. `className` at a call site still only POSITIONS.
- **The mock is INTERACTIVE — every control does the thing it claims.** A dead picture in HTML is still a picture. React state drives it: a session row PUSHES the detail screen (with a Back that returns), `NewSessionButton` opens the real `Modal` naming which machine and which canonical workspace root it would send, `KebabButton` opens its menu with the actual items, a `MachineTab` scopes and says what scoping changes. Verify every path with `spel eval-js "…element.click()"` and check `spel errors` before attaching — and read the DOM in a SEPARATE `eval-js` call after the click, because a React state flush is not visible in the same synchronous expression that dispatched it.
- **Every version frame SCROLLS, and holds enough rows to prove it.** A screen the human cannot scroll is a still life: each version renders inside its own fixed-height scroll viewport (`h-[600px] overflow-y-auto overscroll-contain`), filled with enough sessions and projects that `scrollHeight > clientHeight`, so sticky bands, the leading edge under scroll and the bottom of the list are all inspectable. Report both figures per viewport.
- **The PAGE must scroll too, and the app's own stylesheet stops it.** `src/index.css` locks the product to a fixed app shell (`html, body { height: 100%; overflow: hidden }`), so a proposal that imports it shows ONLY the first version on a phone and every later version is unreachable. `mock.css` unlocks the page — `html, body, #root { height: auto !important; min-height: 100% !important; overflow: visible !important; position: static !important }` — in the scratch file, never in the app. Prove it per viewport with `document.scrollingElement.scrollHeight > innerHeight`, and report the document height beside each frame's two figures.
- Always offer SEVERAL numbered versions, each rendered in the state that can falsify it: one machine paired, a machine not answering, a long name, a project with no sessions.
- Nothing is written to the repo until the human picks a version; then it is built once, in the real screen, with its tests. Proof of the SHIPPED screen is still numbers off the live DOM (see the next section) — a proposal page is never evidence about shipped code.
- Legacy `src/dev/**` design-gallery files are not to be extended — delete them when a change brings you into that code.

## Proving SHIPPED companion UI

Proof is about the screen that ships, never about a proposal. Run the real app with `npm run dev` and inspect it.

- **`spel` is the ONE browser instrument, and every step of a proof is one of its subcommands** — `spel open`, `spel set viewport 390 844` / `set device`, `set media dark`, `snapshot -i -c`, `get box`, `eval-js`, `errors`. Never bring a second driver to the same screen (a hand-written Playwright/Puppeteer script, a browser extension, a one-off headless Chrome): a figure produced by a tool the next reader does not run is a figure nobody can reproduce.
- Drive by **`@ref` from `spel snapshot -i -c`**, not `find role button --name`: name matching is a SUBSTRING match, so `--name 'New session'` also matches `aria-label="Choose which machine the new session runs on"` and the ambiguous click silently does nothing. A control with no name in the snapshot is a real accessibility bug — give it an `aria-label` rather than working around it.
- When a click seems to do nothing, confirm against `spel eval-js "…element.click()"` before blaming the app: the DOM path proves whether the handler or the locator is at fault. Read `spel errors`, not `spel console` (which dumps every entry ever captured).
- **A layout claim is proven by NUMBERS, never by looking at a screenshot.** Every geometric assertion — an edge, a gutter, an indent, a hit box, a baseline, an overflow — is read out of the live DOM with `spel eval-js` and `getBoundingClientRect()` (`spel get box <sel>` when one element is the whole question), once per viewport that decides the layout, and REPORTED as the figures. Measure after fonts load and after any open/close transition settles. A screenshot may illustrate the figures; it is never the evidence for them:

  ```
  spel --session s eval-js '(() => {
    const at = (sel) => { const r = document.querySelector(sel)?.getBoundingClientRect();
      return r && { l: Math.round(r.left), r: Math.round(r.right), w: Math.round(r.width), h: Math.round(r.height) }; };
    return JSON.stringify({ kebab: at("button[aria-label^=Actions]"), row: at("[data-session-id]") });
  })()'
  ```

  What that catches and an eye does not: two trailing controls ending at 378 and 390; a name at x=28 above a name at x=36; a 44px thumb target that quietly became 32; a panel whose `top + height` exceeds `innerHeight`, which is a primary button below the fold.
