@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).
It talks to the backend Worker over REST and a single
WebSocket, sharing wire types from
@cat-factory/contracts.
The SPA source lives under app/ (the Nuxt srcDir).
Table of contents
- What it is
- Tech stack
- Layout
- Nuxt UI: agent tooling and overrides
- Task swimlanes
- Queue view
- Roles (engineer / product manager / designer)
- Interface modes (basic / advanced)
- Agent tiers (basic / intermediate / advanced)
- In-app tutorial tours
- Real-time store coherence
- Internationalization (i18n) authoring
- Extending the layer (consumer modules)
- Key UI surfaces
- 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 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, 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.
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). |
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
requestIdtravels with it.mountRequestLoggingputs 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.
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
loadingprop takes:loading. Never spin its icon by hand (:ui="{ leadingIcon: 'animate-spin' }", or an:iconswapped to a loader while busy). One exception:UButton'sloadingalso 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#leadingslot instead, with a comment saying why.UInputand the other inputs do not disable onloading. - A panel or list waiting on its first fetch shows
USkeletonrows sized like the content they replace, so it does not show nothing and then everything. Its fill isbg-accented, set once inapp.config.ts: Nuxt UI's default skeleton isbg-elevated, which disappears on thebg-elevatedandapp-950window bodies and cards many panels paint. An ACTION in progress (a button press, a sync) is not a content load and keeps:loadingor a spinner. - A bare glyph showing work in progress is
<Spinner>(components/common/Spinner.vue), the one file that writesanimate-spin.:spinningcovers a status icon that spins in one state only (a running step), andnamedefaults to Nuxt UI's own loading icon so it matches the one:loadingdraws. It spins undermotion-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-400for the five grey shades with no role token,bg-app-canvasfor the board canvas (the one surface below the page background; in light the page surfaceapp-950sits at neutral-100 rather than the mirror's 50 and the canvas at 200, so the ~350 translucentbg-default/NNpanels keep an edge), andtext-app-warning-300/bg-app-error-950/40/border-app-success-800for 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,500its own midpoint), so a300text on a950/40fill reads on both surfaces from one class list. Every value is a reference into the--ui-color-<alias>-<n>ramps, so a theme that remapswarningrecolours every warning surface.primaryhas NO numbered token: brand accents use the bare alias, which is what the editor's primary slider andblackAsPrimaryaddress. - 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,400in dark and600in 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 throughtint()(utils/colorTint.ts), which also accepts the hex a deployment-registered kind may send. A theme recolours a hue through itstokensblock. - 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.redandroseboth meanerror. - Never a fixed
white/blackutility (bg-white,text-black,border-white/5,bg-white/[0.02]): invisible or wrong in light mode. Useborder-inverted(a selection ring),border-default/bg-muted(faint chrome) ortext-inverted(text over a filled surface). A line that genuinely needs a fixed value says why with afixed-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-xsup (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, andtext-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 istext-2xs, nottext-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 saysleading-*, which wins over a step's paired value anyway. - Never a literal size (
text-[11px],text-[0.6875rem]). A theme document'sfontSizeis applied as a rootfont-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 aUBadge 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, thefont: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 atype-literal-ok:comment, on that line or the one above. - A section eyebrow is
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 withas(divby default;h3/h4for a real heading,labelwith an:idfor a field; a collapsing section is aUButtonaroundas="span") and owns SPACING through the ordinaryclassattribute. 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 istext-mutedand not the faintertext-dimmedfor contrast: at 11pxtext-dimmedis 2.63:1 on a light surface, under WCAG AA, wheretext-mutedis 4.76:1. (The SPA's wider use oftext-dimmedoutside 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 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 shorthandrounded-(--x). Welded to one number the way a pixel font size is, and unnameable besides, so aUBadgebeside 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 acalc():calc(var(--ui-radius) * 2)is therounded-lgstep, andcalc(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 inlineblock and emits a variable only where the COMPILED stylesheet graph references it, sovar(--radius-lg)is correct until the last other reference is deleted and then silently computes to 0.--ui-radiusis 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 logicalborder-start-start-radius) and the JSborderRadius/'border-radius'spelling a:stylebinding uses included. The pill spellings (9999px,50%) and0are exempt, and a shorthand is read value by value, so50% 50% 8px 8pxis still caught on the8px. scripts/check-frontend-radius.mjs(CI) bans both. It needs no notion of where a class list lives, becauserounded-[androunded-(are not English words. A line that genuinely needs a fixed radius says why with aradius-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, 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_CLASSfor 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_CLASSfor 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, a window inside 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, 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.
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.vueisUModalwith the width applied throughui.content; every other prop, listener and slot passes through, and a call site still passes otheruislots and non-width content classes (a stacked dialog'sz-[70]). The widths live inutils/overlaySize.ts. It is a wrapper rather than anapp.config.tsvariant becauseUModalhas nosizeprop and passes onlytransition,fullscreen,overlayandscrollableinto its variant function, so asizevariant would never be selected.- An always-
fullscreenmodal has no width and stays a plainUModal. A modal bound fullscreen only sometimes (:fullscreen="isPhone") is centered everywhere else, so it takes anAppModalsize, whichAppModaldrops 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, 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 rawUModal(orLazyUModal) outside the wrapper, anAppModalthat names its own width utility (max-w-*,min-w-*,w-*) inclassor in thecontentslot 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. AUSlideoveris 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 itstype=;<select>→USelect(USelectMenuwhen it needs searching,UInputMenuwhen the list is open-ended);<textarea>→UTextarea;<table>→UTablewith column definitions;<details>→UCollapsible;<form>→UForm;<a href>→ULink;<datalist>→UInputMenuwithmode="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, not restated here. - An icon-only button is
common/IconButton.vue, which wrapsUButtonin aUTooltipand applieslabelas the tooltip text ANDaria-label. Atitleattribute is not a tooltip: it never fires on touch, is unstyled, waits about a second, and looks nothing like the app's other hints.IconButtonandCopyButtonown their tooltip, so passing either atitlerenders a second hint over the first. Atitleon a LABELLEDUButtonis still fine: there it is a hover hint beside the control's own name rather than the name itself.classonIconButtonlands 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-onlyUButtonwhose only name is atitle. - 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 araw-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 (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 mdfor bothUButtonandUBadge, stated inapp.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-ifchain as one button and av-forlist 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 asolid-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 withsize: 'lg'orvariants: { 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 onUButtonandUBadge(either casing, either quote style, or a key of av-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 fromapp.config.tsitself. Inside aUFieldGrouporUButtonGroupit does not checksize: the group sets the size there, so an explicitsizeoverrides the group rather than restating the default. It reads template tags only, so aButtonPropsobject (a toast'sactions, a modal's:close) is not checked. A site that must keep a literal says why with avariant-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-uiskill 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.jsonis offered once for approval on the first session in the repo (re-offered when the file changes); a decline is remembered, andclaude mcp reset-project-choicesun-sticks it. Another tool that readsAGENTS.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), not from these repo files. The@nuxt/uinpm package does not ship the skill, so it cannot move withpnpm update: it is vendored from thev4branch ofnuxt/ui(which tracks the 4.x line) and pinned by convention to the Nuxt UI major inpackage.json(@nuxt/ui@^4.11). Refresh it by re-vendoringskills/nuxt-ui/from that branch (ornpx skills add nuxt/ui) when the major moves. The vendored files are kept byte-for-byte as upstream:.oxfmtrc.jsonexempts them from the formatter andscripts/check-doc-links.mjsskips them, so a refresh stays a clean diff even when upstream ships a link this repo's own doc rules would reject. - The
nuxt-uiMCP server (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/i18nand lands in the catalog. Rule. - Schema validation goes through valibot, never zod. The skill's
forms.mdrecipe opens withimport * as z from 'zod'and validates throughz.object({...}); this package depends onvalibot(and@toad-contracts/valibot) and has nozod, so azodimport fails typecheck and "fixing" it pulls in a dependency the repo deliberately lacks (which then has to clear the 24hminimumReleaseAgegate). Validate with valibot, mirroring the wire contracts (@cat-factory/contracts). - A backend-declared form renders through
DescriptorFields.vue, not a hand-builtUForm. The skill teaches the hand-rolledUForm+UFormFieldshape for every form; when the backend declares the fields and the SPA only collects them, that shape is forbidden here. Rule. - Toasts go through the two funnels, never a bare
toast.add. The skill usesuseToast()freely; here a failed backend call is reported withusePipelineErrorToast().present(error, key)and every other toast withuseActionToast().success|info|warning|error(key, opts). Rule. - Loading is
:loading,USkeletonor<Spinner>; empty is<EmptyState>. Never a hand-writtenanimate-spin. Rule. - Icon-only buttons carry an accessible name. Use
common/IconButton.vue(source), which applieslabelas both the tooltip andaria-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. Rule. - Confirmation dialogs use the shared
useConfirm. The skill showsuseOverlay()per call site; here callersawait useConfirm().confirm({...})(source) 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_STATUSis aRecord<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 tounclassified→needs_you, never toundefined, 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 reportsparked— 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_pausedandapprovalall stop work dead and need three unrelated actions, which is whyblocking_reasonis 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
resolveFrameOverlapsis 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): 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.
- 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.
useTaskLaneClassifieris the one classifier the frame swimlanes and the queue share;utils/queueSections.tsonly 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
(
BoardTopOverlayswithin-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 (
resolveUiModetakes the surface and answersbasicforintake, ahead of the env pin). Resolved rather than merely hidden, so everyisAdvancedreader 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: trueinapp/modular/nav-contributions.tsto 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), andnav-contributions.spec.tspins it against a table naming each one's reason, so adding a fourth forces the claim to be written down. ThefullSurfacegate rides the same reactiveNavGatesservice asadvancedMode, 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
NavGatedContributionthat bothNavContributionandExternalToolContributionextend, andnavSlotFilterruns the onenavItemVisibleover 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.tspins 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 fromuseSetupActor(): 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-
intakenav entry declaresTUTORIAL_REQUIREMENTS.fullSurface. Which tours those are is not a judgement call:tutorial-tours.spec.tsderives the pairing fromnavItemVisible, 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) declareswheninstead, 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:
- The role's surface, as a ceiling: an
intakerole rendersbasicwhatever the two below say. NUXT_PUBLIC_UI_MODE(basic|advanced): the deployment pin. LikeNUXT_PUBLIC_API_BASEit 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.- The user's own choice, persisted client-side (the
uiModestore) 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. 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: trueinapp/modular/nav-contributions.ts. The sharednavSlotFilterdrops it in basic mode across all three shells (sidebar, command palette, toolbar), independently of its RBACgateand of the role'sintakeflag: 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.tspins 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 onisAdvancedalone. 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 (falseincluded: a tri-statefalseis 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_readytask 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 asisAdvancedalone (TaskCard'sshowPrChip: 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,isAdvancedalone 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), besidepurposeAllowsAgentCategory, 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 asintermediate. - 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
showOverrideFieldstates 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:
purposeSuggestsAgentKindis 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'scategorythroughpurposeSuggestsAgentCategoryand then the kind's OWNpresentation.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.purposeAllowsAgentCategoryis 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.pipelineMatchesPurposeis membership: which SAVED pipelines the builder's library lists, reduced with the label and archive dials inutils/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'pipelineAllowedForTaskTypeis 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.readygates 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 theboardHas*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.readyflips 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. 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-ifs 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 andupsertit, so they don't clobber. Aboardevent is delivered EITHER way and the backend decides per change: it carriesblockwhen 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-lessboardevent still means a debounced fullworkspace.refresh(), wherehydrateREPLACES whole lists. Routing lives incomposables/workspaceStream/applyWorkspaceEvent.ts, whoseswitchcarries aneverguard: a newWorkspaceEventmember fails the BUILD rather than falling through to nothing. The emit-site decision lives inBoardService.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
deliverableBoardBlockis the single gate (both facades'boardChangedANDbootstrapChangedassemble through it, viaboardWireEvent/bootstrapWireEvent), so a new emitter cannot reintroduce either by forgetting: a service FRAME, whose position and size are a per-boardWorkspaceMountoverride that one shared payload cannot state correctly on the several boards a fan-out reaches; and a headlessinternalanchor block, whichcomposeBoardfilters 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 thebootstrapevent's job rides live while the frame's own transitions arrive as coarseboardevents 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 unguardedhydrate(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
connectedonly after it settles (which is why e2e gates ondata-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, andsocket.onopencannot tell the difference. - A REPLACE-style
hydratemust 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'shydrateBaseline()before the fetch (LiveWriteBaselines) and itshydratekeeps whatever was written after it.boardandnotificationsare the two today;executiongets the same protection from the serverrevit 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 (thepr-reviewspec's flaky 30s wait onnotifications-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 pastupsert'srevcheck. 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 throughexecution.echoAfter(executionId, send, apply), which captures the run'srevbefore 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 theboard-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 coarseboardevent 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 instanceprojected; a liveexecutionevent 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 readsstepHasOutput, neverstep.output, for the "there is prose here" affordance. And the store's reconcile carries the withheld fields forward ONLY at an equalrev: 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.instancesis ashallowRef, 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))tosteps[i]to one field).triggerRefre-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.upsertreplaces the run object;echoAfterapplies 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, sostores/execution.spec.tspins each write shape twice: once on the array, once through thegetInstancechain 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 aRecord<blockId, T>, sox.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] = vkeeps 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 fromexecution.instancesabove, and for the opposite reason: that one is ashallowRef. - Pin it with a store-level unit test (
stores/workspace.spec.tsfor refreshes,stores/workspace/refreshFunnel.spec.tsfor the funnel's own rules,stores/execution.spec.tsfor 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 acomputed'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.tsalso 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.
i18n/locales/<locale>.json: the catalogs (the v9+i18n/convention, NOTapp/locales/).i18n/i18n.config.ts: runtime vue-i18n behaviour only (fallback locale, the plural selectors, the namednumberFormats/datetimeFormats). Messages are deliberately NOT here so the module can deep-merge across theextendschain. Referenced as the BARE filenamevueI18n: 'i18n.config.ts', neverlayerDir-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.jsonfilesMUST 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:
useI18nis auto-imported; destructure in<script setup>and use those fns in the template so the typed-key check sees literal keys. Neverimportit.- 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 + exhaustiveRecordguard 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):
- Typed message keys make a statically written unknown
t('literal.key')a typecheck failure. This does NOT cover a runtime-assembled key. - For enum→key lookups, guard with an exhaustive
Record<TheEnum, string>keyed off the contracts union, plus a runtimete()fallback. Never rely on tier 1 alone for a reason/status-keyed lookup. pnpm --filter @cat-factory/app run i18n:checkhard-fails on MISSING keys and reports unused ones as non-blocking warnings (the catalog legitimately seeds keys ahead of use).- Locale parity:
i18n-locale-parity.mjs --since origin/<base>requires a PR that adds, changes, or removes anen.jsonkey to make the SAME change in every other locale. It is change-coupling against the merge-base, NOT full key parity. - Plural shape:
i18n/plural-forms.spec.tsfails on apl/uk/heentry 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; a full worked
example ships in 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-blockAgentFailureCard(the ONE failure block per task view: a cause line fromutils/failureCause.ts, one action, and the backend's text behind Details) /AgentStopButton, and a deep-zoomfocus/BlockFocusView. Tasks are laid out in status lanes rather than at coordinates (see Task swimlanes); the board-level order/grouping override isLaneViewControlin the toolbar. A running task card expands its build pipeline (TaskPipelineMini) on hover at any zoom level, and across every on-screen card past thestepszoom band: the two grants are combined in thetaskExpansionstore and driven byuseTaskExpansion. - Sidebar & chrome (
components/layout): board/account switchers, palettes entry points, the language + interface-mode switchers, the Views switcher (WorkViewSwitcher), theSpendWarningBanner, and the toolbar (zoom, LOD, and the decision counter, which opens the queue view). - Queue view (
components/queue):QueueViewandQueueCard, the board's second view. It regroups the swimlane verdict and reuses the board card's actions; see 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 toAddTaskModal, which stays the one home of those rules. Both shareutils/reviewFriction.tsfor 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_ROUTESis 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 viaDecisionModal. Areviewtask additionally leads withTaskReviewTarget, 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 inutils/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 withstepIndex: nulland composes from the whole instance, which is also why it passes nostepRefto the shell (there is no step to restart from). Available in both interface tiers; whatbasicchanges is which affordance leads (see interface modes). Two rules bind anything added to it. Every entry point gates onhasOutcomeToShowfrom 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 watchPipelineProgress.PipelinePicker(+ itsPipelinePreviewpane) 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 (seecomposables/useContextLinking), because linking needs a block id. Both hosts attach to the SAME per-block linkage, so the inspector'sTaskContextDocs/TaskContextIssuessections 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), andfragments(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 inIntegrationsHub.vue. Both hubs, plus the user-scopedPersonalSetupModal, share theIntegrationBackTitleBack 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.
modelsis the model layer: the engines, the per-agent model choice, and the surfaces that evaluate a prompt+agent+model combination (Sandbox, Kaizen);integrationsthe optional EXTERNAL systems;infrastructurewhere agent containers and test environments run;configurationworkspace/account settings. A surface that connects to nothing does not belong inintegrationshowever configuration-shaped it feels.nav-contributions.spec.tspins 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.integrationsis 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.
# 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.
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.
Building/deploying the static site is covered in the deployment docs: see the top-level README → Deployment and
deploy/frontend/README.md.