@cat-factory/app
v0.288.1
Published
Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an
Downloads
37,617
Readme
@cat-factory/app: Frontend (Nuxt layer)
The user-facing app, packaged as a reusable Nuxt 4 layer: a single-page app
that runs entirely in the browser and renders the architecture board, drives agent
pipelines, and reflects live execution. A deployment consumes it via
extends: ['@cat-factory/app'] (see deploy/frontend).
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
- Task swimlanes
- 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.
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: single route (
pages/index.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/index.vue | The only route: mounts the sidebar, canvas, toolbar, inspector, focus view, and all modals. |
| 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
(defaultDescriptorValues for the initial values, setDescriptorValue / setDescriptorCheckbox /
toggleDescriptorGroupValue for one edit): 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 stays a plain
toast.add with translated title and description (components/settings/ModelConfigurationPanel.vue).
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.
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.
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.
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 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
st
