# @cat-factory/app

> Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an

Latest version **0.321.5** (published 2026-10-20) · 0 weekly downloads

## Install

```sh
npm install @cat-factory/app
pnpm add @cat-factory/app
yarn add @cat-factory/app
bun add @cat-factory/app
```

## Health

**Score 65/100 (B)** — status: active.

Positive: esm support; no vulnerabilities; has provenance; recently updated; high maintenance score.

Warnings: low downloads; no types; large bundle; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.321.5 |
| Published | 2026-10-20 |
| First published | 2026-06-17 |
| Weekly downloads | 0 |
| TypeScript types | none |
| Module format | ESM + CommonJS |
| Dependencies | 24 |
| Unpacked size | 12.1 MB |
| Known vulnerabilities | 0 |
| Install scripts | yes |
| Provenance | attested (GitHub Actions) |
| GitHub stars | 7 |
| Maintainers | kibertoad |

## Links

- npm: https://www.npmjs.com/package/@cat-factory/app
- Repository: https://github.com/kibertoad/cat-factory
- npm.io page: https://npm.io/package/@cat-factory/app

## Dependencies (24)

- [vue](https://npm.io/package/vue.md) 3.5.44
- [pinia](https://npm.io/package/pinia.md) ^4.1.0
- [wretch](https://npm.io/package/wretch.md) ^3.0.9
- [valibot](https://npm.io/package/valibot.md) ^1.5.0
- [@nuxt/ui](https://npm.io/package/@nuxt/ui.md) ^4.12.0
- [@nuxt/fonts](https://npm.io/package/@nuxt/fonts.md) ^0.14.0
- [@pinia/nuxt](https://npm.io/package/@pinia/nuxt.md) ^1.0.3
- [markdown-it](https://npm.io/package/markdown-it.md) ^15.0.3
- [@nuxtjs/i18n](https://npm.io/package/@nuxtjs/i18n.md) ^10.6.0
- [@vueuse/core](https://npm.io/package/@vueuse/core.md) ^15.0.0
- [@vue-flow/core](https://npm.io/package/@vue-flow/core.md) ^1.48.2
- [@modular-vue/vue](https://npm.io/package/@modular-vue/vue.md) ^1.4.1
- [@modular-vue/core](https://npm.io/package/@modular-vue/core.md) ^1.5.0
- [@modular-vue/nuxt](https://npm.io/package/@modular-vue/nuxt.md) ^0.4.1
- [@modular-vue/runtime](https://npm.io/package/@modular-vue/runtime.md) ^1.4.1
- [@toad-contracts/core](https://npm.io/package/@toad-contracts/core.md) 1.0.0
- [@vue-flow/background](https://npm.io/package/@vue-flow/background.md) ^1.3.2
- [@modular-vue/journeys](https://npm.io/package/@modular-vue/journeys.md) ^1.4.0
- [@cat-factory/contracts](https://npm.io/package/@cat-factory/contracts.md) 0.374.0
- [@modular-frontend/core](https://npm.io/package/@modular-frontend/core.md) 0.6.0
- [@vue-flow/node-resizer](https://npm.io/package/@vue-flow/node-resizer.md) ^1.5.1
- [@toad-contracts/valibot](https://npm.io/package/@toad-contracts/valibot.md) 1.0.0
- [pinia-plugin-persistedstate](https://npm.io/package/pinia-plugin-persistedstate.md) ^4.7.1
- [@toad-contracts/frontend-http-client](https://npm.io/package/@toad-contracts/frontend-http-client.md) 1.0.1

## Recent versions

- 0.321.5 (latest) — 2026-10-20
- 0.321.4 — 2026-10-19
- 0.321.3 — 2026-10-19
- 0.321.2 — 2026-10-19
- 0.321.1 — 2026-10-18
- 0.321.0 — 2026-10-18
- 0.320.0 — 2026-10-17
- 0.319.2 — 2026-10-17
- 0.319.1 — 2026-10-16
- 0.319.0 — 2026-10-16
- 0.318.0 — 2026-10-16
- 0.317.2 — 2026-10-16
- 0.317.1 — 2026-10-16
- 0.317.0 — 2026-10-16
- 0.316.0 — 2026-10-16
- … 611 more at https://npm.io/package/@cat-factory/app/versions

## README

# `@cat-factory/app`: Frontend (Nuxt layer)

The user-facing app, packaged as a **reusable Nuxt 4 layer**: a single-page app
that runs entirely in the browser and renders the architecture board, drives agent
pipelines, and reflects live execution. A deployment consumes it via
`extends: ['@cat-factory/app']` (see [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tree/main/deploy/frontend)).
It talks to the [backend Worker](https://github.com/kibertoad/cat-factory/blob/main/backend/README.md) over REST and a single
WebSocket, sharing wire types from
[`@cat-factory/contracts`](https://github.com/kibertoad/cat-factory/tree/main/backend/packages/contracts).

The SPA source lives under `app/` (the Nuxt srcDir).

## Table of contents

- [What it is](#what-it-is)
- [Tech stack](#tech-stack)
- [Layout](#layout)
- [Nuxt UI: agent tooling and overrides](#nuxt-ui-agent-tooling-and-where-the-spa-overrides-the-skill)
- [Task swimlanes](#task-swimlanes)
- [Queue view](#queue-view)
- [Roles (engineer / product manager / designer)](#roles-engineer--product-manager--designer)
- [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
- [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
- [In-app tutorial tours](#in-app-tutorial-tours)
- [Real-time store coherence](#real-time-store-coherence-avoid-the-full-refresh-clobber)
- [Internationalization (i18n) authoring](#internationalization-i18n-authoring)
- [Extending the layer (consumer modules)](#extending-the-layer-consumer-modules)
- [Key UI surfaces](#key-ui-surfaces)
- [Develop & test](#develop--test)

## What it is

A spatial planning surface. You lay out a system as a **board** of frames
(services), modules and tasks on a [Vue Flow](https://vueflow.dev) canvas, wire up
dependencies, attach requirements, and apply **agent pipelines** to blocks.
Execution streams back in real time (step/subtask progress bars, decision
prompts, failures with retry) so the canvas doubles as a live dashboard. The board's second view,
the [queue](#queue-view), lists the same tasks by what each one needs.

It is a thin client: there is **no business logic here**. Every mutation calls the
Worker API and the stores hydrate from server snapshots and live updates pushed
over the WebSocket. How that sync works is written up in
[`app/docs/architecture.md`](./app/docs/architecture.md).

## Tech stack

- **Nuxt 4 / Vue 3** SPA: the board and its queue view are `pages/index.vue`, beside three
  standalone routes (`mcp-authorize.vue`, `mcp-oauth-callback.vue`, `reset-password.vue`).
- **Pinia** (+ `pinia-plugin-persistedstate`): feature stores.
- **Vue Flow** (`core`, `background`, `controls`, `node-resizer`): the canvas.
- **Nuxt UI** + Tailwind: components and styling.
- **VueUse**: composable utilities.
- Lint/format via **oxlint** + **oxfmt**; tests via **vitest** + **happy-dom**.

## Layout

| Path           | Contents                                                                                                                                                                     |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app.vue`      | Root; wraps the page in `AuthGate`.                                                                                                                                          |
| `pages/`       | The board (`index.vue`: sidebar, canvas, toolbar, inspector, focus view, all modals), plus the standalone `mcp-authorize`, `mcp-oauth-callback` and `reset-password` routes. |
| `components/`  | UI grouped by area (see [Key UI surfaces](#key-ui-surfaces)).                                                                                                                |
| `composables/` | `useApi` (typed client), `useWorkspaceStream` (WebSocket sync), `useBlockDrag`, `useBlockQueries`, `useBoardFlow`, `useSemanticZoom`, `useDepLabels`.                        |
| `stores/`      | Pinia stores, one per feature domain.                                                                                                                                        |
| `types/`       | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts.                                                                                               |
| `utils/`       | Small pure helpers.                                                                                                                                                          |

### The board's top overlay region has ONE owner

**A surface that appears at the top of the board renders as a member of `BoardTopOverlays`, and places nothing itself.** No `absolute`/`fixed`, no `top-*`, no z-index of its own: the band is a flex column, and it owns placement and stacking for everything in it. A member contributes only its card plus `pointer-events-auto` (the column is click-through, so its empty strip never intercepts clicks on the board underneath).

The reason this is a rule is that the alternative failed exactly once per surface. Each of the toolbar, the spend/connection/PAT banners and the four advisory banners used to anchor itself at `top-0` with its own z-index, so which one you could see came down to who picked the higher number: a standing advisory covered the zoom and fit controls outright, and the board-basics tour then ringed a control nobody could see. Tuning an offset onto one of them (`top-16`, sized against the toolbar pill) fixes the pair that was noticed and leaves the rest, and it goes stale the first time the pill wraps or grows a scrollbar. In one column the overlap is not tuned, it is unrepresentable, and a toolbar that grows pushes the banners down by exactly what it grew.

Order within the column is by what the user loses by not reading it now; the toolbar stays first so a tour anchor and the everyday zoom controls do not move as advisories come and go. Full-width page chrome (the translation-warning strip) is a different surface: it sits in NORMAL FLOW at the top of the shell, so it takes its own height rather than covering the row beneath it.

`app/components/layout/BoardTopOverlays.spec.ts` enforces the no-self-placement half, reading the member list from the component's own imports.

### A board driver that MEASURES the DOM runs off the activity pulse, never a bare RAF

Two board features cannot be derived from the stores alone: the dependency-edge overlay needs
each card's on-screen rectangle, and the task-expansion driver needs the topmost card under the
pointer. Both used `useRafFn`, so an open board paid O(edges) `querySelector` plus forced layout
reads sixty times a second with nothing moving, and the edge overlay reassigned an
equal-but-new segment array every frame on top of that.

**A new driver of that kind pairs `useSettlingRaf(compute)` with the canvas pulse
(`useBoardActivity`), and `compute` reports honestly whether it changed anything.** The pulse
answers "something may have started moving" (DOM mutations under the canvas, its resize, the
Vue Flow camera, pointer/wheel/scroll gestures) and the settling loop carries that wake through
the animation that follows, parking once the output has held still for a few frames. Neither
half works alone: a signal fires one frame BEFORE the transition it starts has any geometry, and
a bare frame loop never stops.

**The pulse does not treat its signals alike, and a driver must not assume it does.** What the
user is moving (pointer, wheel, scroll, resize, the camera's own `pulse()`) wakes the loops
immediately, because a lagging arrow under a drag is the bug this whole design exists to fix.
RENDERS do not: a live board re-renders its cards on every execution event, and admitting each
one kept the loops awake forever on exactly the board where measuring costs most, so mutations
go through a rate limit (`utils/boardWakeGate.ts`, one wake led in immediately and then at most
one per 250ms while the stream lasts). The cost is stated rather than hidden: a geometry change
caused purely by a re-render, a badge appearing and growing a card, can take up to that interval
to be followed. A driver that needs a signal the DOM cannot show, a link set changing with no
card moving, watches its own reactive source and pokes, the way `TaskDependencyEdges` watches
its four link lists.

The gesture listeners are on the WINDOW, not on the canvas element. A drag does not stop at the
canvas's edge (`useBlockDrag` tracks the pointer on the window for exactly that reason) and the
top overlay region and the inspector are siblings painted OVER the canvas, so a canvas-bound
listener went quiet for as long as the cursor crossed one of them.

**Measure through `utils/blockRects.ts`, never a `querySelector` per card.** `measureBlocks()`
hands a pass one snapshot: the cards resolved in one query, first-in-document-order per id, and
each rect read at most once. It is what makes a wake cheap enough for the rate limit above to be
a saving rather than a way of hiding an expensive pass, and it is lazy, so a pass that resolves
nothing (a board with no links at all) touches no DOM.

Two things this cost, both worth knowing before adding a third driver. `compute` returning
`true` unconditionally silently restores the old behaviour, which is why the loop's contract is
stated in terms of what the user can see rather than what the function did. And the pulse
watches `style`/`class` attributes but not the geometry attributes the overlay itself writes,
because a driver whose own output pulsed it awake would never settle.

A `compute` that THROWS parks the loop and lets the error reach the frame callback, so the next
pulse of any kind is what restarts it. Retrying the frame instead would turn one bad measurement
into a 60Hz error storm, and staying awake with no frame scheduled would make every later poke a
no-op and freeze the board for the session.

What the pulse cannot see is a reflow with no mutation and no gesture, a late-loading image or
font resizing a card. That leaves an arrow stale until the next pulse of any kind, which is the
deliberate trade: firing too often costs a handful of frames, and the alternative is the loop
that never sleeps.

`app/utils/settlingLoop.spec.ts` pins the loop against a hand-driven frame clock;
`boardWakeGate.spec.ts` pins that the rate limit delivers every suppressed wake rather than
dropping it, and `blockRects.spec.ts` that a snapshot resolves and measures each card once.

### A store must be instantiable outside a component `setup`

A Pinia setup store runs its body on the FIRST `useStore()` anywhere in the app, and that
caller is not always a component: `plugins/modular.client.ts` builds the nav gates
(`createNavGates`) during plugin setup, which instantiates a handful of stores before any
component exists. So nothing a store reaches for at setup time may require an active
component instance.

The one that bites is **`useI18n()`, which throws `MUST_BE_CALL_SETUP_TOP` outside a
component**, and because it happens inside a plugin, Nuxt's error boundary replaces the
whole app with its 500 page rather than surfacing a broken feature. Resolve translations
through the Nuxt app's global i18n instance instead (`useNuxtApp().$i18n`, typed as
`ReturnType<typeof useI18n>`), as `stores/board.ts`, `stores/recurringPipelines.ts` and
`composables/usePipelineErrorToast.ts` do. This costs no typed-message-key coverage: tier 1
only sees literal keys written in a `<script setup>`, never in a `.ts` store or composable.

The blast radius is why this is a rule rather than a preference: a store reached one call
earlier than before takes the entire SPA down at boot, and the unit suite cannot see it
(nothing there installs the plugin). Every e2e spec does, because every one of them boots
the app.

### The persisted board pin is UNVALIDATED until `init()` resolves it

`workspace.workspaceId` is restored from persisted state SYNCHRONOUSLY, before any request fires,
so every `immediate: true` watcher on it (`pages/index.vue`) runs against an id nothing has
checked. The pin can name a board that was deleted, or one whose access was revoked while the
browser held it, and the RBAC gate answers both with a 404 (it hides a denial as a not-found, so
existence never leaks). `init()` then validates the pin against `GET /workspaces` and re-points it
at a board the user can actually reach.

Firing the per-board reads on the pin anyway is deliberate: it overlaps them with the workspace
list instead of queueing them behind it, which is why `init()` fetches the pinned SNAPSHOT
speculatively too. **What travels with that is the miss.** Each of those boot reads states its own
tolerance at its own seam (`init`'s `.catch(() => null)`, `github.ensureProbed`'s internal catch,
`models.prefetchForBoard`), because a 404 there is an expected outcome and not a fault: the
watcher fires again for the board init resolved, which is the read that counts. A bare
`void store.load(workspace.workspaceId)` in that chain is an uncaught rejection in a real user's
browser, and the e2e suite's `pageErrors` fixture fails the spec that boots a session whose access
was just revoked.

Tolerating the miss is not the same as pretending it succeeded: a dropped load leaves its store
UNLOADED (`models.loaded` stays false, so `useAiReadiness().ready` is false), which reads as
unresolved rather than as a board with nothing configured, and leaves the next caller free to
retry. Pin new boot reads with a store-level unit test (`stores/models.spec.ts`).

### A backend-DECLARED form renders through `DescriptorFields.vue`

When the backend declares the fields and the SPA only collects them, render them with the shared
`components/common/DescriptorFields.vue` over the contracts vocabulary
(`contracts/src/form-fields.ts`), and never hand-roll a second renderer for the same shapes. Two
surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a
custom task type (`AddTaskModal`). Adding a third is a `:fields` binding, not a component.

**Grouping is the descriptor's own, through `descriptorFieldSections`**, not a wrapper each surface
builds: consecutive fields sharing a `section` render under one caption, and the reduction applies
`showWhen` first, so a section whose every field is hidden renders no caption. Never re-group or
re-order the fields at a call site, or a form renders in an order its author never wrote.

**A captioned run is rendered FLAT, never as a per-run wrapper element** (`descriptorFormRows`
carries each run's caption on the field that opens it). Run membership is derived state that shifts
as `showWhen` reveals fields, while a field's identity does not: nesting the fields inside a wrapper
re-parents them when a boundary moves, and Vue can only do that by unmounting and remounting. The
remounted input is typically the one being TYPED INTO, because typing into the trigger is what moved
the boundary, so it loses focus, caret and IME composition mid-keystroke. Keep every field a sibling
keyed by `field.key` and the diff MOVES it instead. The same trap as keying any list by index, with a
worse symptom: `descriptorFields.spec.ts` pins that a reveal preserves every field key.

Four rules travel with it. **Validate with the shared `validateDescriptorFields`** so the submit
button reflects exactly what the server will refuse, and **submit the shared
`sanitizeDescriptorFields` result** so a stale answer on a since-hidden `showWhen` field never
reaches the wire. **Every string a descriptor carries is deployment-authored English rendered
verbatim**, labels, help, option captions and the `section` grouping captions alike: only the
platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters
a locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
(`setDescriptorValue` / `setDescriptorCheckbox` / `toggleDescriptorGroupValue` for one edit; the
initial values come straight from contracts' `descriptorFieldDefaults`, the same helper the server
folds in at the creation door): what an edit freezes on an entity is what a unit test must be able
to reach, and a rule inside a component is only reachable by mounting one.

Mirroring the server's check leaves one refusal still reachable, deliberately: the deployment can
re-register the descriptor while the dialog sits open, so a create can come back `422` with
`details.reason: 'task_type_fields_invalid'`. Map it to translated copy like any other reason
(`AddTaskModal`'s `createRefusalMessage`) rather than showing the server's field-key prose.

### Always import a layer component explicitly

**Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.

Some bare tags do work, which is exactly what makes this worth writing down. Nuxt drops a directory segment the filename already repeats, so `pipeline/PipelinePicker.vue` registers as `PipelinePicker` and resolves bare, while `pipeline/AgentKindIcon.vue` in the same folder registers as `PipelineAgentKindIcon` and does not. Whether a tag resolves therefore depends on a coincidence between a folder name and a filename, and renaming either end breaks the tag with no error. An explicit import does not care.

The failure is silent, which is why this is a rule rather than a preference. An unresolved tag warns in dev and then renders nothing, so a built SPA has a hole where the component should be. Nothing catches it: not typecheck, not the unit tests, not the e2e suite, and not the user, who reads it as a backend returning no data. Seven components had shipped this way.

`scripts/check-component-imports.mjs` enforces it (CI's `repo-guards` job). If a panel section is missing and the data looks right, check the import first.

### Every failure toast goes through ONE funnel

**A failed call is reported with `usePipelineErrorToast().present(error, titleKey)`.** Never build
`toast.add({ title, description: e instanceof Error ? e.message : String(e) })`, and never wrap that
shape in a per-component `notifyError(title, e)` helper (29 components had a copy of the same six
lines, plus four more spellings of it).

The funnel is not a formatting convenience. Four properties are what it exists for, and a hand-built
toast has none of them:

- **Translated copy.** The description is resolved from the envelope's status class or its
  `details.reason`; the backend's untranslated prose is DETAIL, never the headline (see the i18n
  section). A hand-built toast shows English to every locale.
- **It does not auto-dismiss.** An error is the one toast a reader has to finish, quote, or act on,
  and a ~5s dismissal took it away mid-sentence. It keeps its close button, so leaving is a choice.
- **One-click copy of the whole thing**, through `useCopyToClipboard` (so the copy's own
  success/failure is reported rather than silently no-op'ing in an insecure context). Selecting text
  in a toast is fiddly and impossible once it is gone.
- **The `requestId` travels with it.** `mountRequestLogging` puts that id on every error envelope,
  and it is the ONLY join between what the user saw and the one server log line that explains it. A
  report that arrives without it costs whoever reads it the entire diagnosis.

`present` takes a KEY, not a resolved title (plus optional interpolation params), so it can resolve
its own copy. A store passes it through its context alongside `api`/`toast` (see
`stores/board/context.ts`) rather than calling the composable per write. A site with BESPOKE copy for
a recognised refusal keeps that branch and drains only its fallback into the funnel
(`stores/board/placement.ts`, `components/board/AddTaskModal.vue`).

**A FAILED CALL, though, not every refusal.** The funnel's whole job is to classify what the backend
answered, so a local check that never left the browser must not be dressed up as one: a synthesized
`new Error(t('...'))` has no envelope and no status, which is precisely the input `describeGenericFailure`
reads as a network fault. A blank required field then renders as "The server could not be reached",
with the real sentence hidden behind a disclosure. Client-side validation is
`useActionToast().error(titleKey, { description })` instead (see below). A failure the backend
reports as DATA (a job row with `status: 'failed'` and its `error`, a probe verdict's `message`) is
`presentReported(detail, titleKey, { descriptionKey })`: translated copy as the headline, the
backend's prose behind "Show details" and in the copied report, the same as a failed call.

The still-open remainder is the INLINE family: `error.value = e.message` rendered in a panel, and
`testResult = { ok: false, message }` rendered by `ConnectionTestVerdict`. Those need a render
surface rather than a toast, and are tracked as G4 in
[`error-message-coverage.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/error-message-coverage.md).

#### Every other toast goes through the second funnel, `useActionToast`

**`toast.add` is called from exactly two composables**: `usePipelineErrorToast` (with its
`pipelineErrorToast/` factory) for a failed call, and `useActionToast` for everything else. A call
site picks a TONE and supplies a title KEY; the tone fixes the colour, the icon and the duration, so
two toasts of one tone render identically wherever they come from. Before this, some success toasts
were green with a check and the next was grey with no icon, depending on who wrote the site.

| Tone      | For                                                                                                      | Dismisses   |
| --------- | -------------------------------------------------------------------------------------------------------- | ----------- |
| `success` | The action the user asked for completed (saved, connected, removed, copied, moved).                      | After 5s    |
| `info`    | Progress or a state report that asks nothing ("drafting", "up to date").                                 | After 5s    |
| `warning` | The action ran, but the outcome is partial or needs a look.                                              | When closed |
| `error`   | A refusal in translated copy that is NOT a failed call: client-side validation, a mapped refusal reason. | When closed |

`warning` and `error` stay for the same reason the failure funnel's toast does: they ask the reader
to do something. Options are content, never styling: `params` and `plural` for the title key, a
resolved `description`, `actions` for a call-to-action, and `undo: { run, windowMs }` for a write
held open behind an Undo (the board's delete and move), which keeps the toast exactly as long as
the window. A toast is action feedback. Something the reader must act on later belongs in an inline
`UAlert` or the notification inbox, not a toast.

#### Loading: the component's `loading` prop, `USkeleton` for content, `Spinner` for a glyph

- **A Nuxt UI component with a `loading` prop takes `:loading`.** Never spin its icon by hand
  (`:ui="{ leadingIcon: 'animate-spin' }"`, or an `:icon` swapped to a loader while busy). One
  exception: `UButton`'s `loading` also DISABLES it (`disabled || isLoading`). A button that must
  stay clickable while its work runs (a tab whose thread is mid-reply, a "planning in progress"
  button that opens the planning window) puts `<Spinner>` in its `#leading` slot instead, with a
  comment saying why. `UInput` and the other inputs do not disable on `loading`.
- **A panel or list waiting on its first fetch shows `USkeleton` rows sized like the content they
  replace**, so it does not show nothing and then everything. Its fill is `bg-accented`, set once
  in `app.config.ts`: Nuxt UI's default skeleton is `bg-elevated`, which disappears on the
  `bg-elevated` and `app-950` window bodies and cards many panels paint. An ACTION in progress (a button
  press, a sync) is not a content load and keeps `:loading` or a spinner.
- **A bare glyph showing work in progress is `<Spinner>`** (`components/common/Spinner.vue`), the
  one file that writes `animate-spin`. `:spinning` covers a status icon that spins in one state
  only (a running step), and `name` defaults to Nuxt UI's own loading icon so it matches the one
  `:loading` draws. It spins under `motion-safe:` only.

#### Empty states: `EmptyState`, always

**A list, panel or section with nothing to show renders `common/EmptyState.vue`**, with its copy as
`title` / `description` and any button that fills the list in its default slot. `compact` is the
inline size for an inspector section or a picker. It wraps Nuxt UI's `UEmpty` (the structure, the
action row and the tokens) and decides once what each `UEmpty` caller would otherwise pick: the
`naked` variant, because an empty state sits inside a panel that already draws its border; the
spacing, because `UEmpty` pads `p-4 sm:p-6 lg:p-8` for a page body; and plain text for the title,
because `UEmpty` renders it as an `<h2>`, which would put a heading into every picker. An inline
value placeholder ("None", a dash in a table cell) is not an empty state.

`scripts/check-frontend-feedback.mjs` (CI's `repo-guards` job) bans `useToast(` and `toast.add(`
outside the two funnels and `animate-spin` outside `Spinner.vue`. Its `PENDING` list names files another open
change is rewriting, and fails once one of them is clean so the entry cannot outlive the reason.

### A panel that seeds state on open uses `onModalOpen`, never a bare `watch(open)`

**Work done on the way IN to a modal goes through `onModalOpen(open, fn)`
(`composables/useModalOpen.ts`).** A hand-written `watch(open, (isOpen) => { if (isOpen) … })` is
wrong in this SPA and reads as right.

`pages/index.vue` mounts most panels only WHILE their flag is set
(`<AssistantModal v-if="ui.assistantOpen" />`), so `open` is already `true` when the component sets
up and a change-only watcher never fires at all. Whatever the body does never happens: the picker
keeps its unselected default, the read that fills a catalog is never issued, and the panel opens
with a confirm button that cannot be pressed. Nothing throws and nothing warns, which is why four
modals carried it at once and the assistant shipped with a Run button that could never submit.

The helper is `{ immediate: true }` plus the open check, and it is correct for an always-mounted
panel too: that one passes a `false` on the first run and the body is skipped. So nobody has to know
which kind of panel they are writing, which is the point of it being shared rather than a flag
remembered per site.

A watcher acting on the CLOSE edge (emitting `close`, revoking object URLs) stays a plain `watch`:
it has nothing to do at mount, and running it there would announce a close that never happened.

### Type a chip map with `BadgeColor`, never `string`

A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.

### Colour through theme tokens, never a fixed palette shade

Every colour in the SPA is a token that follows the THEME and the colour MODE. Three kinds exist:

- **Nuxt UI role tokens** for greys (`bg-default`, `bg-elevated`, `bg-accented`, `text-default`, `text-toned`, `text-muted`, `text-dimmed`, `text-highlighted`, `border-default`, `border-muted`) and the **bare aliases** for an accent at Nuxt UI's own shade (`text-primary`, `bg-warning/10`, `ring-error/25`).
- **The app's mirrored numbered tokens** (`app/assets/css/tokens.css`) for a STATUS surface painted in a specific ramp shade: `bg-app-950` / `text-app-100` / `text-app-600` / `bg-app-500` / `ring-app-400` for the five grey shades with no role token, `bg-app-canvas` for the board canvas (the one surface below the page background; in light the page surface `app-950` sits at neutral-100 rather than the mirror's 50 and the canvas at 200, so the ~350 translucent `bg-default/NN` panels keep an edge), and `text-app-warning-300` / `bg-app-error-950/40` / `border-app-success-800` for the five status ramps (`secondary`, `success`, `info`, `warning`, `error`). Each token's dark value is the exact ramp shade and its light value is the MIRRORED shade (`50↔950`, `100↔900`, `200↔800`, `300↔700`, `400↔600`, `500` its own midpoint), so a `300` text on a `950/40` fill reads on both surfaces from one class list. Every value is a reference into the `--ui-color-<alias>-<n>` ramps, so a theme that remaps `warning` recolours every warning surface. `primary` has NO numbered token: brand accents use the bare alias, which is what the editor's primary slider and `blackAsPrimary` address.
- **Category hues** (`text-app-hue-pink`, `var(--app-hue-teal)` in an inline style) for an IDENTITY colour: an agent kind, a task type, a pipeline step kind (`utils/catalog.ts`). One token per Tailwind hue, `400` in dark and `600` in light (Nuxt UI's own alias convention), referencing Tailwind's ramps because a category is not a status. A STATUS (task status, step state, test verdict, finding severity) is not a category and takes the alias directly (`var(--ui-warning)`, `var(--ui-success)`, `var(--ui-text-muted)` for planned / done / skipped), so the halo, the badge and the dot recolour together. A translucent tile behind a category glyph goes through `tint()` (`utils/colorTint.ts`), which also accepts the hex a deployment-registered kind may send. A theme recolours a hue through its `tokens` block.
- **Never** a raw Tailwind hue (`bg-slate-900`, `text-amber-300`, `text-pink-300`) nor a fixed numbered alias (`text-primary-400`, `bg-warning-950/40`): the first is welded to one palette and one mode, the second follows the palette but not the mode. `scripts/check-frontend-palette.mjs` (CI) bans both. `red` and `rose` both mean `error`.
- **Never** a fixed `white` / `black` utility (`bg-white`, `text-black`, `border-white/5`, `bg-white/[0.02]`): invisible or wrong in light mode. Use `border-inverted` (a selection ring), `border-default` / `bg-muted` (faint chrome) or `text-inverted` (text over a filled surface). A line that genuinely needs a fixed value says why with a `fixed-colour-ok:` comment, on that line or the one above it.

### Type through named steps, never a pixel literal

Every font size in the SPA is a NAMED step, so it scales with the theme and lines up with the sizes Nuxt UI gives its own components. Two sources of steps:

- **Tailwind's own** from `text-xs` up (`text-xs`, `text-sm`, `text-base`, ...), each paired with a line height.
- **The app's two steps below them** (`app/assets/css/type.css`), for the dense surfaces Tailwind's scale bottoms out above: `text-2xs` (0.6875rem, 11px at the default root) for the dense body size that panel rows, node metadata and inline detail are written at, and `text-3xs` (0.625rem, 10px) for the caption tier below it: pill and badge text on a board node, a quoted source excerpt, an axis or legend caption. The section eyebrow is `text-2xs`, not `text-3xs`, and is not written by hand at all (see the last bullet). Neither declares a paired LINE HEIGHT: dense text inherits its rhythm from the surrounding block, and a block that wants a specific rhythm says `leading-*`, which wins over a step's paired value anyway.
- **Never** a literal size (`text-[11px]`, `text-[0.6875rem]`). A theme document's `fontSize` is applied as a root `font-size` (`utils/theme/css.ts`), so every rem step follows it and a pixel literal does not: raising the font size would grow Nuxt UI's components while the app's own text sat still. A literal is also unnameable, so a `UBadge size="sm"` beside one is sized by a system the label cannot refer to. `scripts/check-frontend-type-scale.mjs` (CI) bans the utility forms (`text-[length:11px]`, `[font-size:11px]`, `[font:…]`), a px declaration in CSS (`font-size: 11px`, the `font:` shorthand) and a px JS style property (`fontSize: '11px'`, `style.fontSize = …`); its header lists exactly what it matches. A line that genuinely needs one says why with a `type-literal-ok:` comment, on that line or the one above.
- **A section eyebrow is [`common/SectionLabel.vue`](./app/components/common/SectionLabel.vue)**, not a hand-written class list. The label that titles a group inside a panel, window, inspector section or dropdown is one recipe, `text-2xs font-semibold uppercase tracking-wide text-muted`, and the component owns all five: they are not overridable, because a caller wanting a different size or colour wants a different thing. The caller picks the ELEMENT with `as` (`div` by default; `h3` / `h4` for a real heading, `label` with an `:id` for a field; a collapsing section is a `UButton` around `as="span"`) and owns SPACING through the ordinary `class` attribute. An ACCENT-coloured uppercase label (`text-primary`, `text-app-info-400`) is not this component: it is signalling state or category rather than titling a section, so it keeps its own classes. Nor is a pill, a chip, a metadata row that merely shares the uppercase styling (`BlockNode.vue`'s composition line), or a caption inside a control (a switcher trigger's label line, a slider's end labels): the test is whether the text TITLES the block after it. The colour is `text-muted` and not the fainter `text-dimmed` for contrast: at 11px `text-dimmed` is 2.63:1 on a light surface, under WCAG AA, where `text-muted` is 4.76:1. (The SPA's wider use of `text-dimmed` outside this component is a separate problem.)

### Radius through the theme scale

Every `rounded-*` utility in the SPA derives from `--ui-radius`, so a theme document's `radius` reaches all of them. Nuxt UI rebuilds seven of Tailwind's steps on it (`rounded-xs` is half it, `rounded-sm` is it, then 1.5x, 2x, 3x, 4x and 6x), and [`main.css`](./app/assets/css/main.css) binds the two spellings that sat outside that: `--radius`, which the bare `rounded` alias and its side and corner forms resolve through, and `--radius-4xl`. Tailwind declares both `@theme default`, so the app's own `@theme inline` block wins, and `inline` is what makes the utility compile to `var(--ui-radius)` rather than to a value frozen at build time. Neither number moves on the default theme.

That is the whole mechanism, and it is why there is no rule here about WHICH step to pick. Two things still name a number instead of the scale:

- **Never an arbitrary value**: `rounded-[10px]`, `rounded-t-[0.6rem]`, or Tailwind v4's CSS-variable shorthand `rounded-(--x)`. Welded to one number the way a pixel font size is, and unnameable besides, so a `UBadge` beside one is shaped by a system the box cannot refer to. The SPA has none.
- **In CSS, reach the scale through `var(--ui-radius)`**, on its own or inside a `calc()`: `calc(var(--ui-radius) * 2)` is the `rounded-lg` step, and `calc(var(--ui-radius) - 1px)` is an inner box kept concentric with its parent, which follows the theme just as well. **Never a `--radius-*` theme variable**, even though one works today: Tailwind declares that scale in a `@theme default inline` block and emits a variable only where the COMPILED stylesheet graph references it, so `var(--radius-lg)` is correct until the last other reference is deleted and then silently computes to 0. `--ui-radius` is an ordinary custom property on `:root`, so it is there in a scoped SFC `<style>` block too, where the `--radius-*` scale never is. **Never a raw literal** in px, pt, rem or em, the per-corner longhands (`border-top-left-radius`, the logical `border-start-start-radius`) and the JS `borderRadius` / `'border-radius'` spelling a `:style` binding uses included. The pill spellings (`9999px`, `50%`) and `0` are exempt, and a shorthand is read value by value, so `50% 50% 8px 8px` is still caught on the `8px`.
- `scripts/check-frontend-radius.mjs` (CI) bans both. It needs no notion of where a class list lives, because `rounded-[` and `rounded-(` are not English words. A line that genuinely needs a fixed radius says why with a `radius-literal-ok:` comment, on that line or the one above.

The default `radius` lives on the `Cat Factory` document in `app/utils/theme/builtins.ts`. It equals `THEME_DEFAULTS.radius` and so writes no CSS, which is the point: changing the number there is the whole edit.

### Full-screen overlays bind their root class

A phone's notch and home indicator cover whatever sits at the screen edge, and on a screen without them nothing looks wrong. So a full-screen overlay never spells `fixed inset-0` itself. Its root binds one of the two class lists in [`utils/safeArea.ts`](./app/utils/safeArea.ts), which carry the positioning, the `100dvh` cap and the safe-area padding, and the site adds only its stacking, direction and backdrop:

- **`FULL_BLEED_TAKEOVER_CLASS`** for a takeover that fills the screen (`AgentStepDetail`, the model configuration takeover, the observability, reports, operator dashboard and Kaizen panels, the artifact lightbox). It pads by the inset alone, so a screen without one keeps the edge-to-edge layout.
- **`WINDOWED_OVERLAY_CLASS`** for a window on a backdrop (`ResultWindowShell`). Its gutter is 1rem or the inset, whichever is larger.

Each class list belongs to ONE shell, and a surface renders inside that shell instead of binding the class itself: a takeover inside [`common/TakeoverShell.vue`](./app/components/common/TakeoverShell.vue), a window inside [`panels/ResultWindowShell.vue`](./app/components/panels/ResultWindowShell.vue). The shell binds the padding AND the modal behaviour (`useModalBehavior`: a focus trap, focus return, a body-scroll lock, and a ticket on the shared overlay stack), so focus stays inside and Escape closes only the top overlay. A takeover gates the shell with its own `v-if` inside its own `Teleport` and `Transition`, passes a `label` (the dialog's accessible name), and handles the shell's `close` event; it never adds an Escape listener of its own. A Nuxt UI menu, select, popover or modal opened inside a shell holds its own ticket on the same stack while it is open ([`utils/foreignLayers.ts`](./app/utils/foreignLayers.ts), wired by a client plugin), so Escape and Tab are its keys until it closes. No site pushes a ticket by hand. Why this is not `UModal fullscreen`, and the rest of the overlay consolidation: [`docs/initiatives/overlay-shells.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/overlay-shells.md).

`scripts/check-frontend-safe-area.mjs` (CI) refuses any other `fixed` surface that spans a whole side of the viewport, in `.vue`, `.ts` and `.css`. A surface with no edge content (a scrim, a drawing layer) or one that pads its own edges (the sidebar drawer, the inspector's bottom sheet) says why with a `safe-area-ok:` comment, on the line or the one above it, or above the element's opening tag. The same guard refuses either class list named outside its owning shell, with no waiver.

### Modals open at a named width

A modal states what KIND of content it holds, and the width follows from that in one place. Before this, each modal picked its own `max-w-*`: five widths for three kinds of content, and no rule for which one a new modal takes.

- **`<AppModal size="prompt">`** (448px): a confirmation, a decision, a short read such as the keyboard help, or a dialog with one or two fields.
- **`<AppModal size="form">`** (576px): a form, a list or a hub to work through.
- **`<AppModal size="wide">`** (672px): cards that each carry a paragraph and an action beside it, a row of tabs over a form, or three pickers side by side (the AI provider onboarding, the vendor credentials, the setup checklist, describe new work).
- [`common/AppModal.vue`](./app/components/common/AppModal.vue) is `UModal` with the width applied through `ui.content`; every other prop, listener and slot passes through, and a call site still passes other `ui` slots and non-width content classes (a stacked dialog's `z-[70]`). The widths live in [`utils/overlaySize.ts`](./app/utils/overlaySize.ts). It is a wrapper rather than an `app.config.ts` variant because `UModal` has no `size` prop and passes only `transition`, `fullscreen`, `overlay` and `scrollable` into its variant function, so a `size` variant would never be selected.
- **An always-`fullscreen` modal has no width** and stays a plain `UModal`. A modal bound fullscreen only sometimes (`:fullscreen="isPhone"`) is centered everywhere else, so it takes an `AppModal` size, which `AppModal` drops while the modal is fullscreen.
- **A workbench surface keeps its own width for now**: the libraries, settings panels, bug hunt, adoption review and environment wizard at 768px, the sandbox and the agent prompt editor above that. Their shell is the open decision in [#2359](https://github.com/kibertoad/cat-factory/issues/2359), so a named width for them now would be dead work. Each says why in a `<!-- workbench-width-ok: … -->` comment directly above its tag.
- `scripts/check-frontend-geometry.mjs` (CI) refuses a raw `UModal` (or `LazyUModal`) outside the wrapper, an `AppModal` that names its own width utility (`max-w-*`, `min-w-*`, `w-*`) in `class` or in the `content` slot of `:ui`, or reads either from a variable or a spread it cannot see through, a waiver with no reason, and a waiver with no raw modal directly below it. A `USlideover` is a side panel with no named width, so the guard does not read it.

### A control is its Nuxt UI component

Every CONTROL in the SPA is the Nuxt UI component for it, never the raw HTML element. A raw `<button>` beside a `UButton` is the same control rendered twice, and only one of the two follows the theme, the focus ring, the disabled state and the size scale; the SPA carried 152 of them, 24 raw `<input>`, five hand-built tables and six hand-rolled `<details>`, each with its own border, background and hover recipe.

- **The mapping**: `<button>` → `UButton`; `<input>` → `UInput` / `UInputNumber` / `UCheckbox` / `URadioGroup` / `USlider` / `UFileUpload`, by what the control IS rather than by its `type=`; `<select>` → `USelect` (`USelectMenu` when it needs searching, `UInputMenu` when the list is open-ended); `<textarea>` → `UTextarea`; `<table>` → `UTable` with column definitions; `<details>` → `UCollapsible`; `<form>` → `UForm`; `<a href>` → `ULink`; `<datalist>` → `UInputMenu` with `mode="autocomplete"`, which is what a datalist was (the DEFAULT combobox mode writes its model only on SELECT, so a typed free-text value is dropped). Which input for which job is the skill's [component-selection matrix](https://github.com/nuxt/ui/blob/v4/skills/nuxt-ui/references/component-selection.md), not restated here.
- **An icon-only button is [`common/IconButton.vue`](./app/components/common/IconButton.vue)**, which wraps `UButton` in a `UTooltip` and applies `label` as the tooltip text AND `aria-label`. A `title` attribute is not a tooltip: it never fires on touch, is unstyled, waits about a second, and looks nothing like the app's other hints. `IconButton` and `CopyButton` own their tooltip, so passing either a `title` renders a second hint over the first. A `title` on a LABELLED `UButton` is still fine: there it is a hover hint beside the control's own name rather than the name itself. `class` on `IconButton` lands on the wrapper the tooltip needs, which is the box the parent lays out, so it carries layout (`ms-auto`, `shrink-0`, `nodrag`); the button's own look goes through its props and `:ui="{ base: '…' }"`. The guard refuses an icon-only `UButton` whose only name is a `title`.
- **Prose and layout markup is not in scope.** `<p>`, `<h1>`-`<h6>`, `<ul>`, `<li>`, `<dl>`, `<pre>`, `<code>`, `<img>`, `<nav>`, `<div>` and `<span>` are not controls and have no Nuxt UI component; heading typography is the type scale's rule above.
- `scripts/check-frontend-primitives.mjs` (CI) holds the count at zero. There is deliberately **no allow-list**: the migration expected two exceptions (a Vue Flow gesture handle, a full-bleed media tile) and both converted cleanly, so the exception table would have had more surface than the thing it governed. A site that genuinely cannot convert says why with a `raw-control-ok:` comment, on that line or the one above; nothing in the tree needs one, and a second one appearing is the signal that the rule needs revisiting rather than the site.

### Buttons and badges follow one variant policy

What a primary, a secondary or a destructive action looks like is decided once, here, and not per call site. The weights are Nuxt UI's own [variant table](https://github.com/nuxt/ui/blob/v4/skills/nuxt-ui/references/design-system.md) (`solid` highest, then `outline`, `soft`, `subtle`, `ghost`, and `link` for a text link inside content), mapped onto the app's kinds of action:

| Kind                                                                  | Props                                                                                                                                                                                   |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Primary action, ONE per view                                          | none (the default, `primary solid`)                                                                                                                                                     |
| Dismissal in a dialog or form (Cancel, Close)                         | `color="neutral" variant="ghost"`                                                                                                                                                       |
| Real secondary action (Save draft, Back)                              | `color="neutral" variant="outline"`                                                                                                                                                     |
| Bulk action in a dialog footer, beside Close (Reseed all, Update all) | `variant="ghost"` (primary colour): the dialog's main action, kept light because each row above it has its own button                                                                   |
| Send-back on a gate (bounce, request a fix)                           | `color="warning" variant="soft"`, so the approve beside it stays the one primary                                                                                                        |
| Inline, navigation and chrome actions                                 | `color="neutral" variant="ghost"`, usually with `size="xs"`                                                                                                                             |
| Icon-only action                                                      | `IconButton` with `color="neutral" variant="ghost"`                                                                                                                                     |
| Destructive action, labelled                                          | `color="error" variant="soft"`; `color="error"` alone (solid) only as the confirm step of a destructive action (a dialog's confirm, or an armed inline confirm such as a step's reject) |
| Destructive action, icon-only (a row's trash icon)                    | `IconButton` with `color="error" variant="ghost"`                                                                                                                                       |
| A banner's call to action                                             | solid, in the banner's colour (`color="warning"`, `color="error"`)                                                                                                                      |
| Status chip                                                           | `UBadge` with `variant="subtle" size="sm"`, and a `BadgeColor` map for the colour                                                                                                       |

- **The defaults are Nuxt UI's own**: `primary solid md` for both `UButton` and `UBadge`, stated in `app.config.ts` (`ui.button.defaultVariants`, `ui.badge.defaultVariants`) so the value has one visible home and the guard can read it. A call site states only what DIFFERS.
- **One solid button per view.** Two solid buttons side by side compete for the same attention, so the second action takes a lighter variant from the table. The guard refuses two solid buttons under one parent element; it counts a `v-if` chain as one button and a `v-for` list as one, looks through a plain `<template>` wrapper, and treats a slot `<template #name>` as its own region. A file holding several forms that each end in a Save is several views and passes. A site that genuinely needs two says why with a `solid-ok:` comment on its own line, directly above the second button.
- **The unstyled group is the one a theme restyles, so it is the primary tier.** A theme document's `style.defaults` (a size, a button variant, a button colour) REPLACES the default for every control that does not state that prop; it does not scale or shift the others. So whichever group is unstyled is the group a theme moves. With the primary actions unstyled, a theme with `size: 'lg'` or `variants: { buttons: 'outline' }` restyles the actions and leaves the chrome alone. With the quiet chrome unstyled, the same theme would grow or fill every toolbar and icon button past the actions and invert the hierarchy. This is why the dense, quiet chrome carries its props and the primary action carries none, and why Nuxt UI's own internal buttons (a modal's close button, a toast's actions) agree with the app's.
- **A restated default is a bug, not noise.** It pins the value against a theme that changes the default, and it hides which props are the reason a control looks the way it does. `scripts/check-frontend-variants.mjs` (CI) refuses a value equal to the default on `UButton` and `UBadge` (either casing, either quote style, or a key of a `v-bind="{ … }"` object) and on every component that spreads its attributes into one (`IconButton`; the list is derived, so a new wrapper is covered), reading the defaults from `app.config.ts` itself. Inside a `UFieldGroup` or `UButtonGroup` it does not check `size`: the group sets the size there, so an explicit `size` overrides the group rather than restating the default. It reads template tags only, so a `ButtonProps` object (a toast's `actions`, a modal's `:close`) is not checked. A site that must keep a literal says why with a `variant-default-ok:` comment on its own line, directly above the tag; it waives that tag only.

### Themes and colour mode

A **theme** is a Nuxt UI theme editor document (https://ui.nuxt.com/theme): the sparse `ThemeDoc` the editor edits and shares as a `?doc=` link, decoded by `utils/theme/link.ts` and typed in `utils/theme/doc.ts` (a port of the editor engine's tables). The app speaks THAT format rather than one of its own, so a theme built in the editor imports as-is and a built-in theme is a document a user could have exported. `utils/theme/builtins.ts` ships `Cat Factory` (the default; its `colors` MUST equal `app.config.ts`'s `ui.colors`, which paints the first frame) and the editor's own `Mono` preset, referenced from the preset table rather than copied. The `theme` store persists the pick and the user's imported documents in this browser; `plugins/theme.client.ts` applies the active document at runtime: aliases onto `appConfig.ui.colors` (Nuxt UI's own colours plugin re-emits the ramps), default variants and component overrides onto `appConfig.ui.<component>` merged INTO the layer's own overrides, and tokens / radius / fonts onto one `<style id="app-theme">` whose selectors anchor on the `data-theme` attribute on `<html>` (`utils/theme/css.ts`, which is also the write boundary for untrusted values). The faces the built-in themes name (Geist, Geist Mono) are self-hosted through `@nuxt/fonts` (`fonts.families` in `nuxt.config.ts`, pinned by `builtins.spec.ts`); a font an IMPORTED theme names is applied as a family name only, so it renders when the device or the deployment provides it. A editor link whose document equals one of the editor's presets collapses to `{ preset: 'mono' }`; `utils/theme/presets.ts` is a copy of the editor's preset table so such a link rebuilds (a decode table, not menu entries).

**Colour mode** (system / light / dark) is `@nuxtjs/color-mode`'s, registered by Nuxt UI, with its own persistence; `nuxt.config.ts` follows the system preference and falls back to dark. `AppearanceSwitcher` at the sidebar bottom is the one control for both axes. The pre-JS loading shell (`spa-loading-template.html`) cannot resolve a token (no stylesheet is loaded yet), so the plugin caches the computed colours of the four tokens the shell paints with, per colour mode, in `localStorage` (`utils/theme/bootPalette.ts`), and a two-line inline script in the shell applies the entry for the mode the `<head>` colour-mode script has already put on `<html>`. The hardcoded defaults are the default theme's, used only until a theme has been seen in that mode.

## Nuxt UI: agent tooling and where the SPA overrides the skill

The SPA runs on Nuxt UI 4. Two first-party sources carry the rules this repo does not restate:

- **The vendored [`nuxt-ui` skill](https://github.com/kibertoad/cat-factory/tree/main/.claude/skills/nuxt-ui)** teaches WHEN to reach for which
  component (Modal vs Slideover vs Drawer, Tabs vs NavigationMenu, Toast vs Alert) and HOW to build
  well (the variant-weight table, one solid primary per view, semantic utility roles). It is
  vendored under `.claude/skills/nuxt-ui/`, beside the project's own skills, so the rules version
  with the code and every contributor on Claude Code reads the same text. This is the Claude Code
  surface: `.claude/skills/` auto-loads for a local Claude Code session, and the repo `.mcp.json` is
  offered once for approval on the first session in the repo (re-offered when the file changes); a
  decline is remembered, and `claude mcp reset-project-choices` un-sticks it. Another tool that reads
  `AGENTS.md` (Codex, for one) gets the pointers below but not an auto-loaded skill or an auto-wired
  server. Making the platform's OWN coder agents (Claude Code, Codex, Pi in
  the runner image) apply the skill is a separate, backend change: they stage skills and tool
  servers per run through the agent-capabilities system (ADR 0024 /
  [`custom-agents.md`](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/custom-agents.md)), not from these repo files. The `@nuxt/ui` npm
  package does not ship the skill, so it cannot move with `pnpm update`: it is vendored from the
  `v4` branch of `nuxt/ui` (which tracks the 4.x line) and pinned by convention to the Nuxt UI
  major in [`package.json`](./package.json) (`@nuxt/ui@^4.11`). Refresh it by re-vendoring
  `skills/nuxt-ui/` from that branch (or `npx skills add nuxt/ui`) when the major moves. The
  vendored files are kept byte-for-byte as upstream: `.oxfmtrc.json` exempts them from the
  formatter and `scripts/check-doc-links.mjs` skips them, so a refresh stays a clean diff even when
  upstream ships a link this repo's own doc rules would reject.
- **The [`nuxt-ui` MCP server](https://github.com/kibertoad/cat-factory/blob/main/.mcp.json)** (`https://ui.nuxt.com/mcp`, declared repo-level)
  answers WHAT a component accepts: props, slots, events, theme files and examples per component.
  The skill defers to it for every API question.

The SPA's own rules win where they and the skill disagree. Each entry below links the rule it
protects, so the two never contradict silently:

- **Visible copy is an i18n key, never a literal string.** The skill's examples hard-code labels and
  placeholders (`label="Email"`, `placeholder="you@example.com"`); here every user-facing string
  goes through `@nuxtjs/i18n` and lands in the catalog. [Rule](#internationalization-i18n-authoring).
- **Schema validation goes through valibot, never zod.** The skill's `forms.md` recipe opens with
  `import * as z from 'zod'` and validates through `z.object({...})`; this package depends on
  `valibot` (and `@toad-contracts/valibot`) and has no `zod`, so a `zod` import fails typecheck and
  "fixing" it pulls in a dependency the repo deliberately lacks (which then has to clear the 24h
  `minimumReleaseAge` gate). Validate with valibot, mirroring the wire contracts
  ([`@cat-factory/contracts`](https://github.com/kibertoad/cat-factory/tree/main/backend/packages/contracts)).
- **A backend-declared form renders through `DescriptorFields.vue`, not a hand-built `UForm`.** The
  skill teaches the hand-rolled `UForm` + `UFormField` shape for every form; when the backend
  declares the fields and the SPA only collects them, that shape is forbidden here.
  [Rule](#a-backend-declared-form-renders-through-descriptorfieldsvue).
- **Toasts go through the two funnels, never a bare `toast.add`.** The skill uses `useToast()`
  freely; here a failed backend call is reported with `usePipelineErrorToast().present(error, key)`
  and every other toast with `useActionToast().success|info|warning|error(key, opts)`.
  [Rule](#every-failure-toast-goes-through-one-funnel).
- **Loading is `:loading`, `USkeleton` or `<Spinner>`; empty is `<EmptyState>`.** Never a
  hand-written `animate-spin`. [Rule](#loading-the-components-loading-prop-uskeleton-for-content-spinner-for-a-glyph).
- **Icon-only buttons carry an accessible name.** Use `common/IconButton.vue`
  ([source](./app/components/common/IconButton.vue)), which applies `label` as both the tooltip
  and `aria-label`, rather than a bare `<UButton icon>`.
- **Colour goes through theme tokens, never a fixed palette shade.** The skill bans raw Tailwind
  hues; this repo also bans fixed numbered aliases, enforced by
  [`scripts/check-frontend-palette.mjs`](https://github.com/kibertoad/cat-factory/blob/main/scripts/check-frontend-palette.mjs).
  [Rule](#colour-through-theme-tokens-never-a-fixed-palette-shade).
- **Confirmation dialogs use the shared `useConfirm`.** The skill shows `useOverlay()` per call
  site; here callers `await useConfirm().confirm({...})`
  ([source](./app/composables/useConfirm.ts)) against one app-mounted `<ConfirmDialog />`.

## Task swimlanes

A service frame lays its tasks out in **status lanes**, not at coordinates. Three lanes a reader
works in (`not_started`, `in_progress`, `needs_you`) plus a collapsed **Done** strip beneath them.
The vocabulary, the classification and the Done caps are `app/utils/swimlanes.ts`; the ordering and
grouping are `app/utils/laneSort.ts`; `composables/useFrameLanes.ts` is the only store-facing half.

`useFrameLanes` runs ONE INSTANCE PER MOUNTED FRAME and any execution event invalidates all of them,
so what it derives is a performance decision as much as a modelling one. Two rules hold that line: a
derivation over WORKSPACE-wide state belongs on the store that owns its input, never here (the
review-debt map is `notifications.reviewDebtByBlock` for exactly this reason), and the assembled
output passes through `utils/laneIdentity.ts`, which hands back the previous lane / group / entry
objects wherever the fresh ones match, so the common event that moves no card leaves `TaskLane` and
`LaneGroup` diffing on `===`. That reuse is sound only while every DERIVED field is compared (those
exist nowhere but the entry, so a stale one is a lie nothing corrects), which is why a new
`LaneTaskEntry` field must be added to `sameEntry` in the same change. The `task` itself is compared
by reference and deliberately not field by field: a replaced block is a new reference, and an
in-place patch (`board/placement.ts`, the optimistic drag and field edits) is one object both entries
share, which the renderer reads through and deep reactivity invalidates on its own.

**A lane is a CLAIM, which is a higher bar than a badge.** A mislabelled badge sits beside the
truth; a card filed in the wrong lane states something false _and_ hides the card from the column
its reader was scanning. Three rules follow, and they are what the tests pin:

- **The classification is TOTAL.** `BASE_REASON_BY_STATUS` is a `Record<BlockStatus, …>`, so a new
  status fails the build; and a status the TYPE says is impossible while the DATABASE still holds it
  (a retired picklist member on an old row) resolves to `unclassified` → `needs_you`, never to
  `undefined`, which would drop the card out of every lane with nothing left to say it existed.
- **An imprecise reason beats a wrong lane.** The SPA models only decisions and approvals as global
  per-block selectors; a judge / human-test / visual-confirmation / fork / follow-up / input-gate
  park is reachable only by drilling into a step. But every one of them parks the RUN at
  `status: 'blocked'`, and that coarse marker places the card correctly even when this layer cannot
  name the surface. Such a park reports `parked` — named but imprecise, never demoted to "in flight".
- **The reason is a separate answer from the lane.** The lane says "look at this"; the reason says
  "what would I do". `failed`, `budget_paused` and `approval` all stop work dead and need three
  unrelated actions, which is why `blocking_reason` is a grouping and why a lane header can name it.

**Ordering defaults per lane** (`smart`), because the actionable order genuinely differs by column:
what can be started now / what has gone quiet / what has waited longest. Two rules bind any new
comparator: an **unknown timestamp sorts last in BOTH directions** (a run that reported no activity
is not stale and not fresh, and ranking it as either invents a fact), and **every comparator ends on
board order**, since these re-run on every live push and an unresolved tie makes the lane shuffle
itself. `nullsLast` is the single place the first rule lives.

**Sizing is decoupled from content** (`utils/laneGeometry.ts`). A lane scrolls; it does not grow. A
service with 300 open tasks gets the same frame as one with three, which is what the old free layout
could not do — a busy service grew until it dwarfed its neighbours.

Three consumers have to agree about that size, which is why the module exports two FUNCTIONS rather
than leaving each to do the arithmetic: `frameContentSize` is the frame's floor (read by
`contentSize`, and by `framePlacement.EMPTY_FRAME_SIZE`, which has to reserve a spot for a frame
that does not exist yet and so cannot measure it), and `laneBodyHeightIn` is its inverse, handing a
lane whatever the frame's ACTUAL size leaves it. A dragged border therefore grows the lanes rather
than leaving dead canvas below them, and a constant restating either answer is exactly what went
stale before. A frame with no children at all is the one that skips the lanes: it renders one "add
the first task" panel and is sized for that.

**Two preferences, deliberately different scopes.** The sort/group choice is per user, per browser
(`stores/laneView.ts`, persisted like the interface tier): it is personal and changes several times
an hour, and making it shared would let one person's triage sweep re-arrange everyone's board. The
Done lane's two **caps** are per-workspace settings (`doneLaneMaxItems`, `doneLaneRetentionDays`),
because what the board may show of a service's history is a shared decision. Both caps hide cards
only, and the lane reports the two drop counts separately: an age drop means "there is older
history", a cap drop means "there is more from this period".

**A task with no `completedAt` is exempt from the age cap**, not treated as ancient. Every block
merged before that column existed reads that way, and hiding history on the strength of a timestamp
nobody recorded would be the platform inferring a fact it does not have. The count cap still bounds
them, so the exemption cannot make the lane unbounded, and `undatedShown` says how many there are.

**A FRAME still has coordinates, and they live on its `WorkspaceMount`, not on the block** (one
shared service sits at a different spot on every board that mounts it), so every frame-returning read
projects through kernel's `applyMountLayout`. The resize path is where people hit a missed projection,
because a `size`-only edit is the one frame patch with no other visible effect: the SPA upserts the
authoritative block the mutation returned and the frame jumps to coordinates no board shows it at.

**No two top-level board nodes may overlap**, and that is a standing invariant rather than a rule
each write remembers. `useFrameOverlapGuard` (mounted by `BoardCanvas`) watches the rendered
geometry of every frame and epic and, whenever two come to overlap, bounces them apart through
`framePlacement.resolveFrameOverlaps`. Placement alone was not enough: `findFreeFramePosition`
refuses to CREATE an overlap, but three later events make one anyway, and only one of them is a
drag. A border drag grows a frame into its neighbour, and a frame GROWS ON ITS OWN when its first
task arrives, because an empty service renders the "add the first task" panel and reserves a much
smaller footprint than one rendering lanes. Watching the geometry covers all three, and covers the
next write nobody has thought of yet.

Three things about it are load-bearing:

- **It runs in the SPA because only the SPA can measure a frame.** The footprint is derived from
  the lane geometry the browser renders at (`containerSize`); the backend stores a position and at
  most a size override, so it cannot tell whether two frames overlap.
- **Correcting the VIEW and WRITING the correction are separate, and only a local gesture
  authorises the write.** Every client always draws the board clear, which needs no coordination
  because `resolveFrameOverlaps` is pure: two browsers holding one board draw it identically
  whatever order their events arrived in, and a read-only viewer gets the corrected view for free.
  Persisting is the narrower act, and a drag or border resize is the only cause with an
  unambiguous single author, so that client settles the board when the gesture ends and writes what
  it displaced. A frame that grew ON ITS OWN has no author, so its correction is drawn everywhere
  and written by nobody: better than every open session racing to persist the same value, and
  better behaved besides, since a projected neighbour comes back off the server's own geometry when
  the frame shrinks again while a persisted one would stay pushed.
- **The settlement ORDER is the whole policy** (`bySettlementOrder`), and it takes at most ONE
  anchor: the node the local user is placing, held still while its neighbours move aside. A LIST of
  anchors would carry an order of its own, and every order available to build one from is
  per-client (the sequence a client's live events arrived in, or a history only the client that
  watched the change has), so two clients would resolve one overlap to different positions and
  write over each other. Everything else settles in READING ORDER, which is POSITIONAL: the node
  nearest the top-left keeps its place. Note that this is not "the newcomer yields to the frames
  already there": a block carries no shared creation stamp, so arrival is knowable only from a
  client's own session, which is the per-client input this rules out.

The guard stands down for the whole of a gesture. A drag previews a position on every pointer move,
and bouncing neighbours off those in-flight positions displaces frames the user is merely passing
OVER: each pass reads the neighbour where the previous pass pushed it, so the displacement
accumulates instead of springing back. A frame drawn over its neighbours while the pointer holds it
is what direct manipulation looks like; the board settles on release.

**Dragging a card now only reparents** (`positioned: false` in `useBlockDrag`): between services,
and into or out of a module via a module group header's drop zone. Which LANE a card is in is not
something a drop can decide — the lane is derived from state, so dropping a not-started card on
"In progress" could only lie or silently do nothing. Because that drop target exists only while the
reader has grouping set to `module`, the inspector carries a **module picker** that does not depend
on the current grouping; module sub-frames no longer render as boxes, so without it the only route
into a module would be to change a view preference first.

**A move re-stamps the module the task DECLARES**, which is the one thing about that drag that is
not obvious. A task names its module twice: the block it is parented to, and `moduleName`, which
exists because the engine only materialises the module block on merge, so a task can name its module
before anything is its parent. Grouping reads the parent and falls back to the declared name — so a
card dragged OUT of a module and left still declaring it lands right back in the group it came from.
`BoardService.reparent` therefore rewrites the name from the destination container, exactly as it
already rewrote the `type` a task inherits from its frame, and the SPA's optimistic write predicts
the same answer through the shared `moduleNameInContainer` (`@cat-factory/contracts`) so the card
does not visibly jump when the response lands. "No module" is the EMPTY STRING on the wire, the way
every other clearable field spells a clear; `undefined` is dropped by `JSON.stringify` and reaches
the server as an empty patch.

## Queue view

A board has two views (issue [#2258](https://github.com/kibertoad/cat-factory/issues/2258)): the
**queue**, which lists every task across every service in the column that says what it needs, and
the **board**, the canvas where the work is planned. They are two views of one board, not two
products. The sidebar's Views switcher (`components/layout/WorkViewSwitcher.vue`) moves between
them, and so do the palette entries and a `?view=queue` / `?view=board` link. The initiative it
starts, with the slices after it: [`delivery-surface.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/delivery-surface.md).

- **Which view opens is the person's own choice**: the view they used last in this browser
  (`stores/workView.ts`, a cookie persisted like the interface tier, so two accounts on one browser
  share it). A person who has not chosen
  lands on the queue, because it answers the first question a person opens the app with. Both
  views exist in every tier and every role: neither is an override of the other.
- **The queue states the canvas's verdict, regrouped.** `useTaskLaneClassifier` is the one
  classifier the frame swimlanes and the queue share; `utils/queueSections.ts` only regroups its
  output (a waiting pull request gets its own column), and no task crosses a lane. The
  workspace-wide walk lives on one store (`stores/workspaceQueue.ts`), per the swimlane rule on
  workspace-wide derivations.
- **A card's actions are the board card's** (`useTaskActions`), so Start, Resolve and Merge
  cannot drift between the two views.
- **The queue sits over the canvas**, which stays mounted, so switching back keeps its camera and
  selection. The advisory banners render in the page flow above the queue's columns
  (`BoardTopOverlays` with `in-flow`), and the canvas toolbar does not, being the canvas's own
  chrome. The canvas's decision counter opens the queue view.

## Roles (engineer / product manager / designer)

The outermost of the three narrowing axes, and the only one the app **asks about**: on a first-ever
launch it puts up one question, "what do you work on?", offering `engineer`, `product-manager` and
`designer` with a line each on what picking it gives you. Vocabulary, resolution and the
presentation table are in `app/utils/uiRole.ts`; the choice and its once-per-session prompt live in
the `uiRole` store.

Two roles, three names. `engineer` and `product-manager` both map to the **`full`** surface, and
that is the product decision rather than an oversight: the two do the same job in this app (plan
work on a board, run it, review and merge it), and what makes the question answerable is the copy a
person recognises themselves in. `designer` maps to **`intake`**: the services already on the board,
the work in flight on them, and the routes that bring new work IN (a new task, a task from a tracker
ticket, a task from a design). None of the platform configuration behind that. They are separate
`UiRole` members precisely so one of them can gain a surface the other does not without a migration.

- **The default is the FULL surface, and an unanswered question changes nothing.** Closing the prompt
  writes no choice, so the next launch asks again and the person keeps the whole product in the
  meantime. There is deliberately no "don't ask me again": an unanswered question costs nothing,
  where a wrongly-recorded one costs somebody destinations they need. It is also why the prompt
  yields to every startup advisory (and why the tour offer, in turn, yields to it: the role decides
  which surfaces exist, so a tour picked ahead of it could be about half a product).
- **It is not authorization.** Workspace RBAC (ADR 0025) decides what a request may do and is
  enforced server-side; this decides what the SPA OFFERS, and every role's surface is still gated by
  the caller's permissions on top. Nothing here can widen what a person may do, and everything it
  hides is something they may well be allowed to open, which is why the **way back is reachable from
  inside the narrowed role**: the switcher at the top of the sidebar (rendered in every role) plus a
  command-palette entry, both `intake`.
- **There is NO deployment env pin**, unlike the interface tier. Which tier a fleet of kiosk-ish
  deployments shows is a decision an operator can reasonably make; which JOB the person at the
  keyboard does is not something a build can know.
- **A narrowed role CAPS the interface tier at basic** (`resolveUiMode` takes the surface and answers
  `basic` for `intake`, ahead of the env pin). Resolved rather than merely hidden, so every
  `isAdvanced` reader inside a surface agrees with the narrowed nav without restating the role, and
  the tier switcher is dropped for that role: a control that flipped a tier the resolver fixes would
  be the same lie it refuses to be under an env pin.

The seams, and what a new feature should use rather than reading the store ad hoc:

- **A nav destination** declares `intake: true` in `app/modular/nav-contributions.ts` to survive a
  narrowed role. It is **opt-in**, so a destination added later defaults to the full-surface roles:
  getting that wrong costs one flag, where the other default is a persona that stopped being simple
  without anyone deciding to un-simplify it. Today's set is three (`tutorial`,
  `keyboard-shortcuts`, `ui-role`), and `nav-contributions.spec.ts` pins it against a table naming
  each one's reason, so adding a fourth forces the claim to be written down. The `fullSurface` gate
  rides the same reactive `NavGates` service as `advancedMode`, so a role switch re-gates all three
  shells with no reload; all three axes (role, tier, `gate`) must pass.
- **A deployment's own external tool** declares the same flag, and defaults the same way. The three
  axes live on one `NavGatedContribution` that both `NavContribution` and `ExternalToolContribution`
  extend, and `navSlotFilter` runs the one `navItemVisible` over both slots, because a tool is
  projected onto a nav contribution downstream. A tool filtered by any other expression is a
  registered application that outlives the narrowing every destination beside it obeys, which is how
  the role axis first shipped; `nav-contributions.spec.ts` pins the two slots' verdicts in lockstep
  across the axes rather than trusting the two spellings to stay equal.
- **A surface that narrows inline** reads `useUiRoleStore().fullSurface` (the frame header's bug-hunt
  button, the palette's per-connection integration commands). Same rule as the tier: what remains
  must be exactly what the full surface would have shown, only less of it.
- **A setup prompt** (a "configure this" card over the board) goes through `canActOnSetup`
  (`app/utils/setupAudience.ts`) with the caller from `useSetupActor()`: it combines the role's
  surface with the board and account grants and the owner the server reports for the gap
  (`infraSetupOwners`). A caller who cannot act gets no card. If the gap stops runs they could start,
  it reaches them as one line saying how it gets fixed (`blockingSetupNotice`): the owner to ask, or
  the role switch when they hold the grant and only the designer surface hides the card. A viewer
  gets nothing, and the line honours the card's dismissals. Never a button that opens a screen the
  caller's save is refused on.
- **A tutorial tour whose step clicks a non-`intake` nav entry declares
  `TUTORIAL_REQUIREMENTS.fullSurface`.** Which tours those are is not a judgement call:
  `tutorial-tours.spec.ts` derives the pairing from `navItemVisible`, so a tour that gains such a
  step fails until the requirement is declared. A single STEP that the role removes (the orientation
  tour's interface-tier step) declares `when` instead, so it is dropped rather than reported as an
  abridged tour.

## Interface modes (basic / advanced)

The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
**delivery** surface: plan work on a board, run it, review and merge it: the run/pipeline
options that only exist to override a workspace-level default are left at that default, and
the nav is trimmed to what that loop needs. `advanced` shows everything. The tier resolves in
a fixed order, first match wins:

0. **The [role](#roles-engineer--product-manager--designer)'s surface**, as a ceiling: an `intake`
   role renders `basic` whatever the two below say.
1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`): the deployment pin. Like
   `NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
   set the in-app switcher is a read-only indicator, since a preference the resolver ignores
   would be a lie. An unrecognised value is ignored rather than failing the boot.
2. **The user's own choice**, persisted client-side (the `uiMode` store) and changed from the
   switcher at the top of the sidebar, under the board switcher, or from the **command
   palette** entry, which is deliberately _not_ an advanced item: basic is the default, so the
   route back to the advanced half has to exist inside basic mode.
3. **`basic`.**

That switcher is a **segmented control showing both tiers**, above the fold rather than in the
footer, because basic is the shipped default and it is most users' only sight of the tier: a
dropdown states the current mode but not that another one exists, so the half of the product it
gates stays invisible to anyone who does not open menus to see what is in them. In the collapsed
rail it degrades to one button that flips the tier (with only two modes a toggle is
unambiguous), keeping the current tier's name under the glyph.

The sidebar can independently be **collapsed to an icon rail** (the toggle at its top, lg+
only: below `lg` the navbar is already an off-canvas drawer). The rail preference is
**per-tier**: basic _defaults_ to railed and advanced to expanded, and each tier remembers its
own choice, so an expand in either survives a reload and a round trip through the other.

Two seams carry the tier, and a new feature should use them rather than reading the store ad
hoc where it can be avoided:

- **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
  shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
  palette, toolbar), independently of its RBAC `gate` and of the role's `intake` flag: all
  three must pass. A consumer module's own contributions take the same flag. The bar is **whether the everyday delivery loop needs
  it**, and marking an item does one of two distinguishable things:
  - **Reached another way**; a shortcut whose surface a basic destination also opens, so
    nothing is lost (the Merge / Service-best-practices palette entries into Workspace
    settings, the local-models knob the Model providers hub already offers).
  - **Out of the tier**: the sole route, hidden on purpose, so the capability is _absent_
    from basic mode and the tier switch is the way to it (Sandbox, Kaizen, repo bootstrap,
    and the deployment-wide operator + reports rollups).

  Sole-route items stay in basic when the delivery loop runs on them: the pipeline builder,
  add-from-repo, the fragment library, the infrastructure/ephemeral-env windows, and the workspace /
  model configuration a run actually reads. `nav-contributions.spec.ts` pins the advanced set
  against a table naming each item's kind and reason, so promoting one forces that claim to be
  written down rather than assumed.

- **A less-used option inside a surface** reads `useUiModeStore().isAdvanced`. Hide, never
  disable, and only ever hide an OVERRIDE: what remains must be exactly the default the hidden
  field would have shown, so a basic-mode user never gets different behaviour from an advanced
  one; only fewer choices. An input nothing else supplies (the pipeline, the apriori branches)
  stays in both tiers however advanced it feels.
- **A whole AUTHORING affordance** may be tier-scoped the same way (the frame header's
  recurring-schedule and initiative buttons are advanced-only) but only while the tier hides
  the ability to CREATE, never the ability to SEE. Existing state has to stay legible in basic
  mode through its normal surfaces (a live schedule still badges its task card and opens its
  inspector panel; an initiative is still a block on the board with its own inspector), or the
  tier turns into a way for a user to be acted on by configuration they cannot find.
- **An override control on an EXISTING entity gates on `showOverrideField(isAdvanced, …values)`**
  (`app/utils/uiMode.ts`) rather than on `isAdvanced` alone. Hiding an override is only safe
  while it is unset: always true for a creation form, never guaranteed for a block that a
  teammate on the advanced tier (or the API) already wrote one onto. The helper reveals the
  control, editable, as soon as any value it edits is set (`false` included: a tri-state
  `false` is a choice, not absence), so basic mode can never conceal a setting a run will
  actually use.
- **The tier may also change which of two routes to the same thing LEADS**, and that is not a
  hidden capability: a `pr_ready` task card offers the outcome summary in both tiers and drops
  the raw pull-request chip in basic, because the card the button opens carries that same link
  at the top. What basic mode may never do is remove the only route (the rule above); ordering
  two routes by which one a tier's reader wants first is what the tier is for. **Write the
  condition as that INVARIANT, not as `isAdvanced` alone** (`TaskCard`'s `showPrChip`: keep the
  chip wherever the outcome card is not offered), because the surface that carries the hidden
  half is itself conditional, and two predicates that must agree by coincidence eventually do
  not: the day the leading route hides, `isAdvanced` alone takes the last route with it.

## Agent tiers (basic / intermediate / advanced)

A separate, narrower axis: how deep into the **agent catalog** a surface reaches. Every agent
kind carries a `tier`: `basic` (the everyday delivery loop), `intermediate` (reached for
regularly) or `advanced` (specialist), and the two surfaces that enumerate the catalog, the
**pipeline builder's palette** and the **model preset's per-agent override list**, show the
selected tier and everything below it. They open on `basic`; the `AgentTierSelect` control on
each widens them, with `advanced` showing the whole catalog. The choice is one shared,
persisted preference (the `agentTier` store), because picking the agents a pipeline runs and
picking what each of them runs on are halves of the same job.

- The vocabulary, the default and the cumulative predicate live in `@cat-factory/contracts`
  (`AGENT_TIERS` / `DEFAULT_AGENT_TIER` / `agentTierVisibleAt`), beside
  `purposeAllowsAgentCategory`, so a **deployment-registered kind's** declared tier
  (`presentation.tier`, carried in the workspace snapshot) and the SPA's own built-ins are
  read by one rule. A kind that declares no tier is treated as `intermediate`.
- **This is not the interface mode.** That tier decides which surfaces the whole SPA offers;
  this one decides how much of one surface's catalog is listed. They are independent (an
  advanced-mode user still starts on the basic agent tier) and the tier control is present in
  **both** interface modes, since it is the only route to the kinds it hides.
- A narrowed catalog states what it is holding back (the "n hidden at this tier" hint), and
  the model preset list **always keeps a kind the edited preset already pins a model for**,
  whatever the tier: the same rule `showOverrideField` states for a single field: a row the
  user can neither read nor clear is worse than a longer list.

### The palette's second dial: the pipeline's purpose

The builder's palette narrows on two axes, and both controls sit on one row above the catalog
(`PipelinePurposeSelect` above `AgentTierSelect`), each with its own "n hidden" hint so neither
narrowing reads as an empty catalog. The tier says how deep to look; the **purpose** says what
the pipeline is for (`build` / `bugfix` / `document` / `review` / `research` / `planning`), and the
palette drops the categories that purpose has no use for. `bugfix` is the one pair that shares a
row with another member: it ships code exactly as `build` does, and differs only in being offered
to a `bug` task and withheld from a `feature` one, because a preset that investigates a defect
report and writes a failing reproduction test has neither input on a feature. It reaches past the
palette: the saved-pipeline
library in the builder's third column lists the pipelines built for the purpose being edited, so
one dial narrows both ends of the slideover. The purpose is not a view preference either: it is
saved on the pipeline and also decides which task pickers offer it, which is why the control
writes through to the draft while the tier writes to its own store.

`Pipeline.purpose` is MANDATORY, so there is no unclassified state for any of these surfaces to
invent a policy for: a new draft starts at `build` (what an unclassified pipeline always behaved
as) and the dial only moves it. What each surface still has to read carefully is a purpose the
BUNDLE cannot name, which the persisted, closed vocabulary makes reachable in both directions (a
browser older than a new member, a row older than a retired one).

Purpose is filtered by three predicates in `@cat-factory/contracts`, and the difference between
the first two is the point:

- `purposeSuggestsAgentKind` is **relevance**: what the palette OFFERS. Opinionated (a
  review pipeline designs nothing; a planning pipeline has no pull request to gate), because a
  wrong guess costs one purpose switch. It reads the kind's `category` through
  `purposeSuggestsAgentCategory` and then the kind's OWN `presentation.purposes`, and the two
  INTERSECT: a declaration may only hide more, never buy a kind back into a purpose its section
  is not offered to, which is what keeps relevance inside compatibility whatever a deployment
  declares.
- `purposeAllowsAgentCategory` is **compatibility**: what the builder will SAVE. It states only
  what is contradictory (a pipeline that writes no code carrying an implementation step) and
  drives the draft's conflict warning.
- `pipelineMatchesPurpose` is **membership**: which SAVED pipelines the builder's library lists,
  reduced with the label and archive dials in `utils/pipelineLibrary.ts`. The one dial in that
  column whose control is elsewhere, so it is the one that owes a "n hidden" hint. It may be
  exact where the pickers' `pipelineAllowedForTaskType` is permissive, because it narrows a list
  somebody is BROWSING rather than one they are about to run from: two known purposes never mix,
  while a pipeline whose classifier this build cannot NAME is listed at every purpose rather than
  vanishing from the editor that has to fix it.

The library's purpose is a BROWSING dial of its own, defaulting to the draft's and relaxed by the
hint itself ("show every purpose"). Reading the draft directly is the trap: it is an authoring
field with no "off" setting, so a hint that only NAMES the absence would send the reader to a
control whose every setting narrows and whose every change is saved. A dial that hides rows owes
both a count and a way back, and the way back may not be an edit.

Relevance is a subset of compatibility, asserted over the whole grid in `pipeline.spec.ts`. Keep
it that way: the palette may hide what the save gate tolerates, so tightening the relevance table
never turns a stored pipeline into one its own editor refuses, but offering a kind the save gate
then rejects would be a dead end with the refusal arriving after the work.

**A category is a shelf label, not a statement of what a kind does**, which is why relevance is
asked of the KIND. Keeping `docs` for a `review` pipeline so the Domain Rules Reviewer survives
also handed it the two kinds that WRITE documentation into the repo, and `document` and `research`
had identical rows, so moving the dial between them narrowed nothing at all. A kind that belongs
to one use-case says so in `presentation.purposes` and leaves a section its siblings stay in; the
section keeps deciding for every kind that declares nothing, which is the normal case and the one
a deployment-registered kind falls into for free. Declare it only to opt OUT: it can never widen,
and a list naming only purposes this build cannot name is read as no declaration at all rather
than as excluding everything, the same default-open reading the unknown `purpose` gets. An EMPTY
list is refused at registration instead (`agentPresentationSchema`, and `catalog.spec.ts` for the
static half valibot never parses): the reader cannot tell one from declaring nothing, so it would
offer the kind everywhere its section is offered, which is the inverse of what writing it means.

**Each hint counts what relaxing THAT dial alone would reveal**, which is why each reduction is one
function (`utils/agentPalette.ts` for the catalog, `utils/pipelineLibrary.ts` for the library)
rather than chained filters at the call site. Chaining them
and subtracting the lengths gives the second dial an honest count and hands the first one the whole
rest of the catalog: at the default `basic` tier a `planning` pipeline claimed thirteen kinds hidden
for its purpose when switching back to Build revealed three, the other ten being tier-hidden either
way. So a kind BOTH dials hide is counted by neither, correctly, and a new dial measures itself
against what the others already admit rather than against the raw catalog.

**A purpose or category this build does not recognise narrows nothing.** Both are closed
vocabularies and both are persisted, so a reader is total against the type and partial against the
data: a `Pipeline.purpose` outlives the build that wrote it, and a `presentation.category` arrives
in the snapshot from a kind a deployment registered. Narrow with the schema-derived
`isPipelinePurpose` / `isAgentCategory` before indexing anything by one, never with an optional
call, so adding a member still fails the build. The two predicates read the unknown value through
one helper because they have to agree about it: one narrowing by a purpose the other no longer
recognises is exactly the subset violation above. On the control itself an unrecognised purpose is
NAMED and quoted back rather than left to render blank, which would read as a pipeline nobody
classified while the saved row says otherwise.

Offering such a kind is only half of keeping it: `groupAgentPalette` puts whatever no section
CLAIMED into the trailing custom bucket, derived from the sections rather than from an absent
`category`. A kind whose category has no section matches neither test, so filtering on the absent
one alone deleted it from a palette its own save gate accepts.

## In-app tutorial tours

On first launch (once the board is up and no other startup advisory is open) the app asks
whether the user wants a guided tour. The answer is SAVED per browser (`stores/tutorial.ts`,
persisted like the interface tier): "no thanks" stops the prompt for good, and closing without
answering defers it to the next launch.

**The prompt is the OFFER; the catalogue is the library.** `TutorialCatalogue.vue` (the
sidebar's Help section, the palette, and a button in the prompt's own footer) lists every tour
the deployment ships and lets any of them be started, resumed or repeated at any time. The two
surfaces exist separately because they answer different questions, and the split is what keeps
the prompt a short answerable one rather than a browsing surface. Start / Resume / Repeat /
Back-to-the-tour is decided ONCE for both (`useTutorialLaunch` over the pure `tourState` +
`launchActionFor`), or the same button would mean different things on two screens.

**Which is why a tour can be catalogue-only** (`offeredAtLaunch: false`, read through the pure
`isLaunchOffer`; `useTutorialTours` exposes `offered` for the prompt beside `tours` for the
overlay). The catalog covers the PLATFORM as well as the delivery loop (the engine, the pipeline
builder, the standards library, the integrations), and those tours gate on a PERMISSION rather
than on board state, so every one of them is startable on a brand-new board. Offered unfiltered
they would put six walkthroughs in front of someone whose board has neither a repository nor a
task, burying the two they can act on. The default is OFFERED, so a consumer deployment's tour
appears beside the built-ins with nothing to declare, and a tour cannot fall out of the offer by
omission. It thins an offer, never the library: an un-offered tour is listed, startable, counted
in the progress line and one footer button away, and `requires` remains the only thing that can
hold a tour back, which is always reported.

**The finish card HANDS OFF to the next walkthrough** (`nextTourAfter`, offered beside Done). The
delivery loop is a chain — each tour produces the state the next one requires — and finishing one is
the last moment the product can bring the tutorial up at all: `startTour` writes
`decision: 'accepted'`, which is exactly what stops the launch prompt auto-opening, so without this
the walkthrough a user's own action just unlocked is reachable only by going and finding the
catalogue. It offers ONE tour, launch-offer tours first whatever their `order` (a deployment's
reference tour must not cut into the arc), never one already completed or the one just finished, and
nothing at all when nothing is ready — where the plain Done is the honest ending. It reads the gates
LIVE, which is the one deliberate exception to the held-script rule below: completing `first-task` is
precisely what makes `run-task` ready, so a candidate resolved at tour start would be empty exactly
when it matters. Taking the offer completes this tour first (or its badge stays "not started") and
goes through the same `useTutorialLaunch().launch`, so a suggested tour the user had broken off
earlier RESUMES. **Per-run overlay state resets with the script**, not by the component unmounting:
the handoff completes one tour and starts the next in ONE tick, so `touring` never goes false for a
render and the finished tour's skips would otherwise be counted against the new one.

**A CONTEXTUAL offer catches a tour becoming takeable** (`resolveNudge` over `newlyAvailableTour` +
`useTutorialNudge` + `TutorialNudge.vue`): a corner card, not a modal, since the whole point is the
moment. The trigger is deliberately not a per-surface hook — every tour already declares, as its
`requires`, the predicate that means "you can take this now", so ONE rule over the resolved
catalogue covers the catalog and inherits `navRequirementDrift` unchanged. Four rules bind it. It
fires on a TRANSITION into `ready`, never on the standing state, which would greet every board load
with an offer about a walkthrough available for weeks. Only the launch-offer arc, for the same reason
`offeredAtLaunch` exists. Never twice per tour (`nudgedTourIds`, persisted) and never after an
explicit decline — "no thanks" answered the question about guided tours, not about when it was
asked. And the offer is HELD rather than dropped while a tour or a tutorial window is up, because
the two most valuable moments (a run parked, a run failed) routinely arrive then; it is marked spent
when RAISED, so holding it cannot become nagging.

**What that transition is measured against is the subtle half, and it takes TWO guards** (both in
the pure `resolveNudge`, so they are unit-tested rather than inferred from a watcher; the composable
holds only the ref, because a pure function cannot). Every gate reads a store something fills
asynchronously, so a baseline taken when the composable mounts records "nothing is takeable" and the
app's own startup then reads as a transition, which is the every-board-load greeting the rule exists
to prevent arriving through the mechanism meant to be its cure.

- **`workspace.ready`** gates taking a baseline at all on the snapshot having been fanned out, and
  is re-set per board, which is what makes switching boards RE-SEED rather than offer everything the
  incoming board happens to satisfy.
- **A board-state FINGERPRINT** (`boardStateFingerprint`, over the `boardHas*` gates) is what an
  offer requires to have MOVED. Readiness widening is not the world changing: a permission
  resolving or a capability probe answering makes tours takeable that were "blocked" only because
  the app had not found out yet, and the app finding out about itself is not a moment to interrupt
  anyone about. Those resolutions advance the baseline silently. This is the guard that generalises
  — it needs no list of which stores load late, because none of them describe the world — and it is
  the reason readiness alone was not enough: `workspace.ready` flips before the RBAC access and the
  integration probes have landed.

**The catalogue lists the tours it CANNOT start, and says what would unlock each.** That is the
reason a tour's preconditions are declared (`TutorialRequirement`: an id, a copy key, and the
gate predicate) rather than being an anonymous `when(gates)`. A predicate can only answer "no",
and a list that quietly omits four of six walkthroughs is indistinguishable from a deployment
that ships two: to exactly the user who came looking for the rest. It also forces the two
unavailable cases apart, because they need different reactions: `blocked` names something the
reader can go and do ("A service on the board"), while `not-applicable` (requirements met, but
every step is about a branch this board isn't on) names nothing at all, and telling them to fix
it would send them hunting for a control that was never missing.

**Tour gating therefore does NOT live in `navSlotFilter`**, unlike every other gated slot. A
`SlotFilter` maps slots to slots, so it can only drop; `resolveTourCatalogue` (pure,
gates-nullable, in `utils/tutorial.ts`) returns every tour with its availability and its unmet
requirements, and `useTutorialTours` runs it once: exposing `tours` (what can start now, which
is what the prompt and the overlay have always seen) and `catalogue` (everything, annotated). It
reads the SAME registered `gates` service the nav filter does, through the shared-dependency
`useOptional('gates')`, so the two can never disagree about what this board offers.

**Progress follows the USER, not the browser** (`useTutorialSync` / `useTutorialServer` over
`GET|PUT|DELETE /tutorial/progress`). The browser-persisted store stays what the SPA reads and stays
fully functional with no accounts, no store wired on the facade, or offline; the server row is a
MIRROR, adopted in the snapshot fan-out (`stores/workspace/hydrate.ts`) so the launch prompt decides
whether to appear against the merged state rather than this browser's copy alone. Both id lists are
grow-only sets and are UNIONED on BOTH sides, because two browsers signed in as one person each hold
a full copy and each write it back: a last-writer-wins replace on either side silently drops what the
other learned, and the symptom is a finished walkthrough going back to "not started" days later.
Only `decision` is replaced (a preference, not an accumulating fact), and then only where this browser
is not holding an answer the mirror has not carried yet: without that exception a failed push lets the
next snapshot re-adopt the older server answer, so "No thanks" silently comes back as accepted and
every contextual offer re-arms. "Reset progress" is a DELETE, which is also why the catalogue calls
`useTutorialServer` and not just the store — a local clear alone would be undone by the next snapshot
re-merging the row.

Three rules make fire-and-forget honest rather than merely convenient. Every push carries the WHOLE
local state, so a retry, a racing tab and a stale copy are all the same well-formed write. The
RESPONSE (the merged row) is reconciled back through the store, which is what closes the hole the
server's un-rev-guarded merge leaves: two concurrent merges can lose a writer's ids, because a union
is idempotent under retry but not commutative under concurrency, and the loser's answer comes back
missing something local and re-pushes automatically. And the mirror watches the store's LOCAL
revision counter rather than its state, because adopting the server's own ids is a state change too:
watching the state posts the server's row straight back at it on every fresh-browser board load, and
a reset (whose server side is the DELETE) would race a push of the freshly-emptied state.

**The funnel is counted** (`POST /tutorial/events` → the kernel `OperationalMetrics` counters
`tutorial.tour_started` / `_completed` / `_abandoned`, dimensioned by tour). The events are DERIVED
from the cursor in one watcher rather than emitted from each store action, because those are five
sites and a missing `started` fails nothing — it just biases the number the next decision is made
against. Vue's batching is what makes that work across the handoff: it completes one tour and starts
the next in a single tick, so the cursor goes `A → null → B` and the watcher sees `A → B` with the
completion list one longer, reporting "A completed, B started". A resume counts as a start, which is
deliberate: an attempt is an attempt, and not counting re-entries would make completions exceed
starts. Nothing per-user or per-workspace is recorded, and nothing is stored.

Progress is per tour id. The catalogue's counter is over the WHOLE catalog, not
the runnable part: counting only today's runnable tours would move the denominator every time a
repo was linked, and "2 of 2 completed" on a board with four walkthroughs still waiting reads as
a finished tutorial. `Reset progress` clears the completions, the resume point AND the saved
launch answer, because everyone who asks for it (demoing, handing the app to a colleague) wants
the first-launch experience back; it leaves a RUNNING tour alone, since a click about history
must not end the walkthrough in progress. **It is therefore offered whenever ANY of those three
is set, not only when a tour was taken**: someone who answered "No thanks" and stopped there has
nothing completed and nothing paused, and that saved answer is the whole of what stands between
them and the offer they came to restore.

**The coach marks stand down while a tutorial-owned window is open** (`ownWindowOpen`). The
overlay renders at `z-[70]`, above the app's own modals, because a step legitimately points INTO
one, but no step points into the prompt or the catalogue, so there the same rule would float a
highlight ring and a tooltip over the window the user just opened. The catalogue reaches that
state by design: it is openable mid-tour, which is what the `continue` action is for. The overlay
is SUPPRESSED rather than unmounted, because it holds the running tour's resolved script and a
remount would re-resolve it against gates that may have flipped since the tour started.

The decisions behind this surface, and why each alternative was rejected, are recorded in
[ADR 0036](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/adr/0036-in-app-tutorials.md). This section is the authority on how
the thing WORKS.

A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
control by its `data-testid` (the e2e anchor vocabulary; cover a control that has none by
adding the test id first) and carrying i18n keys for its copy. One shared runtime
(`components/tutorial/TutorialOverlay.vue`) renders every tour: it highlights the current
step's control, places the tooltip (`utils/tutorial.ts` owns the pure geometry + types),
advances on Next or (for `advanceOn: 'target-click'` steps) on the user really clicking
the control, so the app's real response (the actual modal, the actual task) is what the next
step anchors to. `target-click` is for BUTTONS, where the click is the completed action; a
text field keeps Next, or the tooltip would leave the instruction the moment the user clicked
in to type. A step whose anchor never appears within its wait is SKIPPED, because controls
come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
fixed script. Reaching the end having skipped steps is reported on the final card rather than
congratulating the user on a walkthrough they did not see, and a tour that could only ever
be abridged should not be offered at all, which is what each tour's `requires` is for (the
task-creation tour needs board write AND a service frame to add a task to).

**A step carries its own `when(gates)` when its BRANCH, not its control, is the thing that
may not apply.** The two are different facts and only one of them is a defect: a skip means
the control should be here and isn't, while a `when` means this board is not on that branch
of the flow (a run parked on a decision has no approval gate, and the reverse). Reporting the
second as an abridged tour would tell a user who saw exactly the right walkthrough that they
missed half of it, every time. `resolveTourCatalogue` (in `utils/tutorial.ts`) drops the
rejected steps and marks a tour left with none `not-applicable` rather than ready, so a tour
whose every step is branch-specific can never open on an empty cursor. With no gates service
wired at all (a bare install withholds nothing) every branch survives instead, and only one
of them can anchor, so the abridged notice ignores any skipped step that carries a `when`,
which has already declared that not applying is legitimate.

**Gates decide what is OFFERED; the running tour's script is resolved once and HELD.** The
overlay snapshots its tour when it starts rather than re-reading the gated slot on every
flip. This is not an optimisation: gates over live run state flip as a direct result of
following the tour; `answer-park` is offered while something waits for a human, so the
moment the user answers, its `when` goes false. A re-reading overlay tore itself down there,
one step short of its own finish card and with nothing recorded as completed, at exactly the
moment the user succeeded. Holding the script also freezes the branch `resolveTours` chose,
so a step can't be swapped underneath a stationary cursor.

**Gates must mean what the board RENDERS, not what the store holds.** `boardHasOpenDecision`
/ `boardHasPendingApproval` are not the store's raw pending counts: a park on a frame block
has no task card, and a reviewer gate mid-cycle is deliberately suppressed by the card
(`useReviewStage().isBackground`), so either would offer a tour onto a control that isn't
there. `hasActionablePark` (`modular/nav-gates.logic.ts`) is the shared rule; the run gates
are task-scoped for the same reason.

**Fixed proper nouns ride `bodyParams`, not the catalogs.** A step naming the sample
repository slug (`SAMPLE_REPO` in `modular/tutorial-tours.ts`) passes it as a `{repo}`
interpolation, so it is written once in code rather than translated into ten catalogs that
each drift on their own: the same split components make for inline placeholders.

The built-ins come in two halves. The DELIVERY LOOP, end to end, each tour gated on the state the
previous one leaves behind, so the launch prompt only ever offers what this board can demonstrate:
board basics, add a repository (`add-service`), create a task (`first-task`), run it (`run-task`),
answer it when it parks (`answer-park`), read a failure when one comes (`diagnose-failure`), review
and merge the result (`review-merge`). The loop covers work going WRONG on purpose: a first run fails
often, `boardHasFinishedRun` deliberately excludes failures (a result view and a merge control are
not what a failed run renders), and that left the state a new user is most likely to be in as the
only one on the arc with no walkthrough. Then the PLATFORM behind it, catalogue-only: connect an
engine (`wire-models`), assemble a flow (`design-pipeline`), curate the standards agents read
(`agent-standards`), link the systems a run talks to (`connect-systems`), where runs execute
(`prepare-infrastructure`), review by panel (`panel-reviews`), the shared services designs build on
(`share-services`). Each of those covers ONE surface and ends there, because the surface opens as a
modal over the sidebar it was reached from, so a later step could not click another sidebar entry
anyway, and each declares exactly the permission that renders the entry it clicks, since a weaker
requirement offers a tour to someone with no such control and it then reports itself abridged.

**That pairing is DERIVED from the nav catalog, not restated.** A step whose anchor IS a nav
entry's `testId` is checked by `navRequirementDrift` (`tutorial-tours.spec.ts`, beside the anchor
guard) against that `NavContribution`'s OWN `gate`: over every combination of the `NavGates`
booleans, a gate set satisfying the tour's `requires` must also be one the entry RENDERS in.
Restating the permission in a spec's gate literal is what this replaced, and it could not catch
either edit that really breaks the pairing, because neither one touches the tour. Tightening an
entry's `gate` is the obvious one. The subtler one is marking it `advanced: true`, which hides it
from BASIC mode; basic is the shipped default, so that tour would be offered to nearly everyone
and find nothing. "Renders" therefore means the gate AND the tier, and a tour that wants an
advanced entry has to declare the tier as a requirement of its own.

**A SECTION can hide itself the same way, one level below anything that guard can see.**
`panel-reviews` clicks `nav-model-config`, a BASIC-mode entry, but the consensus section inside that
panel renders on `uiMode.isAdvanced || groups.hasGroups` — so on the shipped default tier a workspace
that has never made a group renders nothing for the anchored step to find. `navRequirementDrift` pairs
a tour only against a NAV entry's visibility, so this one is declared by hand (`advancedTier`) and
pinned by its own named case in `tutorial-tours.spec.ts`. Any tour anchoring INSIDE a surface owes the
same check of what that surface's own `v-if`s read.

Two deliberate asymmetries about click-to-advance: `run-task` points at Start without it,
because starting a run spends real model budget and nobody should discover they agreed to that by
following a tutorial; `design-pipeline` points at Save without it, because Save is DISABLED until
the draft holds a step, and a click-to-advance step whose control cannot be clicked has no Next
button either, so it strands the tour.

Two runtime constraints worth knowing before changing the overlay: it must keep
`pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
pointerdown; without both, the tooltip's own buttons go inert and pressing one closes the
user's half-filled form. And everything that DECIDES (skip direction, wait budget,
target-click matching, which skips count as abridged) lives in
`components/tutorial/TutorialOverlay.logic.ts` so it is unit-tested; the SFC keeps only the
DOM work. Target-click matching is by SELECTOR, not by the highlighted element:
several anchors (`task-card`, `task-resolve`, `run-step`) render once per board item and the
ring can only sit on one of them, so requiring the click to land on that one left a user who
clicked the card the copy asked for with no way forward; such a step renders no Next.

**An anchor that is on the page but off SCREEN is revealed before it is pointed at.** An
element scrolled out of a panel or panned off the board still HAS layout boxes, so it passes
the visibility check, and the ring was drawn at off-screen coordinates while the tooltip
clamped to a viewport edge, leaving the user reading "click this" beside nothing. The two
`task-card` steps hit this hardest, since they anchor whichever card is first in the DOM.
`needsReveal` (in `utils/tutorial.ts`, unit-tested) decides, measuring against
`min(anchorArea, viewportArea)` so a control bigger than the viewport (`board-canvas`,
`sidebar`) is judged on how much of the SCREEN it fills rather than on a fraction of its own
area it could never clear. The mechanism then depends on the container: the board is a
transform-panned Vue Flow canvas, where `scrollIntoView` does nothing and the camera has to
move instead (clamped to the current zoom, or fitting one button would throw away the user's
view of their board), and everything else is an ordinary scroll. `boardNodeIdFor` asks the
DOM which it is, rather than keying off the target id: the same id is a canvas node on the
board and a plain row in a panel. A reveal is attempted at most once per step, because both
mechanisms are animations longer than a tracking tick.

**Tracking is event-driven once an anchor is held**: only the hunt for a not-yet-mounted
anchor polls fast, and it is bounded by the step's wait budget. Movement arrives from scroll
(capture phase, so every scroll container counts), window resize, a `ResizeObserver` on the
anchor, and the board camera: with a slow backstop tick that also RE-RESOLVES the selector,
which is what lets a step re-anchor when its control is replaced underneath it. Every one of
those re-measures is coalesced into one per animation frame, because `measure()` reads layout
and then writes it, and capture-phase scroll fires for every container many times a frame.

**Accessibility.** The card is a non-modal `dialog` and deliberately not a focus trap: half
the catalog asks the user to operate the real control behind it. Focus moves onto the card
when the tour starts and on every Next/Back (without that a keyboard user has to tab the
whole page, since the overlay is teleported to the end of `body`) but never on a
`target-click` advance, where the app is opening a modal that rightly autofocuses its own
first field and the NEXT step is usually the one telling the user to type in it. That is a
decision, so it is `shouldFocusCard` in the logic module with a test on it, not an `if` at
the call site: it was an inline one, and a call site that forgot it is exactly how the card
came to steal focus from the modal it had just opened.

Step changes are announced through a separate `role="status"` region rather than `aria-live`
on the card, because the card's entire contents are replaced per step and a wholesale subtree
swap inside a dialog is not reliably announced. Two things about that region are load-bearing
and easy to undo by accident: it lives OUTSIDE the overlay's `v-if` and its text lands a tick
after the node does, because assistive tech announces a CHANGE to a live region and routinely
says nothing about one that was inserted already populated, which would silently cost the
first step of every tour. And it is the SOLE announcement: the card carries no
`aria-describedby`, or the body would be read a second time on every focus move.

Motion is honoured on both sides: `motion-safe:` on the ring transition and the searching
spinner, and an instant scroll and camera move under `prefers-reduced-motion`.

**Breaking off a tour leaves a resume point.** Esc and Skip are both easy to reach (one by
accident, one to get the overlay out of the way for a moment) and what they discarded was
the whole walkthrough. `stopTour()` records where it stopped and the prompt offers Resume
instead of only Start. Session-only, like the cursor itself: within a session the board is
still in the state the tour left it in, which is exactly what a DOM-anchored position needs.
The store validates no index (it knows nothing about which tours exist), so the overlay
clamps a resume that lands past the end of a script the gates have thinned since, and the
runtime's own bail-out on an unresolvable tour passes `resumable: false`, or resuming would
put the user straight back into the same dead overlay. There is ONE slot, and starting a tour
clears only that tour's own entry: another tour's position is not this action's to discard,
and it loses the slot soon enough, when this one is broken off past step 0.

The catalog is the `tutorialTours` slot: first-party tours live in
`modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
`registerAppModule`; they appear in the prompt and the catalogue beside the built-ins, held
back per tour by its own `requires` (resolved against the same reactive gates service the nav
uses). A consumer writes its own requirement objects with its own copy keys; the first-party
ones are shared constants (`TUTORIAL_REQUIREMENTS`), because a second copy of "a service on the
board" is a second sentence to keep in step with the gate it describes. Completion is persisted
per tour id, so renaming an id resets its state.

**A built-in tour's anchors are drift-guarded** (`tutorial-tours.spec.ts`), because they are
the one thing about a tour that nothing else in the build checks: a renamed `data-testid`
passes typecheck, lint and the whole e2e suite, and several anchors have no other consumer at
all. The failure it prevents is worse than a dead step: those steps carry no `when`, so the
miss counts as an unexpected skip and every user lands on a permanent "you missed N steps"
notice, a false claim the tour goes on making in production with nothing red anywhere. The
guard scans the layer for both ways an id is named: written onto an element, or declared as a
`testId` field on a data contribution (the whole `nav-*` family reaches the DOM that way). It
is scoped to the built-in catalog, since a consumer's tours anchor on its own layer.

## Real-time store coherence: avoid the full-refresh CLOBBER

The recurring product bug behind most e2e flakes: a stale full-snapshot refresh clobbering newer
live state. The SPA has two delivery shapes and mixing them wrong drops live-added state with NO
event left to restore it.

- **Know how your entity is delivered.** Targeted events (`execution`/`bootstrap`/`initiative`)
  carry the entity and `upsert` it, so they don't clobber. A `board` event is delivered EITHER
  way and the backend decides per change: it carries `block` when the change is fully described
  by one (a spawned task, a field edit, a dependency toggle, a move), and carries none when it is
  not (a removal, a reparent, a resize, a blueprint reconcile). A payload-less `board` event still
  means a debounced full `workspace.refresh()`, where `hydrate` REPLACES whole lists. Routing
  lives in `composables/workspaceStream/applyWorkspaceEvent.ts`, whose `switch` carries a `never`
  guard: a new `WorkspaceEvent` member fails the BUILD rather than falling through to nothing. The
  emit-site decision lives in `BoardService.emitBoardChanged`'s doc comment. Prefer a targeted
  upsert for anything that must appear reliably.
- **Two blocks are refused a payload at the wire, on EVERY event that carries one.** Kernel's
  `deliverableBoardBlock` is the single gate (both facades' `boardChanged` AND `bootstrapChanged`
  assemble through it, via `boardWireEvent`/`bootstrapWireEvent`), so a new emitter cannot
  reintroduce either by forgetting: a service FRAME, whose position and size are a per-board
  `WorkspaceMount` override that one shared payload cannot state correctly on the several boards a
  fan-out reaches; and a headless `internal` anchor block, which `composeBoard` filters out of
  every snapshot and which would therefore render as a card no later read can remove. Both degrade
  to the coarse signal, so nothing is lost but the refresh. A bootstrap's frame is always the first
  case, which is why the `bootstrap` event's job rides live while the frame's own transitions
  arrive as coarse `board` events beside it.
- **Full refreshes go through the ONE funnel, which is what makes them monotonic.**
  `workspace.refresh()` IS the funnel (`stores/workspace/refreshFunnel.ts`), so the ~35 direct
  post-mutation call sites need no opt-in and a new one inherits it. It SERIALIZES: at most one
  snapshot fetch is outstanding, so two cannot resolve out of order and no sequence stamp is
  needed. It is deliberately not plain single-flight: a caller arriving mid-fetch joins a single
  QUEUED follow-up rather than the in-flight request, because a caller that mutated and then
  refreshed is entitled to a snapshot read AFTER its call. Do not reintroduce an unguarded
  `hydrate(await fetch())`, and never add a second refresh path beside the funnel.
- **Serializing makes one stalled request everyone's problem, so the slot is BOUNDED.** The API
  client sets no timeout, and a hung fetch behind a shared slot stops every later refresh, the
  coarse-event resync and the retry chain at once. The funnel puts a deadline on its own read and
  ABORTS it, so a dead connection surfaces as an ordinary failure rather than a wedge; a snapshot
  arriving after the deadline is never applied. Anything else that serializes work behind one slot
  owes the same bound. The same argument is why a dedupe must QUEUE the later ask rather than drop it
  (`environmentTest.reconcileRun`): the outstanding read may predate the state the later ask exists
  to observe.
- **Never gate readiness on a snapshot a later resync can undo.** The on-connect resync flips
  `connected` only after it settles (which is why e2e gates on `data-connected`). A resync that
  stands down because a NEWER one started must hand its caller that newer one, not resolve: a
  stand-down is not a reconcile, and `socket.onopen` cannot tell the difference.
- **A REPLACE-style `hydrate` must never silently drop live-only state.** Either fold that state
  into the snapshot or reconcile rather than replace. The funnel above orders refreshes against
  each OTHER and does nothing when ONE slow fetch straddles a live event, so every such store also
  takes a WATERMARK: `refresh()` captures each one's `hydrateBaseline()`
  before the fetch (`LiveWriteBaselines`) and its `hydrate` keeps whatever was written after it.
  `board` and `notifications` are the two today; `execution` gets the same protection from the
  server `rev` it carries. Whether the store can re-derive the dropped state is what decides how
  bad the bug is: a block's status arrives again on the run's next transition, while a
  notification is pushed ONCE, so dropping the card leaves a parked run nothing can surface (the
  `pr-review` spec's flaky 30s wait on `notifications-bell`). A watermark that tracks only
  INSERTS is half a guard: a card resolved live must also stay gone, or a snapshot read while it
  was open resurrects an action the server has already taken.
- **An action's OPTIMISTIC ECHO is a clobber too, and it bypasses both guards above.** A store
  that awaits a mutation and then assigns the returned sub-state onto the cached run
  (`step.forkDecision`, `step.prReview`, `step.judge`, `step.followUps`) is writing straight past
  `upsert`'s `rev` check. Where the mutation WAKES THE DRIVER, the driver's next emit routinely
  beats the HTTP response, so the echo puts the run back; if the run then parks, nothing emits
  again and the newer state is gone for good (the fork-chat reply that vanished, leaving a
  "thinking…" bubble spinning). Every echo therefore goes through
  `execution.echoAfter(executionId, send, apply)`, which captures the run's `rev` before the
  request and drops the echo if anything advanced it. Never hand-roll the await-then-assign.
- **The coarse-event debounce is capped, and it checks coverage before firing.**
  (`composables/workspaceStream/coarseRefresh.ts`, which owns both ways a full resync is asked for:
  the on-connect reconcile and the `board`-event fan-out.) Trailing-only
  re-armed forever under a sustained sub-300ms event stream, so the board stopped resyncing exactly
  when the workspace was busiest; there is now a max-wait. Before fetching it asks the funnel
  whether a snapshot issued after the latest coarse event has already hydrated
  (`refreshMark()` / `hydratedSince()`) and stands down if so, which is what stops a mutation that
  refreshes directly AND raises a coarse event from paying for two snapshots. That skip rests on
  the server emitting a coarse `board` event only after committing what it announces.
- **The board snapshot's runs are a LEAN PROJECTION, so a hydrate can WITHHOLD what it does not
  clobber.** `projectExecutionForBoard` (contracts) strips each step's captured prose from the
  snapshot's executions and stamps the instance `projected`; a live `execution` event still carries
  the whole run. Two rules follow, and both are the clobber rule in a new shape. A step-detail
  surface fetches the run before rendering (`execution.ensureFull`, asked by the two overlay HOSTS
  so no window can forget) and reads `stepHasOutput`, never `step.output`, for the "there is prose
  here" affordance. And the store's reconcile carries the withheld fields forward ONLY at an equal
  `rev`: at the same revision the withheld prose IS what the cache holds, one revision later it may
  not be, so a newer projection replaces and the open overlay re-fetches.
- **`execution.instances` is a `shallowRef`, so a write must both TRIGGER and change IDENTITY.**
  Nothing under the ref is a reactive proxy any more, so the only dependency a reader can hold is
  the ref itself, and nearly every reader holds it through an identity-stable chain
  (`computed(() => getInstance(id))` to `steps[i]` to one field). `triggerRef` re-runs the FIRST
  computed in that chain, and Vue stops propagating when its recomputed value is `===` the previous
  one, so a run patched in place reaches that computed and nothing below it. `upsert` replaces the
  run object; `echoAfter` applies the action store's patch to a COPY of the run and its steps and
  swaps that in. A missing trigger and an in-place patch are both SILENT, so
  `stores/execution.spec.ts` pins each write shape twice: once on the array, once through the
  `getInstance` chain a window actually reads.
- **A record keyed by block id is written PER KEY, never replaced.** The review-family caches
  (`requirements`, `clarity`, `brainstorm`, `consensus`, `docInterview`, `initiative`) are deep
  reactive refs holding a `Record<blockId, T>`, so `x.value = { ...x.value, [id]: v }` is a write
  to the REF: a dependency every reader shares, whatever key it reads. One review event therefore
  woke every card on the board. `x.value[id] = v` keeps the invalidation on the key that changed,
  and Vue still tracks a key read before it exists, so a first write reaches its waiting reader.
  The exception is a HYDRATE, which replaces the record wholesale on purpose: a snapshot is
  authoritative for EXISTENCE, so anything it omits has been deleted. This is the opposite rule
  from `execution.instances` above, and for the opposite reason: that one is a `shallowRef`.
- **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
  `stores/workspace/refreshFunnel.spec.ts` for the funnel's own rules, `stores/execution.spec.ts`
  for echoes): drive the two orderings and assert the fresher one wins. The per-key rule above has
  one table for the whole family, `stores/perKeyWrites.spec.ts`: a store joining it is a row there,
  and each row counts a `computed`'s evaluations across an event for a DIFFERENT block, plus the
  first write to a key a reader read while it was absent. What the record is keyed BY is per store
  (`stores/requirements.spec.ts` also covers the stage read, whose pending-recommendation half has
  to answer off the block's own review object rather than a computed over the record).

## Internationalization (i18n) authoring

All user-facing SPA copy goes through `@nuxtjs/i18n`; never hard-code a display string. This
layer ships the base `en` locale, and a downstream deployment overrides by dropping its own files
(the per-layer deep-merge is the override seam, consumer wins key by key). Migration status:
[`docs/internal/localization.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/internal/localization.md).

- `i18n/locales/<locale>.json`: the catalogs (the v9+ `i18n/` convention, NOT `app/locales/`).
- `i18n/i18n.config.ts`: runtime vue-i18n behaviour only (fallback locale, the plural
  selectors, the named `numberFormats`/`datetimeFormats`). Messages are deliberately NOT here
  so the module can deep-merge across the `extends` chain. Referenced as the BARE filename
  `vueI18n: 'i18n.config.ts'`, never `layerDir`-anchored.
- `i18n/plural-rules.ts`: the per-locale plural selectors, kept beside the config as pure
  logic so they unit-test standalone. See **Plural forms** below.
- `package.json` `files` MUST include `"i18n"`. Release-blocking.

**Adding a string**: add the key to `en.json` under the feature namespace, resolve with
`t('feature.area.key')`, and format numbers/dates through `$n`/`$d` (the named formats), never
raw `Intl`.

**Key conventions**: one namespace per feature; **leaf keys mirror the enum/code value verbatim**
so a dynamic lookup is total; **no cross-key concatenation** (a full sentence is ONE key with
`{named}` placeholders, plurals use the pipe form).

**Component mechanics that bite:**

- `useI18n` is auto-imported; destructure in `<script setup>` and use those fns in the template
  so the typed-key check sees literal keys. Never `import` it.
- Plural + interpolation: `t(key, { vendor, count }, count)`, where the THIRD arg is the choice.
- **Code/format-example placeholders stay INLINE**, not in the catalog; required when they
  contain `{`/`}` (vue-i18n metacharacters). Only prose placeholders get a key. Same for brand
  names.
- **No HTML in message bodies**: drop mid-sentence `<strong>`, or use `<i18n-t>` with slots.
- For a vendor/enum-keyed set, build an array of STATIC literal `t()` keys, one per member.
  Reserve the runtime-assembled key + exhaustive `Record` guard for lookups genuinely unknown
  until runtime.
- Straight quotes, no em-dashes in new entries.

**Plural forms: how MANY forms an entry carries is part of its contract.** Most locales run on
vue-i18n's built-in selector, where a 2-form entry is `one | other` and a 3-form entry is
`zero | one | other` (the leading zero form is a copy nicety, not a CLDR category: "no
participants" beats "0 participants"). `pl`, `uk` and `he` override that selector in
`i18n/plural-rules.ts` because the built-in one cannot express their agreement, so their entries
carry the locale's CLDR categories instead, optionally behind the same zero form:

| Locale      | CLDR forms            | With a zero form              |
| ----------- | --------------------- | ----------------------------- |
| `pl` / `uk` | `one \| few \| many`  | `zero \| one \| few \| many`  |
| `he`        | `one \| two \| other` | `zero \| one \| two \| other` |

Dropping a form does not drop a case, it RE-POINTS every remaining slot onto a different count,
so `i18n/plural-forms.spec.ts` fails the build on an entry whose form count is neither shape (and
on a key `en` pluralizes that one of those three renders flat). Neither other i18n gate can see
this: the key exists and it moved with `en`, which is all they check. `plural-rules.spec.ts`
separately pins each selector against `Intl.PluralRules`, so a hand-written rule that disagrees
with the platform's own CLDR data fails a test rather than shipping.

**Translator descriptions (`@<key>` siblings): default to NONE.** They live only in `en.json` and
are notes to a translator, never runtime data. Add one ONLY when a competent translator seeing
the English and the key path could plausibly get it wrong: homograph / part-of-speech ambiguity
(`@close`), proper nouns that must NOT be translated (`@kaizen`), umbrella strings hiding cases
the text doesn't show, placeholder/format constraints, or plural-form requirements beyond
English's two.

**Presenting a backend failure**: raw backend prose is DETAIL, never the description. Even with
no `reason` to key off, a failure is described from its STATUS CLASS through an exhaustive
`Record<ApiErrorCode, …>` of translated copy, and the untranslated `message` (plus a validation
400's `issues` and the envelope's `requestId`) is reached through a "Show details" disclosure
that reveals it in place. So a non-English user is never handed English as the primary
explanation, and the elaborate operator remedies the backend does write stay one click away
rather than being dropped. A new failure-presenting surface copies that split (the
`usePipelineErrorToast.ts` pattern; the wire vocabulary comes from `@cat-factory/contracts`).

**Drift guards** (oxlint has no `no-raw-text` rule, so these replace it):

1. **Typed message keys** make a statically written unknown `t('literal.key')` a typecheck
   failure. This does NOT cover a runtime-assembled key.
2. For enum→key lookups, guard with an **exhaustive `Record<TheEnum, string>`** keyed off the
   contracts union, plus a runtime `te()` fallback. Never rely on tier 1 alone for a
   reason/status-keyed lookup.
3. `pnpm --filter @cat-factory/app run i18n:check` hard-fails on MISSING keys and reports unused
   ones as non-blocking warnings (the catalog legitimately seeds keys ahead of use).
4. **Locale parity**: `i18n-locale-parity.mjs --since origin/<base>` requires a PR that adds,
   changes, or removes an `en.json` key to make the SAME change in every other locale. It is
   change-coupling against the merge-base, NOT full key parity.
5. **Plural shape**: `i18n/plural-forms.spec.ts` fails on a `pl`/`uk`/`he` entry carrying the
   wrong number of forms (see **Plural forms** above), which the other three gates all pass.

**Translate for real: NEVER ship an English string as a non-`en` value.** The parity gate checks
only that the key exists, so it will pass a verbatim English copy, and that copy is a bug. The
only values that may legitimately match `en` are proper nouns identical across languages
(`DeepSeek`, `AWS Bedrock`). If you genuinely cannot produce a translation, say so in the PR
rather than committing a placeholder that reads as done.

Migration is incremental: when you touch a component, lift its visible copy into the catalog.

## Extending the layer (consumer modules)

A deployment can contribute its own components (result windows, nav entries, inspector
panels, agent-kind palette data) plus two DATA-only seams that need no components at all:
its own applications in an **External tools** sidebar section (`externalTools`, each
resolving its URL from the acting user / open workspace / this board's custom fields) and the
**custom workspace metadata fields** those resolvers read (`workspaceMetadataFields`, edited
on the Metadata tab of Workspace settings). All of it **without forking**, through the
auto-imported
`registerAppModule` seam (the frontend analogue of the backend's `registerAgentKind` /
`registerGate` registries). The authoring walkthrough, the reusable shared building blocks
(`ResultWindowShell`, the `StepRunMeta` run-metadata block, `useResultView`, …), and the
namespacing / degradation rules are in
[`app/docs/consumer-extensions.md`](./app/docs/consumer-extensions.md); a full worked
example ships in [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tree/main/deploy/frontend) (the `acme:security` module).

## Key UI surfaces

- **Board canvas** (`components/board`): `BoardCanvas` + `nodes/` (`BlockNode`,
  `FrameSwimlanes` / `TaskLane` / `LaneGroup` / `LaneTask`, `TaskCard`), dependency edges,
  the per-block `AgentFailureCard` (the ONE failure block per task view: a cause line from
  `utils/failureCause.ts`, one action, and the backend's text behind Details) /
  `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. Tasks are laid out in status
  lanes rather than at coordinates (see [Task swimlanes](#task-swimlanes)); the board-level
  order/grouping override is `LaneViewControl` in the toolbar. A running task card expands
  its build pipeline (`TaskPipelineMini`) on hover at any zoom level, and across every
  on-screen card past the `steps` zoom band: the two grants are combined in the
  `taskExpansion` store and driven by `useTaskExpansion`.
- **Sidebar & chrome** (`components/layout`): board/account switchers, palettes
  entry points, the language + [interface-mode](#interface-modes-basic--advanced)
  switchers, the Views switcher (`WorkViewSwitcher`), the `SpendWarningBanner`, and the toolbar
  (zoom, LOD, and the decision counter, which opens the queue view).
- **Queue view** (`components/queue`): `QueueView` and `QueueCard`, the board's second view. It
  regroups the swimlane verdict and reuses the board card's actions; see [Queue view](#queue-view).
- **Palettes** (`components/palettes`): drag blocks, pipelines and agents onto
  the board.
- **Describe new work** (`components/queue/DescribeWorkModal.vue`, `utils/describeWork.ts`): the
  one-sentence intake, from Create, the palette and the queue header. It files a feature or a bug
  from a sentence and a service, titled by the first sentence; the overrides sit behind one
  disclosure, and the merge policy and model preset follow the full form's tier rule
  (`showOverrideField`), so basic mode and the designer role see the pipeline only.
  It rebuilds no other kind: "More kinds" and "Attach context" hand the text to `AddTaskModal`,
  which stays the one home of those rules. Both share `utils/reviewFriction.ts` for the review-debt 409.
- **Setup checklist** (`components/setup/SetupChecklistPanel.vue`, `utils/setupChecklist.ts`):
  every prerequisite for "a task can run and merge" in one list, for people who can change the
  setup. It adds no probe of its own: each row reads what its banner or startup dialog already
  reads (`useSetupChecklist`) and opens the same surface. "Checking" and "missing" are separate
  states, because an unanswered probe and an absent capability are opposite facts.
- **Notifications inbox** (`components/layout/NotificationsInbox.vue`, `utils/notificationRoutes.ts`):
  a card that asks for a person carries its verb ("Answer", "Review", ...) as the primary button,
  and "Mark read" is secondary. `NOTIFICATION_ROUTES` is exhaustive over the notification types and
  holds each one's verb and destination, so the title link and the button cannot open different
  places. A route that can only select the task is named "Open".
- **Inspector** (`components/panels` + `panels/inspector`): per-block tabs:
  structure, dependencies, model + fragment picker, live execution, and linked
  docs/issues/scenarios. Decisions resolve via `DecisionModal`. A `review` task
  additionally leads with `TaskReviewTarget`, linking the pull request it reviews:
  distinct from the execution panel's link to the PR a run PRODUCED, which a review
  task never has.
- **Outcome summary** (`components/outcome`): the run-keyed result window, and what
  the board card and the inspector open to "read the result". It renders the reduction
  in `utils/runOutcome.ts` (requirement coverage joined to the service spec, the
  tester's verdict, the captured views, the recorded checks) and keeps the pull request
  at the top, so the diff stays one click away rather than being the starting point.
  The only result window keyed by a RUN rather than a step: it opens with
  `stepIndex: null` and composes from the whole instance, which is also why it passes
  no `stepRef` to the shell (there is no step to restart from). Available in both
  interface tiers; what `basic` changes is which affordance leads (see
  [interface modes](#interface-modes-basic--advanced)).
  Two rules bind anything added to it. Every entry point gates on `hasOutcomeToShow`
  from the same reduction, so the card is never offered onto sections that all read
  "nothing here". And the window is BLOCK-keyed with the run riding along: a block
  naming a run the store never hydrated is a distinct fact from a task that never ran
  (`RunUnavailableGap`), so a new section reports that case rather than composing from
  the empty step list, which would read as a pipeline that produced nothing.
- **Pipeline builder** (`components/pipeline`): assemble/edit agent chains and
  watch `PipelineProgress`. `PipelinePicker` (+ its `PipelinePreview` pane) is the
  single way a pipeline is chosen anywhere (add-task, run settings, the recurring
  schedule, the focus view's Run menu) so every surface explains a pipeline by the
  ordered steps it will run rather than by its name alone.
- **Context attachments** (`components/context`): `ContextAttachmentFields`, the
  shared staged-attachment form used by both the add-task and create-initiative
  modals. Picks are held locally and import-and-linked once the block exists (see
  `composables/useContextLinking`), because linking needs a block id. Both hosts
  attach to the SAME per-block linkage, so the inspector's `TaskContextDocs` /
  `TaskContextIssues` sections render for a task AND an initiative: an initiative's
  attachments would otherwise be invisible the moment the create modal closed.
- **Integrations**: modals/panels for `github` (the source-control panel, shared
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
  (acceptance), and `fragments` (the prompt-fragment library).
- **Model providers**: `ModelProvidersHub.vue`, the sibling hub for the ENGINES
  (OpenRouter, vendor keys, personal subscriptions, own-machine runners). Kept out
  of the Integrations hub on purpose: an integration is optional context in or
  output out, while a provider is what executes the work, so a deployment with none
  connected runs nothing at all. **A new provider-shaped connection belongs here,
  never in `IntegrationsHub.vue`.** Both hubs, plus the user-scoped `PersonalSetupModal`,
  share the `IntegrationBackTitle` Back control, which returns to whichever hub set
  its came-from marker (`ui.cameFrom{Integrations,ModelProviders,Personal}`): a panel
  reachable from more than one hub must not hard-code its return.
- **Auth** (`components/auth`): `AuthGate` / `LoginScreen` / `UserMenu`; the app
  is gated when the backend requires sign-in.

## Where a surface lives

Two placements are load-bearing enough to state, because putting a new one in the
wrong place is invisible until a user cannot find it:

- **The sidebar section is a claim about what the destination IS.** `models` is the
  model layer: the engines, the per-agent model choice, and the surfaces that
  evaluate a prompt+agent+model combination (Sandbox, Kaizen); `integrations` the
  optional EXTERNAL systems; `infrastructure` where agent containers and test
  environments run; `configuration` workspace/account settings. A surface that
  connects to nothing does not belong in `integrations` however configuration-shaped
  it feels. `nav-contributions.spec.ts` pins the section order and each section's
  membership.
- **A flow that edits ONE entity's config is a section of the window that owns that
  config, not a sibling nav entry.** The guided Docker Compose environment setup
  (`ComposeEnvironmentSetupSection.vue` → `EnvironmentSetupWizard.vue`) lives inside
  Infrastructure → Test environments for exactly this reason: it writes a service's
  Compose recipe plus the workspace's Compose handler, both configured in that tab.
  It also carries a full "how it works / when you need this / when you can skip it"
  explanation there rather than a one-line hint, since the decision to run it has to
  be made before opening a five-minute wizard.
- **"It talks to an external service" is not what puts a surface in `integrations`.**
  Private package registries connect to npmjs.com and GitHub Packages and still belong
  in Infrastructure, because the question they answer is _what may a container install
  from_: a property of where agents RUN, which is what the Infrastructure window is
  for. `integrations` is for a system the WORKSPACE links in and would still be a
  coherent product without. Ask which question the destination answers, not whether a
  credential leaves the building. A surface moved between sections must also move its
  entry point: leaving a hub row behind as a shortcut splits the answer across two
  places, so the row goes and the window's tab becomes the single route in.

## Develop & test

This package has no `dev` or `lint` script of its own: it is a layer, consumed through `extends`,
and linting is whole-tree from the repo root (AGENTS.md). The `AGENTS.md` verify checklist carries
the same list, plus the install-free frontend guards.

```bash
# From the repo root:
pnpm install
pnpm dev:frontend                                    # Nuxt dev server via deploy/frontend; set NUXT_PUBLIC_API_BASE to a running backend
pnpm exec turbo run typecheck --filter=@cat-factory/app   # typecheck goes through Turbo
pnpm lint                                            # oxlint + oxfmt over the whole tree

# From frontend/app:
pnpm exec vitest run <file>                          # run the spec your change touched
pnpm i18n:check                                      # catalog is well-formed
pnpm i18n:parity                                     # locales are in parity

# Component stories, from the repo root:
pnpm --filter @cat-factory/storybook run storybook      # http://localhost:6006
pnpm --filter @cat-factory/storybook run test:stories   # every story in headless Chromium
```

A component's stories sit beside it as `Foo.stories.ts`; the fixtures and helpers they share are in
`app/stories/` (neither ships in the package). Conventions, helpers and the traps the setup already
paid for: [Component stories](https://github.com/kibertoad/cat-factory/blob/main/docs/internal/storybook.md).

To see the SPA with data while you work on it, run it against the e2e backend: it needs only a
Postgres, fakes the LLM, the agent runner and GitHub, and can seed boards. See
[Running the SPA against the e2e backend](https://github.com/kibertoad/cat-factory/blob/main/backend/internal/e2e/README.md#running-the-spa-against-the-e2e-backend-ui-development).

> Building/deploying the static site is covered in the deployment docs: see the
> [top-level README → Deployment](https://github.com/kibertoad/cat-factory/blob/main/README.md#deployment) and
> [`deploy/frontend/README.md`](https://github.com/kibertoad/cat-factory/blob/main/deploy/frontend/README.md).

---
_Source: https://npm.io/package/@cat-factory/app · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
