@cat-factory/app 0.207.0 → 0.208.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # `@cat-factory/app` Frontend (Nuxt layer)
1
+ # `@cat-factory/app`: Frontend (Nuxt layer)
2
2
 
3
3
  The user-facing app, packaged as a **reusable Nuxt 4 layer**: a single-page app
4
4
  that runs entirely in the browser and renders the architecture board, drives agent
@@ -26,8 +26,8 @@ The SPA source lives under `app/` (the Nuxt srcDir).
26
26
  A spatial planning surface. You lay out a system as a **board** of frames
27
27
  (services), modules and tasks on a [Vue Flow](https://vueflow.dev) canvas, wire up
28
28
  dependencies, attach requirements, and apply **agent pipelines** to blocks.
29
- Execution streams back in real time step/subtask progress bars, decision
30
- prompts, failures with retry so the canvas doubles as a live dashboard.
29
+ Execution streams back in real time (step/subtask progress bars, decision
30
+ prompts, failures with retry) so the canvas doubles as a live dashboard.
31
31
 
32
32
  It is a thin client: there is **no business logic here**. Every mutation calls the
33
33
  Worker API and the stores hydrate from server snapshots and live updates pushed
@@ -36,11 +36,11 @@ over the WebSocket. How that sync works is written up in
36
36
 
37
37
  ## Tech stack
38
38
 
39
- - **Nuxt 4 / Vue 3** SPA single route (`pages/index.vue`).
40
- - **Pinia** (+ `pinia-plugin-persistedstate`) feature stores.
41
- - **Vue Flow** (`core`, `background`, `controls`, `node-resizer`) the canvas.
42
- - **Nuxt UI** + Tailwind components and styling.
43
- - **VueUse** composable utilities.
39
+ - **Nuxt 4 / Vue 3** SPA: single route (`pages/index.vue`).
40
+ - **Pinia** (+ `pinia-plugin-persistedstate`): feature stores.
41
+ - **Vue Flow** (`core`, `background`, `controls`, `node-resizer`): the canvas.
42
+ - **Nuxt UI** + Tailwind: components and styling.
43
+ - **VueUse**: composable utilities.
44
44
  - Lint/format via **oxlint** + **oxfmt**; tests via **vitest** + **happy-dom**.
45
45
 
46
46
  ## Layout
@@ -48,7 +48,7 @@ over the WebSocket. How that sync works is written up in
48
48
  | Path | Contents |
49
49
  | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
50
50
  | `app.vue` | Root; wraps the page in `AuthGate`. |
51
- | `pages/index.vue` | The only route mounts the sidebar, canvas, toolbar, inspector, focus view, and all modals. |
51
+ | `pages/index.vue` | The only route: mounts the sidebar, canvas, toolbar, inspector, focus view, and all modals. |
52
52
  | `components/` | UI grouped by area (see [Key UI surfaces](#key-ui-surfaces)). |
53
53
  | `composables/` | `useApi` (typed client), `useWorkspaceStream` (WebSocket sync), `useBlockDrag`, `useBlockQueries`, `useBoardFlow`, `useSemanticZoom`, `useDepLabels`. |
54
54
  | `stores/` | Pinia stores, one per feature domain. |
@@ -64,14 +64,14 @@ component exists. So nothing a store reaches for at setup time may require an ac
64
64
  component instance.
65
65
 
66
66
  The one that bites is **`useI18n()`, which throws `MUST_BE_CALL_SETUP_TOP` outside a
67
- component** and because it happens inside a plugin, Nuxt's error boundary replaces the
67
+ component**, and because it happens inside a plugin, Nuxt's error boundary replaces the
68
68
  whole app with its 500 page rather than surfacing a broken feature. Resolve translations
69
69
  through the Nuxt app's global i18n instance instead (`useNuxtApp().$i18n`, typed as
70
70
  `ReturnType<typeof useI18n>`), as `stores/board.ts`, `stores/recurringPipelines.ts` and
71
71
  `composables/usePipelineErrorToast.ts` do. This costs no typed-message-key coverage: tier 1
72
72
  only sees literal keys written in a `<script setup>`, never in a `.ts` store or composable.
73
73
 
74
- The blast radius is why this is a rule rather than a preference a store reached one call
74
+ The blast radius is why this is a rule rather than a preference: a store reached one call
75
75
  earlier than before takes the entire SPA down at boot, and the unit suite cannot see it
76
76
  (nothing there installs the plugin). Every e2e spec does, because every one of them boots
77
77
  the app.
@@ -89,17 +89,17 @@ The failure is silent, which is why this is a rule rather than a preference. An
89
89
  ## Interface modes (basic / advanced)
90
90
 
91
91
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
92
- **delivery** surface plan work on a board, run it, review and merge it: the run/pipeline
92
+ **delivery** surface: plan work on a board, run it, review and merge it: the run/pipeline
93
93
  options that only exist to override a workspace-level default are left at that default, and
94
94
  the nav is trimmed to what that loop needs. `advanced` shows everything. The tier resolves in
95
95
  a fixed order, first match wins:
96
96
 
97
- 1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`) the deployment pin. Like
97
+ 1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`): the deployment pin. Like
98
98
  `NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
99
99
  set the in-app switcher is a read-only indicator, since a preference the resolver ignores
100
100
  would be a lie. An unrecognised value is ignored rather than failing the boot.
101
101
  2. **The user's own choice**, persisted client-side (the `uiMode` store) and changed from the
102
- switcher at the top of the sidebar, under the board switcher or from the **command
102
+ switcher at the top of the sidebar, under the board switcher, or from the **command
103
103
  palette** entry, which is deliberately _not_ an advanced item: basic is the default, so the
104
104
  route back to the advanced half has to exist inside basic mode.
105
105
  3. **`basic`.**
@@ -112,7 +112,7 @@ rail it degrades to one button that flips the tier (with only two modes a toggle
112
112
  unambiguous), keeping the current tier's name under the glyph.
113
113
 
114
114
  The sidebar can independently be **collapsed to an icon rail** (the toggle at its top, lg+
115
- only below `lg` the navbar is already an off-canvas drawer). The rail preference is
115
+ only: below `lg` the navbar is already an off-canvas drawer). The rail preference is
116
116
  **per-tier**: basic _defaults_ to railed and advanced to expanded, and each tier remembers its
117
117
  own choice, so an expand in either survives a reload and a round trip through the other.
118
118
 
@@ -121,13 +121,13 @@ hoc where it can be avoided:
121
121
 
122
122
  - **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
123
123
  shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
124
- palette, toolbar), independently of its RBAC `gate` both must pass. A consumer module's
124
+ palette, toolbar), independently of its RBAC `gate`: both must pass. A consumer module's
125
125
  own contributions take the same flag. The bar is **whether the everyday delivery loop needs
126
126
  it**, and marking an item does one of two distinguishable things:
127
- - **Reached another way** a shortcut whose surface a basic destination also opens, so
127
+ - **Reached another way**; a shortcut whose surface a basic destination also opens, so
128
128
  nothing is lost (the Merge / Service-best-practices palette entries into Workspace
129
129
  settings, the local-models knob the Model providers hub already offers).
130
- - **Out of the tier** the sole route, hidden on purpose, so the capability is _absent_
130
+ - **Out of the tier**: the sole route, hidden on purpose, so the capability is _absent_
131
131
  from basic mode and the tier switch is the way to it (Sandbox, Kaizen, repo bootstrap,
132
132
  and the deployment-wide operator + reports rollups).
133
133
 
@@ -140,27 +140,27 @@ hoc where it can be avoided:
140
140
  - **A less-used option inside a surface** reads `useUiModeStore().isAdvanced`. Hide, never
141
141
  disable, and only ever hide an OVERRIDE: what remains must be exactly the default the hidden
142
142
  field would have shown, so a basic-mode user never gets different behaviour from an advanced
143
- one only fewer choices. An input nothing else supplies (the pipeline, the apriori branches)
143
+ one; only fewer choices. An input nothing else supplies (the pipeline, the apriori branches)
144
144
  stays in both tiers however advanced it feels.
145
- - **A whole AUTHORING affordance** may be tier-scoped the same way the frame header's
146
- recurring-schedule and initiative buttons are advanced-only but only while the tier hides
145
+ - **A whole AUTHORING affordance** may be tier-scoped the same way (the frame header's
146
+ recurring-schedule and initiative buttons are advanced-only) but only while the tier hides
147
147
  the ability to CREATE, never the ability to SEE. Existing state has to stay legible in basic
148
148
  mode through its normal surfaces (a live schedule still badges its task card and opens its
149
149
  inspector panel; an initiative is still a block on the board with its own inspector), or the
150
150
  tier turns into a way for a user to be acted on by configuration they cannot find.
151
151
  - **An override control on an EXISTING entity gates on `showOverrideField(isAdvanced, …values)`**
152
152
  (`app/utils/uiMode.ts`) rather than on `isAdvanced` alone. Hiding an override is only safe
153
- while it is unset always true for a creation form, never guaranteed for a block that a
153
+ while it is unset: always true for a creation form, never guaranteed for a block that a
154
154
  teammate on the advanced tier (or the API) already wrote one onto. The helper reveals the
155
- control, editable, as soon as any value it edits is set (`false` included a tri-state
155
+ control, editable, as soon as any value it edits is set (`false` included: a tri-state
156
156
  `false` is a choice, not absence), so basic mode can never conceal a setting a run will
157
157
  actually use.
158
158
 
159
159
  ## Agent tiers (basic / intermediate / advanced)
160
160
 
161
161
  A separate, narrower axis: how deep into the **agent catalog** a surface reaches. Every agent
162
- kind carries a `tier` `basic` (the everyday delivery loop), `intermediate` (reached for
163
- regularly) or `advanced` (specialist) and the two surfaces that enumerate the catalog, the
162
+ kind carries a `tier`: `basic` (the everyday delivery loop), `intermediate` (reached for
163
+ regularly) or `advanced` (specialist), and the two surfaces that enumerate the catalog, the
164
164
  **pipeline builder's palette** and the **model preset's per-agent override list**, show the
165
165
  selected tier and everything below it. They open on `basic`; the `AgentTierSelect` control on
166
166
  each widens them, with `advanced` showing the whole catalog. The choice is one shared,
@@ -169,16 +169,16 @@ picking what each of them runs on are halves of the same job.
169
169
 
170
170
  - The vocabulary, the default and the cumulative predicate live in `@cat-factory/contracts`
171
171
  (`AGENT_TIERS` / `DEFAULT_AGENT_TIER` / `agentTierVisibleAt`), beside
172
- `purposeAllowsAgentCategory` so a **deployment-registered kind's** declared tier
172
+ `purposeAllowsAgentCategory`, so a **deployment-registered kind's** declared tier
173
173
  (`presentation.tier`, carried in the workspace snapshot) and the SPA's own built-ins are
174
174
  read by one rule. A kind that declares no tier is treated as `intermediate`.
175
175
  - **This is not the interface mode.** That tier decides which surfaces the whole SPA offers;
176
- this one decides how much of one surface's catalog is listed. They are independent an
177
- advanced-mode user still starts on the basic agent tier and the tier control is present in
176
+ this one decides how much of one surface's catalog is listed. They are independent (an
177
+ advanced-mode user still starts on the basic agent tier) and the tier control is present in
178
178
  **both** interface modes, since it is the only route to the kinds it hides.
179
179
  - A narrowed catalog states what it is holding back (the "n hidden at this tier" hint), and
180
180
  the model preset list **always keeps a kind the edited preset already pins a model for**,
181
- whatever the tier the same rule `showOverrideField` states for a single field: a row the
181
+ whatever the tier: the same rule `showOverrideField` states for a single field: a row the
182
182
  user can neither read nor clear is worse than a longer list.
183
183
 
184
184
  ## In-app tutorial tours
@@ -188,69 +188,81 @@ whether the user wants a guided tour. The answer is SAVED per browser (`stores/t
188
188
  persisted like the interface tier): "no thanks" stops the prompt for good, and closing without
189
189
  answering defers it to the next launch.
190
190
 
191
- **The prompt is the OFFER; the catalogue is the library.** `TutorialCatalogue.vue` the
192
- sidebar's Help section, the palette, and a button in the prompt's own footer lists every tour
191
+ **The prompt is the OFFER; the catalogue is the library.** `TutorialCatalogue.vue` (the
192
+ sidebar's Help section, the palette, and a button in the prompt's own footer) lists every tour
193
193
  the deployment ships and lets any of them be started, resumed or repeated at any time. The two
194
194
  surfaces exist separately because they answer different questions, and the split is what keeps
195
195
  the prompt a short answerable one rather than a browsing surface. Start / Resume / Repeat /
196
196
  Back-to-the-tour is decided ONCE for both (`useTutorialLaunch` over the pure `tourState` +
197
197
  `launchActionFor`), or the same button would mean different things on two screens.
198
198
 
199
+ **Which is why a tour can be catalogue-only** (`offeredAtLaunch: false`, read through the pure
200
+ `isLaunchOffer`; `useTutorialTours` exposes `offered` for the prompt beside `tours` for the
201
+ overlay). The catalog covers the PLATFORM as well as the delivery loop (the engine, the pipeline
202
+ builder, the standards library, the integrations), and those tours gate on a PERMISSION rather
203
+ than on board state, so every one of them is startable on a brand-new board. Offered unfiltered
204
+ they would put six walkthroughs in front of someone whose board has neither a repository nor a
205
+ task, burying the two they can act on. The default is OFFERED, so a consumer deployment's tour
206
+ appears beside the built-ins with nothing to declare, and a tour cannot fall out of the offer by
207
+ omission. It thins an offer, never the library: an un-offered tour is listed, startable, counted
208
+ in the progress line and one footer button away, and `requires` remains the only thing that can
209
+ hold a tour back, which is always reported.
210
+
199
211
  **The catalogue lists the tours it CANNOT start, and says what would unlock each.** That is the
200
212
  reason a tour's preconditions are declared (`TutorialRequirement`: an id, a copy key, and the
201
213
  gate predicate) rather than being an anonymous `when(gates)`. A predicate can only answer "no",
202
214
  and a list that quietly omits four of six walkthroughs is indistinguishable from a deployment
203
- that ships two to exactly the user who came looking for the rest. It also forces the two
215
+ that ships two: to exactly the user who came looking for the rest. It also forces the two
204
216
  unavailable cases apart, because they need different reactions: `blocked` names something the
205
- reader can go and do ("A service on the board"), while `not-applicable` requirements met, but
206
- every step is about a branch this board isn't on names nothing at all, and telling them to fix
217
+ reader can go and do ("A service on the board"), while `not-applicable` (requirements met, but
218
+ every step is about a branch this board isn't on) names nothing at all, and telling them to fix
207
219
  it would send them hunting for a control that was never missing.
208
220
 
209
221
  **Tour gating therefore does NOT live in `navSlotFilter`**, unlike every other gated slot. A
210
222
  `SlotFilter` maps slots to slots, so it can only drop; `resolveTourCatalogue` (pure,
211
223
  gates-nullable, in `utils/tutorial.ts`) returns every tour with its availability and its unmet
212
- requirements, and `useTutorialTours` runs it once exposing `tours` (what can start now, which
224
+ requirements, and `useTutorialTours` runs it once: exposing `tours` (what can start now, which
213
225
  is what the prompt and the overlay have always seen) and `catalogue` (everything, annotated). It
214
226
  reads the SAME registered `gates` service the nav filter does, through the shared-dependency
215
227
  `useOptional('gates')`, so the two can never disagree about what this board offers.
216
228
 
217
229
  Progress is per tour id and per browser. The catalogue's counter is over the WHOLE catalog, not
218
- the runnable part counting only today's runnable tours would move the denominator every time a
230
+ the runnable part: counting only today's runnable tours would move the denominator every time a
219
231
  repo was linked, and "2 of 2 completed" on a board with four walkthroughs still waiting reads as
220
232
  a finished tutorial. `Reset progress` clears the completions, the resume point AND the saved
221
233
  launch answer, because everyone who asks for it (demoing, handing the app to a colleague) wants
222
234
  the first-launch experience back; it leaves a RUNNING tour alone, since a click about history
223
235
  must not end the walkthrough in progress. **It is therefore offered whenever ANY of those three
224
- is set, not only when a tour was taken** someone who answered "No thanks" and stopped there has
236
+ is set, not only when a tour was taken**: someone who answered "No thanks" and stopped there has
225
237
  nothing completed and nothing paused, and that saved answer is the whole of what stands between
226
238
  them and the offer they came to restore.
227
239
 
228
240
  **The coach marks stand down while a tutorial-owned window is open** (`ownWindowOpen`). The
229
241
  overlay renders at `z-[70]`, above the app's own modals, because a step legitimately points INTO
230
- one but no step points into the prompt or the catalogue, so there the same rule would float a
242
+ one, but no step points into the prompt or the catalogue, so there the same rule would float a
231
243
  highlight ring and a tooltip over the window the user just opened. The catalogue reaches that
232
244
  state by design: it is openable mid-tour, which is what the `continue` action is for. The overlay
233
245
  is SUPPRESSED rather than unmounted, because it holds the running tour's resolved script and a
234
246
  remount would re-resolve it against gates that may have flipped since the tour started.
235
247
 
236
- The arc this surface is being built along what has landed, what each slice learned, and what
237
- is still open is tracked in
248
+ The arc this surface is being built along (what has landed, what each slice learned, and what
249
+ is still open) is tracked in
238
250
  [`docs/initiatives/in-app-tutorials.md`](../../docs/initiatives/in-app-tutorials.md). This
239
251
  section is the authority on how the thing WORKS.
240
252
 
241
253
  A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
242
- control by its `data-testid` (the e2e anchor vocabulary cover a control that has none by
254
+ control by its `data-testid` (the e2e anchor vocabulary; cover a control that has none by
243
255
  adding the test id first) and carrying i18n keys for its copy. One shared runtime
244
256
  (`components/tutorial/TutorialOverlay.vue`) renders every tour: it highlights the current
245
257
  step's control, places the tooltip (`utils/tutorial.ts` owns the pure geometry + types),
246
- advances on Next or for `advanceOn: 'target-click'` steps on the user really clicking
258
+ advances on Next or (for `advanceOn: 'target-click'` steps) on the user really clicking
247
259
  the control, so the app's real response (the actual modal, the actual task) is what the next
248
260
  step anchors to. `target-click` is for BUTTONS, where the click is the completed action; a
249
261
  text field keeps Next, or the tooltip would leave the instruction the moment the user clicked
250
262
  in to type. A step whose anchor never appears within its wait is SKIPPED, because controls
251
263
  come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
252
264
  fixed script. Reaching the end having skipped steps is reported on the final card rather than
253
- congratulating the user on a walkthrough they did not see and a tour that could only ever
265
+ congratulating the user on a walkthrough they did not see, and a tour that could only ever
254
266
  be abridged should not be offered at all, which is what each tour's `requires` is for (the
255
267
  task-creation tour needs board write AND a service frame to add a task to).
256
268
 
@@ -263,13 +275,13 @@ missed half of it, every time. `resolveTourCatalogue` (in `utils/tutorial.ts`) d
263
275
  rejected steps and marks a tour left with none `not-applicable` rather than ready, so a tour
264
276
  whose every step is branch-specific can never open on an empty cursor. With no gates service
265
277
  wired at all (a bare install withholds nothing) every branch survives instead, and only one
266
- of them can anchor so the abridged notice ignores any skipped step that carries a `when`,
278
+ of them can anchor, so the abridged notice ignores any skipped step that carries a `when`,
267
279
  which has already declared that not applying is legitimate.
268
280
 
269
281
  **Gates decide what is OFFERED; the running tour's script is resolved once and HELD.** The
270
282
  overlay snapshots its tour when it starts rather than re-reading the gated slot on every
271
283
  flip. This is not an optimisation: gates over live run state flip as a direct result of
272
- following the tour `answer-park` is offered while something waits for a human, so the
284
+ following the tour; `answer-park` is offered while something waits for a human, so the
273
285
  moment the user answers, its `when` goes false. A re-reading overlay tore itself down there,
274
286
  one step short of its own finish card and with nothing recorded as completed, at exactly the
275
287
  moment the user succeeded. Holding the script also freezes the branch `resolveTours` chose,
@@ -285,60 +297,81 @@ are task-scoped for the same reason.
285
297
  **Fixed proper nouns ride `bodyParams`, not the catalogs.** A step naming the sample
286
298
  repository slug (`SAMPLE_REPO` in `modular/tutorial-tours.ts`) passes it as a `{repo}`
287
299
  interpolation, so it is written once in code rather than translated into ten catalogs that
288
- each drift on their own the same split components make for inline placeholders.
289
-
290
- The built-ins walk the delivery loop end to end, each gated on the state the previous one
291
- leaves behind, so the launch prompt only ever offers what this board can demonstrate: board
292
- basics, add a repository (`add-service`), create a task (`first-task`), run it (`run-task`),
293
- answer it when it parks (`answer-park`), review and merge the result (`review-merge`). One
294
- deliberate asymmetry: `run-task` points at Start without click-to-advance, because starting a
295
- run spends real model budget and nobody should discover they agreed to that by following a
296
- tutorial.
300
+ each drift on their own: the same split components make for inline placeholders.
301
+
302
+ The built-ins come in two halves. The DELIVERY LOOP, end to end, each tour gated on the state the
303
+ previous one leaves behind, so the launch prompt only ever offers what this board can demonstrate:
304
+ board basics, add a repository (`add-service`), create a task (`first-task`), run it (`run-task`),
305
+ answer it when it parks (`answer-park`), review and merge the result (`review-merge`). Then the
306
+ PLATFORM behind it, catalogue-only: connect an engine (`wire-models`), assemble a flow
307
+ (`design-pipeline`), curate the standards agents read (`agent-standards`), link the systems a run
308
+ talks to (`connect-systems`). Each of those covers ONE surface and ends there, because the surface
309
+ opens as a modal over the sidebar it was reached from, so a later step could not click another
310
+ sidebar entry anyway, and each declares exactly the permission that renders the entry it clicks,
311
+ since a weaker requirement offers a tour to someone with no such control and it then reports itself
312
+ abridged.
313
+
314
+ **That pairing is DERIVED from the nav catalog, not restated.** A step whose anchor IS a nav
315
+ entry's `testId` is checked by `navRequirementDrift` (`tutorial-tours.spec.ts`, beside the anchor
316
+ guard) against that `NavContribution`'s OWN `gate`: over every combination of the `NavGates`
317
+ booleans, a gate set satisfying the tour's `requires` must also be one the entry RENDERS in.
318
+ Restating the permission in a spec's gate literal is what this replaced, and it could not catch
319
+ either edit that really breaks the pairing, because neither one touches the tour. Tightening an
320
+ entry's `gate` is the obvious one. The subtler one is marking it `advanced: true`, which hides it
321
+ from BASIC mode; basic is the shipped default, so that tour would be offered to nearly everyone
322
+ and find nothing. "Renders" therefore means the gate AND the tier, and a tour that wants an
323
+ advanced entry has to declare the tier as a requirement of its own.
324
+
325
+ Two deliberate asymmetries about click-to-advance: `run-task` points at Start without it,
326
+ because starting a run spends real model budget and nobody should discover they agreed to that by
327
+ following a tutorial; `design-pipeline` points at Save without it, because Save is DISABLED until
328
+ the draft holds a step, and a click-to-advance step whose control cannot be clicked has no Next
329
+ button either, so it strands the tour.
297
330
 
298
331
  Two runtime constraints worth knowing before changing the overlay: it must keep
299
332
  `pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
300
333
  dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
301
- pointerdown without both, the tooltip's own buttons go inert and pressing one closes the
334
+ pointerdown; without both, the tooltip's own buttons go inert and pressing one closes the
302
335
  user's half-filled form. And everything that DECIDES (skip direction, wait budget,
303
336
  target-click matching, which skips count as abridged) lives in
304
337
  `components/tutorial/TutorialOverlay.logic.ts` so it is unit-tested; the SFC keeps only the
305
- DOM work. Note that target-click matching is by SELECTOR, not by the highlighted element:
338
+ DOM work. Target-click matching is by SELECTOR, not by the highlighted element:
306
339
  several anchors (`task-card`, `task-resolve`, `run-step`) render once per board item and the
307
340
  ring can only sit on one of them, so requiring the click to land on that one left a user who
308
- clicked the card the copy asked for with no way forward such a step renders no Next.
341
+ clicked the card the copy asked for with no way forward; such a step renders no Next.
309
342
 
310
343
  **An anchor that is on the page but off SCREEN is revealed before it is pointed at.** An
311
344
  element scrolled out of a panel or panned off the board still HAS layout boxes, so it passes
312
- the visibility check and the ring was drawn at off-screen coordinates while the tooltip
345
+ the visibility check, and the ring was drawn at off-screen coordinates while the tooltip
313
346
  clamped to a viewport edge, leaving the user reading "click this" beside nothing. The two
314
347
  `task-card` steps hit this hardest, since they anchor whichever card is first in the DOM.
315
348
  `needsReveal` (in `utils/tutorial.ts`, unit-tested) decides, measuring against
316
- `min(anchorArea, viewportArea)` so a control bigger than the viewport `board-canvas`,
317
- `sidebar` is judged on how much of the SCREEN it fills rather than on a fraction of its own
349
+ `min(anchorArea, viewportArea)` so a control bigger than the viewport (`board-canvas`,
350
+ `sidebar`) is judged on how much of the SCREEN it fills rather than on a fraction of its own
318
351
  area it could never clear. The mechanism then depends on the container: the board is a
319
352
  transform-panned Vue Flow canvas, where `scrollIntoView` does nothing and the camera has to
320
353
  move instead (clamped to the current zoom, or fitting one button would throw away the user's
321
354
  view of their board), and everything else is an ordinary scroll. `boardNodeIdFor` asks the
322
- DOM which it is, rather than keying off the target id the same id is a canvas node on the
355
+ DOM which it is, rather than keying off the target id: the same id is a canvas node on the
323
356
  board and a plain row in a panel. A reveal is attempted at most once per step, because both
324
357
  mechanisms are animations longer than a tracking tick.
325
358
 
326
359
  **Tracking is event-driven once an anchor is held**: only the hunt for a not-yet-mounted
327
360
  anchor polls fast, and it is bounded by the step's wait budget. Movement arrives from scroll
328
361
  (capture phase, so every scroll container counts), window resize, a `ResizeObserver` on the
329
- anchor, and the board camera with a slow backstop tick that also RE-RESOLVES the selector,
362
+ anchor, and the board camera: with a slow backstop tick that also RE-RESOLVES the selector,
330
363
  which is what lets a step re-anchor when its control is replaced underneath it. Every one of
331
364
  those re-measures is coalesced into one per animation frame, because `measure()` reads layout
332
365
  and then writes it, and capture-phase scroll fires for every container many times a frame.
333
366
 
334
367
  **Accessibility.** The card is a non-modal `dialog` and deliberately not a focus trap: half
335
368
  the catalog asks the user to operate the real control behind it. Focus moves onto the card
336
- when the tour starts and on every Next/Back without that a keyboard user has to tab the
337
- whole page, since the overlay is teleported to the end of `body` but never on a
369
+ when the tour starts and on every Next/Back (without that a keyboard user has to tab the
370
+ whole page, since the overlay is teleported to the end of `body`) but never on a
338
371
  `target-click` advance, where the app is opening a modal that rightly autofocuses its own
339
372
  first field and the NEXT step is usually the one telling the user to type in it. That is a
340
373
  decision, so it is `shouldFocusCard` in the logic module with a test on it, not an `if` at
341
- the call site it was an inline one, and a call site that forgot it is exactly how the card
374
+ the call site: it was an inline one, and a call site that forgot it is exactly how the card
342
375
  came to steal focus from the modal it had just opened.
343
376
 
344
377
  Step changes are announced through a separate `role="status"` region rather than `aria-live`
@@ -346,28 +379,28 @@ on the card, because the card's entire contents are replaced per step and a whol
346
379
  swap inside a dialog is not reliably announced. Two things about that region are load-bearing
347
380
  and easy to undo by accident: it lives OUTSIDE the overlay's `v-if` and its text lands a tick
348
381
  after the node does, because assistive tech announces a CHANGE to a live region and routinely
349
- says nothing about one that was inserted already populated which would silently cost the
382
+ says nothing about one that was inserted already populated, which would silently cost the
350
383
  first step of every tour. And it is the SOLE announcement: the card carries no
351
384
  `aria-describedby`, or the body would be read a second time on every focus move.
352
385
 
353
386
  Motion is honoured on both sides: `motion-safe:` on the ring transition and the searching
354
387
  spinner, and an instant scroll and camera move under `prefers-reduced-motion`.
355
388
 
356
- **Breaking off a tour leaves a resume point.** Esc and Skip are both easy to reach one by
357
- accident, one to get the overlay out of the way for a moment and what they discarded was
389
+ **Breaking off a tour leaves a resume point.** Esc and Skip are both easy to reach (one by
390
+ accident, one to get the overlay out of the way for a moment) and what they discarded was
358
391
  the whole walkthrough. `stopTour()` records where it stopped and the prompt offers Resume
359
392
  instead of only Start. Session-only, like the cursor itself: within a session the board is
360
393
  still in the state the tour left it in, which is exactly what a DOM-anchored position needs.
361
394
  The store validates no index (it knows nothing about which tours exist), so the overlay
362
- clamps a resume that lands past the end of a script the gates have thinned since and the
395
+ clamps a resume that lands past the end of a script the gates have thinned since, and the
363
396
  runtime's own bail-out on an unresolvable tour passes `resumable: false`, or resuming would
364
397
  put the user straight back into the same dead overlay. There is ONE slot, and starting a tour
365
398
  clears only that tour's own entry: another tour's position is not this action's to discard,
366
- and it loses the slot soon enough when this one is broken off past step 0.
399
+ and it loses the slot soon enough, when this one is broken off past step 0.
367
400
 
368
401
  The catalog is the `tutorialTours` slot: first-party tours live in
369
402
  `modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
370
- `registerAppModule` they appear in the prompt and the catalogue beside the built-ins, held
403
+ `registerAppModule`; they appear in the prompt and the catalogue beside the built-ins, held
371
404
  back per tour by its own `requires` (resolved against the same reactive gates service the nav
372
405
  uses). A consumer writes its own requirement objects with its own copy keys; the first-party
373
406
  ones are shared constants (`TUTORIAL_REQUIREMENTS`), because a second copy of "a service on the
@@ -377,7 +410,7 @@ per tour id, so renaming an id resets its state.
377
410
  **A built-in tour's anchors are drift-guarded** (`tutorial-tours.spec.ts`), because they are
378
411
  the one thing about a tour that nothing else in the build checks: a renamed `data-testid`
379
412
  passes typecheck, lint and the whole e2e suite, and several anchors have no other consumer at
380
- all. The failure it prevents is worse than a dead step those steps carry no `when`, so the
413
+ all. The failure it prevents is worse than a dead step: those steps carry no `when`, so the
381
414
  miss counts as an unexpected skip and every user lands on a permanent "you missed N steps"
382
415
  notice, a false claim the tour goes on making in production with nothing red anywhere. The
383
416
  guard scans the layer for both ways an id is named: written onto an element, or declared as a
@@ -386,8 +419,8 @@ is scoped to the built-in catalog, since a consumer's tours anchor on its own la
386
419
 
387
420
  ## Extending the layer (consumer modules)
388
421
 
389
- A deployment can contribute its own components result windows, nav entries, inspector
390
- panels, agent-kind palette data plus two DATA-only seams that need no components at all:
422
+ A deployment can contribute its own components (result windows, nav entries, inspector
423
+ panels, agent-kind palette data) plus two DATA-only seams that need no components at all:
391
424
  its own applications in an **External tools** sidebar section (`externalTools`, each
392
425
  resolving its URL from the acting user / open workspace / this board's custom fields) and the
393
426
  **custom workspace metadata fields** those resolvers read (`workspaceMetadataFields`, edited
@@ -402,49 +435,49 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
402
435
 
403
436
  ## Key UI surfaces
404
437
 
405
- - **Board canvas** (`components/board`) `BoardCanvas` + `nodes/` (`BlockNode`,
438
+ - **Board canvas** (`components/board`): `BoardCanvas` + `nodes/` (`BlockNode`,
406
439
  `ModuleFrame`, `TaskCard`), dependency edges, the per-block `AgentFailureCard` /
407
440
  `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. A running task card expands
408
441
  its build pipeline (`TaskPipelineMini`) on hover at any zoom level, and across every
409
- on-screen card past the `steps` zoom band the two grants are combined in the
442
+ on-screen card past the `steps` zoom band: the two grants are combined in the
410
443
  `taskExpansion` store and driven by `useTaskExpansion`.
411
- - **Sidebar & chrome** (`components/layout`) board/account switchers, palettes
444
+ - **Sidebar & chrome** (`components/layout`): board/account switchers, palettes
412
445
  entry points, the language + [interface-mode](#interface-modes-basic--advanced)
413
446
  switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
414
- - **Palettes** (`components/palettes`) drag blocks, pipelines and agents onto
447
+ - **Palettes** (`components/palettes`): drag blocks, pipelines and agents onto
415
448
  the board.
416
- - **Inspector** (`components/panels` + `panels/inspector`) per-block tabs:
449
+ - **Inspector** (`components/panels` + `panels/inspector`): per-block tabs:
417
450
  structure, dependencies, model + fragment picker, live execution, and linked
418
451
  docs/issues/scenarios. Decisions resolve via `DecisionModal`. A `review` task
419
- additionally leads with `TaskReviewTarget`, linking the pull request it reviews
452
+ additionally leads with `TaskReviewTarget`, linking the pull request it reviews:
420
453
  distinct from the execution panel's link to the PR a run PRODUCED, which a review
421
454
  task never has.
422
- - **Pipeline builder** (`components/pipeline`) assemble/edit agent chains and
455
+ - **Pipeline builder** (`components/pipeline`): assemble/edit agent chains and
423
456
  watch `PipelineProgress`. `PipelinePicker` (+ its `PipelinePreview` pane) is the
424
- single way a pipeline is chosen anywhere add-task, run settings, the recurring
425
- schedule, the focus view's Run menu so every surface explains a pipeline by the
457
+ single way a pipeline is chosen anywhere (add-task, run settings, the recurring
458
+ schedule, the focus view's Run menu) so every surface explains a pipeline by the
426
459
  ordered steps it will run rather than by its name alone.
427
- - **Context attachments** (`components/context`) `ContextAttachmentFields`, the
460
+ - **Context attachments** (`components/context`): `ContextAttachmentFields`, the
428
461
  shared staged-attachment form used by both the add-task and create-initiative
429
462
  modals. Picks are held locally and import-and-linked once the block exists (see
430
463
  `composables/useContextLinking`), because linking needs a block id. Both hosts
431
464
  attach to the SAME per-block linkage, so the inspector's `TaskContextDocs` /
432
- `TaskContextIssues` sections render for a task AND an initiative an initiative's
465
+ `TaskContextIssues` sections render for a task AND an initiative: an initiative's
433
466
  attachments would otherwise be invisible the moment the create modal closed.
434
- - **Integrations** modals/panels for `github` (the source-control panel, shared
467
+ - **Integrations**: modals/panels for `github` (the source-control panel, shared
435
468
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
436
469
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
437
470
  (acceptance), and `fragments` (the prompt-fragment library).
438
- - **Model providers** `ModelProvidersHub.vue`, the sibling hub for the ENGINES
471
+ - **Model providers**: `ModelProvidersHub.vue`, the sibling hub for the ENGINES
439
472
  (OpenRouter, vendor keys, personal subscriptions, own-machine runners). Kept out
440
473
  of the Integrations hub on purpose: an integration is optional context in or
441
474
  output out, while a provider is what executes the work, so a deployment with none
442
475
  connected runs nothing at all. **A new provider-shaped connection belongs here,
443
476
  never in `IntegrationsHub.vue`.** Both hubs, plus the user-scoped `PersonalSetupModal`,
444
477
  share the `IntegrationBackTitle` Back control, which returns to whichever hub set
445
- its came-from marker (`ui.cameFrom{Integrations,ModelProviders,Personal}`) a panel
478
+ its came-from marker (`ui.cameFrom{Integrations,ModelProviders,Personal}`): a panel
446
479
  reachable from more than one hub must not hard-code its return.
447
- - **Auth** (`components/auth`) `AuthGate` / `LoginScreen` / `UserMenu`; the app
480
+ - **Auth** (`components/auth`): `AuthGate` / `LoginScreen` / `UserMenu`; the app
448
481
  is gated when the backend requires sign-in.
449
482
 
450
483
  ## Where a surface lives
@@ -453,7 +486,7 @@ Two placements are load-bearing enough to state, because putting a new one in th
453
486
  wrong place is invisible until a user cannot find it:
454
487
 
455
488
  - **The sidebar section is a claim about what the destination IS.** `models` is the
456
- model layer the engines, the per-agent model choice, and the surfaces that
489
+ model layer: the engines, the per-agent model choice, and the surfaces that
457
490
  evaluate a prompt+agent+model combination (Sandbox, Kaizen); `integrations` the
458
491
  optional EXTERNAL systems; `infrastructure` where agent containers and test
459
492
  environments run; `configuration` workspace/account settings. A surface that
@@ -471,7 +504,7 @@ wrong place is invisible until a user cannot find it:
471
504
  - **"It talks to an external service" is not what puts a surface in `integrations`.**
472
505
  Private package registries connect to npmjs.com and GitHub Packages and still belong
473
506
  in Infrastructure, because the question they answer is _what may a container install
474
- from_ a property of where agents RUN, which is what the Infrastructure window is
507
+ from_: a property of where agents RUN, which is what the Infrastructure window is
475
508
  for. `integrations` is for a system the WORKSPACE links in and would still be a
476
509
  coherent product without. Ask which question the destination answers, not whether a
477
510
  credential leaves the building. A surface moved between sections must also move its
@@ -488,6 +521,6 @@ pnpm typecheck # nuxt typecheck
488
521
  pnpm lint # oxlint + oxfmt --check
489
522
  ```
490
523
 
491
- > Building/deploying the static site is covered in the deployment docs see the
524
+ > Building/deploying the static site is covered in the deployment docs: see the
492
525
  > [top-level README → Deployment](../../README.md#deployment) and
493
526
  > [`deploy/frontend/README.md`](../../deploy/frontend/README.md).
@@ -599,6 +599,10 @@ async function unlinkSource(id: string) {
599
599
  </script>
600
600
 
601
601
  <template>
602
+ <!-- Deliberately UNNAMED. This manager is mounted at two scopes (the board's library modal and
603
+ the account settings' fragment tab), so a `data-testid` on its root would be one id over
604
+ two elements. The tutorial tour's anchor is named by the WORKSPACE entry point instead —
605
+ see `FragmentLibraryPanel.vue`. -->
602
606
  <div class="flex flex-col gap-4">
603
607
  <!-- The library is opt-out; if a deployment disabled it, don't offer forms that
604
608
  would fail with a raw 503 — say so instead (any entry point lands here). -->
@@ -20,12 +20,24 @@ const open = computed({
20
20
  <template>
21
21
  <UModal v-model:open="open" :title="t('fragments.panel.title')" :ui="{ content: 'max-w-3xl' }">
22
22
  <template #body>
23
- <FragmentLibraryManager
24
- v-if="workspace.workspaceId"
25
- kind="workspace"
26
- :owner-id="workspace.workspaceId"
27
- show-catalog
28
- />
23
+ <!-- The anchor for the tutorial tour about steering agents with standards, which points at
24
+ the library as a whole. Named HERE rather than inside the manager because that manager
25
+ is mounted at two scopes (this modal and the account settings' fragment tab), so an id
26
+ on its root would be one id over two elements, leaving which one the tour highlights to
27
+ DOM order. A wrapper rather than an attribute passed down to it, because the anchor
28
+ DRIFT GUARD reads test ids out of the templates that write them: Vue's attribute
29
+ fallthrough would still satisfy the guard on the day someone adds a second root node to
30
+ the manager and the anchor stops rendering.
31
+ The tour requires the library to be enabled, so it never lands on the manager's own
32
+ unavailable notice. -->
33
+ <div data-testid="fragment-library">
34
+ <FragmentLibraryManager
35
+ v-if="workspace.workspaceId"
36
+ kind="workspace"
37
+ :owner-id="workspace.workspaceId"
38
+ show-catalog
39
+ />
40
+ </div>
29
41
  </template>
30
42
  </UModal>
31
43
  </template>
@@ -364,7 +364,9 @@ const filteredGroups = computed<IntegrationGroup[]>(() => {
364
364
  :ui="{ content: 'max-w-xl' }"
365
365
  >
366
366
  <template #body>
367
- <div class="space-y-5">
367
+ <!-- Named for the same reason `model-providers-hub` is: the tutorial tour that explains
368
+ what a connection adds to a run points at this list. -->
369
+ <div class="space-y-5" data-testid="integrations-hub">
368
370
  <p class="text-xs text-slate-400">
369
371
  {{ t('layout.integrationsHub.intro') }}
370
372
  </p>
@@ -455,7 +455,10 @@ async function clone(p: Pipeline) {
455
455
  columns filling the full height. -->
456
456
  <div class="grid grid-cols-1 gap-4 lg:h-full lg:grid-cols-3">
457
457
  <!-- agent palette -->
458
- <div class="flex flex-col lg:min-h-0 lg:overflow-hidden">
458
+ <div
459
+ class="flex flex-col lg:min-h-0 lg:overflow-hidden"
460
+ data-testid="pipeline-builder-palette"
461
+ >
459
462
  <div class="mb-2 flex shrink-0 items-center justify-between gap-2">
460
463
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
461
464
  {{ t('pipeline.builder.agentPalette') }}
@@ -476,7 +479,10 @@ async function clone(p: Pipeline) {
476
479
  </div>
477
480
 
478
481
  <!-- draft chain -->
479
- <div class="flex flex-col lg:min-h-0 lg:overflow-hidden">
482
+ <div
483
+ class="flex flex-col lg:min-h-0 lg:overflow-hidden"
484
+ data-testid="pipeline-builder-draft"
485
+ >
480
486
  <div class="mb-2 flex items-center justify-between gap-2">
481
487
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
482
488
  {{ t('pipeline.builder.pipeline') }}
@@ -1294,6 +1300,7 @@ async function clone(p: Pipeline) {
1294
1300
  icon="i-lucide-save"
1295
1301
  size="sm"
1296
1302
  :disabled="pipelines.draft.length === 0 || stepsDisallowedByPurpose.length > 0"
1303
+ data-testid="pipeline-builder-save"
1297
1304
  @click="save"
1298
1305
  >
1299
1306
  {{ pipelines.editingId ? t('pipeline.builder.update') : t('pipeline.builder.save') }}