@cat-factory/app 0.206.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.
@@ -1,14 +1,14 @@
1
1
  # Extending the SPA from a consumer deployment
2
2
 
3
3
  A deployment that consumes this layer (`extends: ['@cat-factory/app']`) can contribute
4
- its own **components** result windows, navigation entries, inspector panels, agent-kind
5
- palette data **without forking the layer**. This is the frontend counterpart of the
4
+ its own **components** (result windows, navigation entries, inspector panels, agent-kind
5
+ palette data) **without forking the layer**. This is the frontend counterpart of the
6
6
  backend's public registries (`registerAgentKind`, `registerGate`; see
7
7
  [`backend/docs/custom-agents.md`](../../../backend/docs/custom-agents.md)). The governing
8
8
  principle is the same: **zero host edits for a consumer extension**.
9
9
 
10
- A worked, end-to-end example ships in the template deployment
11
- [`deploy/frontend/app/`](../../../deploy/frontend) (the `acme:security` module) the
10
+ A worked, end-to-end example ships in the template deployment:
11
+ [`deploy/frontend/app/`](../../../deploy/frontend) (the `acme:security` module): the
12
12
  frontend analogue of the backend
13
13
  [`@cat-factory/example-custom-agent`](../../../backend/internal/example-custom-agent)
14
14
  package. Read this guide alongside it.
@@ -31,13 +31,13 @@ import AcmeSecurityReport from '../components/acme/AcmeSecurityReport.vue'
31
31
  export default defineNuxtPlugin(() => {
32
32
  registerAppModule(
33
33
  defineModule({
34
- id: 'acme:security', // namespaced see "Rules" below
34
+ id: 'acme:security', // namespaced - see "Rules" below
35
35
  version: '1.0.0',
36
36
  slots: {
37
37
  resultViews: [{ id: 'acme:security-report', component: AcmeSecurityReport }],
38
- agentKinds: [/* palette entries see "Agent kinds" */],
39
- nav: [/* sidebar / command-palette destinations see "Navigation" */],
40
- inspectorPanels: [/* per-block detail panels see "Inspector panels" */],
38
+ agentKinds: [/* palette entries - see "Agent kinds" */],
39
+ nav: [/* sidebar / command-palette destinations - see "Navigation" */],
40
+ inspectorPanels: [/* per-block detail panels - see "Inspector panels" */],
41
41
  },
42
42
  }),
43
43
  )
@@ -49,9 +49,9 @@ export default defineNuxtPlugin(() => {
49
49
  - **`enforce: 'post'` is load-bearing.** The layer's own install plugin is `enforce:
50
50
  'post'`, and Nuxt runs layer plugins before the consuming app's plugins within one
51
51
  enforce bucket. So your registration plugin must run in the **default** (or `pre`) bucket
52
- i.e. **do not** put `enforce: 'post'` on it, or it registers too late and is silently
52
+ , i.e. **do not** put `enforce: 'post'` on it, or it registers too late and is silently
53
53
  missed.
54
- - **`defineModule` / the slot-entry types come from `@modular-vue/core`** add it to your
54
+ - **`defineModule` / the slot-entry types come from `@modular-vue/core`**: add it to your
55
55
  deployment's `dependencies`.
56
56
 
57
57
  ## The landed seams
@@ -70,7 +70,7 @@ export default defineNuxtPlugin(() => {
70
70
  | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
71
71
 
72
72
  A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
73
- (the shipped default) exactly as it does for the first-party destinations see
73
+ (the shipped default) exactly as it does for the first-party destinations: see
74
74
  [the layer README](../../README.md#interface-modes-basic--advanced). Use it for a power-user
75
75
  destination; the flag is independent of `gate`, so both must pass for the item to render.
76
76
 
@@ -80,7 +80,7 @@ Backend data selects a frontend component, joined by a namespaced id:
80
80
 
81
81
  1. A backend agent kind (registered on `AgentKindRegistry`, e.g.
82
82
  `@cat-factory/example-custom-agent`'s `security-auditor`) arrives in the workspace
83
- snapshot with `presentation.resultView: '<ns>:<name>'` **or** you code-ship the kind's
83
+ snapshot with `presentation.resultView: '<ns>:<name>'`: **or** you code-ship the kind's
84
84
  palette entry via the `agentKinds` slot (as the example does, to give an existing kind a
85
85
  bespoke window).
86
86
  2. You contribute the component to `resultViews` under the SAME id.
@@ -107,7 +107,7 @@ nullish subject (the boot-time validation resolve passes `null`).
107
107
 
108
108
  ### External tools + workspace metadata (`externalTools`, `workspaceMetadataFields`)
109
109
 
110
- Put your OWN web applications a map editor, an asset pipeline, an admin console in the
110
+ Put your OWN web applications (a map editor, an asset pipeline, an admin console) in the
111
111
  sidebar's **External tools** section, and open each one _already scoped to what the user is
112
112
  looking at_. That second half is the point of the seam; a static link needs no registration.
113
113
 
@@ -132,25 +132,25 @@ workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork'
132
132
  ```
133
133
 
134
134
  - **`url` is a string or a RESOLVER** `(ctx) => string | null`. The context carries `userId`,
135
- `userEmail`, `workspaceId`, `workspaceName` and `metadata` the custom workspace fields you
135
+ `userEmail`, `workspaceId`, `workspaceName` and `metadata`: the custom workspace fields you
136
136
  declared. It is read at CLICK time, so a value a teammate fills in while the sidebar is open
137
137
  takes effect without a reload.
138
138
  - **Clicking opens a separate page** (`target=_blank`, `noopener`). The resolved URL must be
139
139
  `http(s)`: anything else is refused rather than handed to the browser, because the string
140
140
  reaches `window.open` and a `javascript:` URL would run in the SPA's own origin.
141
141
  - **Declare `requiredMetadata` for the fields your resolver needs.** An unconfigured workspace
142
- then gets "fill in `gameId` on the Metadata tab" instead of a generic failure and the tool
142
+ then gets "fill in `gameId` on the Metadata tab" instead of a generic failure, and the tool
143
143
  stays LISTED, because the person looking at the sidebar is usually the one who can fix it. A
144
144
  resolver that returns `null` reports separately ("this tool gave no address"), since that one
145
145
  is yours to fix, not the operator's.
146
146
  - **Treat every `ctx.metadata` value as untrusted input.** A workspace admin types these in, so a
147
- value is operator-supplied text that happens to be length-bounded not a constant you chose.
147
+ value is operator-supplied text that happens to be length-bounded, not a constant you chose.
148
148
  Set it as a query parameter or an `encodeURIComponent`'d path segment, as above. Never build the
149
149
  ORIGIN from one: `` `https://${ctx.metadata.region}.acme.dev` `` with `region` set to
150
150
  `evil.com/x?a=` resolves to a URL on someone else's host, and the `http(s)` allow-list cannot
151
151
  tell that apart from the link you meant.
152
152
  - **A resolver that THROWS costs only its own item.** It is caught and reported as a fourth
153
- reason (`resolver-failed`) with the cause logged to the console the sidebar, the palette and
153
+ reason (`resolver-failed`) with the cause logged to the console: the sidebar, the palette and
154
154
  the toolbar all render from one catalog, so an uncaught throw would otherwise blank all three.
155
155
  Do not rely on it: `requiredMetadata` is how you say a field must be there.
156
156
  - **`gate` and `advanced`** work exactly as on a `nav` entry; both must pass.
@@ -158,27 +158,27 @@ workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork'
158
158
  **The metadata half** is a deployment-declared FIELD list (here) whose VALUES are per workspace,
159
159
  typed in under _Workspace settings → Metadata_ and persisted on the workspace settings row. The
160
160
  tab appears only where a deployment declares fields. Keys must be identifier-shaped
161
- (`^[A-Za-z][A-Za-z0-9_.-]{0,63}$` the backend refuses anything else); a malformed or duplicate
161
+ (`^[A-Za-z][A-Za-z0-9_.-]{0,63}$`: the backend refuses anything else); a malformed or duplicate
162
162
  key is dropped with a dev-console warning rather than rendered. `type: 'select'` renders a picker
163
163
  over your `options`; everything is stored as a string.
164
164
 
165
165
  Two rules the editor keeps, and any other writer of the bag should too: a CLEARED field drops its
166
166
  key (so "unset" never reads as "set to nothing" in a resolver), and a save carries through any
167
- stored key the current build does not declare the update replaces the whole bag, so a value
167
+ stored key the current build does not declare; the update replaces the whole bag, so a value
168
168
  written under a field you have since retired must not be deleted by an unrelated save.
169
169
 
170
170
  Values are readable anywhere in the SPA via `useWorkspaceSettingsStore().settings.metadata`.
171
171
 
172
172
  ### Custom task types (`taskTypes`)
173
173
 
174
- Model a proprietary work item an "incident", "pentest", "compliance-audit" as a first-class
174
+ Model a proprietary work item (an "incident", "pentest", "compliance-audit") as a first-class
175
175
  task type, the create-task twin of an agent kind. Contribute `{ taskType: '<ns>:<name>',
176
176
  presentation: { label, icon, color, description }, fields?, defaultPipelineId?, formPanel? }` to
177
177
  the `taskTypes` slot (see `acme:incident` in the example module). The SPA merges it into the
178
178
  create-task picker and the card-badge catalog:
179
179
 
180
180
  - **`presentation`** drives the create-task picker entry and the `TaskCard` type badge (resolved
181
- through the pure `taskTypeMeta` read-model the `agentKindMeta` twin). An UNREGISTERED
181
+ through the pure `taskTypeMeta` read-model: the `agentKindMeta` twin). An UNREGISTERED
182
182
  namespaced type (a stale row after your extension is removed) degrades to the `feature`
183
183
  presentation, so a leftover string never breaks a card.
184
184
  - **`fields`** are descriptor-driven create-form inputs (`text` / `textarea` / `number` /
@@ -198,20 +198,20 @@ task created with it round-trips with zero host edits.
198
198
  > (namespaced id, well-formed `formPanel`, a `defaultPipelineId` that resolves to a real pipeline).
199
199
  > A CODE-shipped `taskTypes` entry is trusted and **not** validated (like a code-shipped agent kind):
200
200
  > a malformed `taskType`/`formPanel` id or a `defaultPipelineId` naming no real pipeline fails
201
- > silently the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
201
+ > silently; the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
202
202
  > descriptor `fields`. Prefer backend registration when you want the fail-fast guardrail.
203
203
 
204
204
  ### Top-level overlays (`appOverlays`)
205
205
 
206
- A nav item's `run` closure or any consumer code often needs to open a full-screen panel of
206
+ A nav item's `run` closure, or any consumer code, often needs to open a full-screen panel of
207
207
  its own: a dashboard, a wizard, a settings surface. The layer's first-party modals are
208
208
  hand-mounted in `pages/index.vue`, which a consumer can't edit, so the `appOverlays` slot + the
209
209
  single `<AppOverlayHost>` are the seam:
210
210
 
211
211
  1. Contribute `{ id: '<ns>:<name>', component }` to the `appOverlays` slot (see
212
212
  `acme:security-dashboard-overlay` in the example module).
213
- 2. Open it from anywhere with the auto-imported `useAppOverlays().open('<ns>:<name>', subject?)`
214
- typically a nav item's `run` closure. The optional `subject` is any value your overlay
213
+ 2. Open it from anywhere with the auto-imported `useAppOverlays().open('<ns>:<name>', subject?)`:
214
+ typically a nav item's `run` closure. The optional `subject` is any value your overlay
215
215
  renders against (e.g. a block id); it reaches the component as a `subject` prop.
216
216
  3. `<AppOverlayHost>` resolves the slot with `resolveComponentRegistry` (the same pick-one
217
217
  primitive `resultViews` uses) and mounts the matching component, wiring its `close` emit to
@@ -219,16 +219,16 @@ single `<AppOverlayHost>` are the seam:
219
219
 
220
220
  It is a **pick-one** host: opening a second overlay replaces the first, and `close()` clears it.
221
221
  Compose the shared `ResultWindowShell` (via `#components`) for chrome so your overlay inherits
222
- focus-trap / scroll-lock / shared-stack Escape emit `close` from its `@close`. A dangling open
223
- (`open('<ns>:x')` with no registered component e.g. a stale closure after the extension was
222
+ focus-trap / scroll-lock / shared-stack Escape: emit `close` from its `@close`. A dangling open
223
+ (`open('<ns>:x')` with no registered component, e.g. a stale closure after the extension was
224
224
  removed) degrades to nothing (a dev-console warning names the id), never a crash. Duplicate ids
225
225
  across modules throw at boot, like every other slot.
226
226
 
227
227
  > **Scope.** This seam is for CONSUMER overlays. The layer's own ~34 first-party modals stay
228
- > hand-mounted in `index.vue` and are migrated only opportunistically don't reach for
228
+ > hand-mounted in `index.vue` and are migrated only opportunistically: don't reach for
229
229
  > `appOverlays` to replace a first-party fast-path modal.
230
230
 
231
- ## Reuse the shared building blocks don't reinvent them
231
+ ## Reuse the shared building blocks: don't reinvent them
232
232
 
233
233
  The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
234
234
  re-deriving the "which run is this / how did the model do" facts. **Composables** (and the
@@ -236,25 +236,25 @@ re-deriving the "which run is this / how did the model do" facts. **Composables*
236
236
  **components** must be named through the `#components` virtual module (see the boxed note
237
237
  below). Compose these:
238
238
 
239
- | Building block | Reference it as | What it gives you |
240
- | ----------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
241
- | `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. It also renders the universal per-step trailing sections (agent effort, pre-PR validation, binary outputs) off the ACTIVE step, so your window inherits them and must not re-render them itself. |
242
- | `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar never reinvent run metadata. |
243
- | `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
244
- | `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
245
- | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
246
- | `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
247
- | `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path from a board card or the inspector carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
248
- | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
249
- | `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
239
+ | Building block | Reference it as | What it gives you |
240
+ | ----------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
241
+ | `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window: backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. It also renders the universal per-step trailing sections (agent effort, pre-PR validation, binary outputs) off the ACTIVE step, so your window inherits them and must not re-render them itself. |
242
+ | `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar, never reinvent run metadata. |
243
+ | `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
244
+ | `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
245
+ | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
246
+ | `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
247
+ | `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path (from a board card or the inspector) carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
248
+ | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
249
+ | `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
250
250
 
251
251
  > **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
252
252
  > layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
253
253
  > → `PanelsResultWindowShell`), and only rewrites bare `<ResultWindowShell>` tags inside the
254
254
  > layer's own SFCs. A bare tag in a **consumer** SFC resolves to nothing and silently renders
255
- > as an unknown element its `<slot>` children still appear, so a shallow test can pass while
255
+ > as an unknown element: its `<slot>` children still appear, so a shallow test can pass while
256
256
  > the shared chrome (and its `data-testid`) never mounts. Import the ones you use from
257
- > `#components` (Nuxt's stable virtual registry **not** a deep path into the layer's
257
+ > `#components` (Nuxt's stable virtual registry: **not** a deep path into the layer's
258
258
  > `app/components/*`), aliasing them back to the short names for readable templates:
259
259
  >
260
260
  > ```ts
@@ -274,13 +274,13 @@ below). Compose these:
274
274
 
275
275
  Some state the engine records on a step is deliberately NOT a result view's job, because the
276
276
  record's scope is wider than any one kind: the agent's effort self-assessment, the pre-PR
277
- validation report, and for a `binary-output` generator the artifacts it declared it stored
277
+ validation report, and (for a `binary-output` generator) the artifacts it declared it stored
278
278
  (`step.binaryOutputs`). `ResultWindowShell` resolves the active step itself and renders each as a
279
279
  collapsible trailing section, and the generic step-detail panel renders the same components for a
280
280
  step whose kind declares no window at all.
281
281
 
282
282
  So a generator kind should declare a result view for its OWN output (or none), and leave the
283
- artifact list alone you get it either way, on every entry point, with no id to register. A
283
+ artifact list alone: you get it either way, on every entry point, with no id to register. A
284
284
  window that renders it again just shows it twice.
285
285
 
286
286
  The example `AcmeSecurityReport.vue` window is a full demonstration: it imports
@@ -293,19 +293,19 @@ reads the auditor's structured assessment straight off `step.custom`.
293
293
  Ship your strings under your own namespace in the deployment's `i18n/locales/*.json` (e.g.
294
294
  `acme.*`). `@nuxtjs/i18n` is layer-aware and **deep-merges** them into the layer catalog, so
295
295
  `t('acme.securityReport.title')` resolves in your components with no config change. The
296
- layer's typed-key and locale-parity guards govern only the layer's own keys your namespace
296
+ layer's typed-key and locale-parity guards govern only the layer's own keys: your namespace
297
297
  is yours.
298
298
 
299
299
  ## Rules that hold across every seam
300
300
 
301
301
  - **Namespacing.** Every consumer-authored id is `<ns>:<name>`. Built-ins are never
302
- shadowable the merge logic drops a consumer entry whose id collides with a built-in
302
+ shadowable: the merge logic drops a consumer entry whose id collides with a built-in
303
303
  (see the agents store).
304
304
  - **Fail fast at boot, degrade at runtime.** Duplicate ids across first-party + consumer
305
305
  modules throw when the layer resolves the merged slots at startup; missing pairings and
306
306
  unknown wire ids degrade with a dev-console warning, never a crash.
307
307
  - **Never crash on stale data.** An id that arrives on the wire (a `resultView`, an agent
308
- kind) after its extension was removed must degrade to a defined rendering extensions get
308
+ kind) after its extension was removed must degrade to a defined rendering: extensions get
309
309
  uninstalled while persisted rows outlive them.
310
310
  - **The remote manifest is DATA only.** Components never travel the wire; per-workspace
311
311
  variability comes from which capabilities the snapshot lists, not from which modules are
@@ -609,16 +609,32 @@ export const navigationModule = defineModule({
609
609
  slots: { nav: [...NAV_CONTRIBUTIONS] },
610
610
  })
611
611
 
612
+ /**
613
+ * Does a shell render this contribution under `gates`?
614
+ *
615
+ * The two axes a destination is gated on, in ONE place. They are independent and BOTH must
616
+ * pass: an `advanced` item is dropped in basic mode, and every item still answers to its own
617
+ * `gate`. Order doesn't matter (it's a conjunction) but the tier is checked first, since it's
618
+ * the cheaper read.
619
+ *
620
+ * Named rather than inlined in {@link navSlotFilter} because a second reader has to agree with
621
+ * it exactly: a tutorial tour whose step CLICKS a nav entry declares the requirement that
622
+ * renders it, and `tutorial-tours.spec.ts` pairs the two through this function. Spelling the
623
+ * conjunction out there instead would be a copy that keeps passing while this one changes —
624
+ * and the drift it would miss (an entry gaining a gate clause, or being marked `advanced` and
625
+ * so leaving the DEFAULT interface tier) is precisely a tour offered to a user who then finds
626
+ * no such control.
627
+ */
628
+ export function navItemVisible(item: NavContribution, gates: NavGates): boolean {
629
+ return (item.advanced ? gates.advancedMode : true) && (item.gate ? item.gate(gates) : true)
630
+ }
631
+
612
632
  /**
613
633
  * Reactive RBAC/availability/interface-tier filter over the merged `nav` slot. Reads
614
634
  * `deps.gates.*` (the reactive gate service) per item, so evaluated inside
615
635
  * `useReactiveSlots` it re-runs when a permission, connection, or the interface
616
636
  * mode flips. Passed to `installModularApp` as the global `slotFilter`.
617
637
  *
618
- * The two axes are independent and BOTH must pass: an `advanced` item is dropped in
619
- * basic mode, and every item still answers to its own `gate`. Order doesn't matter
620
- * (it's a conjunction) but the tier is checked first, since it's the cheaper read.
621
- *
622
638
  * Typed against `AppSlots` (not the generic `SlotFilter`) so it matches the
623
639
  * filter shape the runtime infers for this registry. `deps` is widened to an
624
640
  * optional `gates` to avoid importing `AppDeps` (which would be circular).
@@ -631,11 +647,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
631
647
  ...slots,
632
648
  // No gates service wired (tests / bare install) ⇒ show everything, matching
633
649
  // the dev-open "absent access allows all" backend parity.
634
- nav: gates
635
- ? nav.filter(
636
- (i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
637
- )
638
- : nav,
650
+ nav: gates ? nav.filter((i) => navItemVisible(i, gates)) : nav,
639
651
  // `tutorialTours` is deliberately NOT filtered here, unlike every other gated slot. A
640
652
  // `SlotFilter` can only DROP, and the tutorial catalogue's whole job is to explain what
641
653
  // was dropped — which tour this board can't run yet, and what would unlock it. That is a
@@ -7,9 +7,11 @@ import {
7
7
  TUTORIAL_TOURS,
8
8
  tutorialToursModule,
9
9
  } from '~/modular/tutorial-tours'
10
- import { resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
10
+ import { isLaunchOffer, resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
11
11
  import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
12
- import type { NavGates } from '~/modular/nav-contributions'
12
+ import { NAV_CONTRIBUTIONS, navItemVisible } from '~/modular/nav-contributions'
13
+ import type { NavContribution, NavGates } from '~/modular/nav-contributions'
14
+ import type { TutorialStep, TutorialTour } from '~/utils/tutorial'
13
15
 
14
16
  const ALL_GATES: NavGates = {
15
17
  canWriteBoard: true,
@@ -100,6 +102,90 @@ function declaredAnchors(): { label: string; id: string }[] {
100
102
  return out
101
103
  }
102
104
 
105
+ /**
106
+ * Every step whose anchor IS a nav entry, paired with the contribution it points at.
107
+ *
108
+ * Those steps are the ones a tour's `requires` has to agree with, because the anchor only
109
+ * exists while the sidebar/palette renders that entry. An anchor that is NOT a nav entry (a
110
+ * control inside the modal the click opens) has no contribution to pair with and is left to
111
+ * the anchor guard above; `altTargets` are included, since a fallback anchor is reached on
112
+ * exactly the same terms as the primary one.
113
+ */
114
+ function navAnchoredSteps(): {
115
+ tour: TutorialTour
116
+ step: TutorialStep
117
+ item: NavContribution
118
+ label: string
119
+ }[] {
120
+ const byTestId = new Map(
121
+ NAV_CONTRIBUTIONS.flatMap((item) => (item.testId ? [[item.testId, item] as const] : [])),
122
+ )
123
+ return TUTORIAL_TOURS.flatMap((tour) =>
124
+ tour.steps.flatMap((step) =>
125
+ [step.target, ...(step.altTargets ?? [])].flatMap((target) => {
126
+ const item = target === undefined ? undefined : byTestId.get(target)
127
+ return item ? [{ tour, step, item, label: `${tour.id}/${step.id} -> ${item.id}` }] : []
128
+ }),
129
+ ),
130
+ )
131
+ }
132
+
133
+ /**
134
+ * Every combination of the {@link NavGates} booleans, yielded lazily so the 2^N gate sets are
135
+ * never all live at once.
136
+ *
137
+ * Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
138
+ * gate sets — anything that satisfies a tour must also render its entry — between two
139
+ * predicates written independently in two files, and nothing about their shape is guaranteed
140
+ * (either may be a conjunction, a disjunction, or read a field the other doesn't). At fifteen
141
+ * fields the whole matrix costs milliseconds, which is a fair price for a guard that needs no
142
+ * assumption about how either side is spelled.
143
+ */
144
+ function* everyGateSet(): Generator<NavGates> {
145
+ const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]
146
+ for (let mask = 0; mask < 2 ** keys.length; mask++) {
147
+ const gates = {} as Record<keyof NavGates, boolean>
148
+ for (const [bit, key] of keys.entries()) gates[key] = (mask & (1 << bit)) !== 0
149
+ yield gates as NavGates
150
+ }
151
+ }
152
+
153
+ /** The gate fields a witness has turned OFF — the interesting half of a failure message. */
154
+ function absentGates(gates: NavGates): readonly string[] {
155
+ return Object.entries(gates)
156
+ .filter(([, value]) => value === false)
157
+ .map(([key]) => key)
158
+ }
159
+
160
+ /**
161
+ * A gate set that OFFERS this tour while the entry its step clicks is NOT rendered, or
162
+ * `undefined` when no such set exists (what the guard wants). A step the gate set DROPS
163
+ * (`when`) needs no anchor, so it cannot be a counterexample.
164
+ *
165
+ * Of the many counterexamples one break produces, the one reported is the SMALLEST: the gate
166
+ * set closest to fully-permitted, so its absent fields are exactly the ones that matter. The
167
+ * first witness the matrix happens to reach names most of `NavGates` and reads as noise, which
168
+ * is the difference between a failure that says "declare `advancedMode`" and one that says
169
+ * "something about fourteen gates".
170
+ */
171
+ function navRequirementDrift(
172
+ pair: ReturnType<typeof navAnchoredSteps>[number],
173
+ ): NavGates | undefined {
174
+ let smallest: NavGates | undefined
175
+ let fewestAbsent = Number.POSITIVE_INFINITY
176
+ for (const gates of everyGateSet()) {
177
+ if (!(pair.tour.requires ?? []).every((requirement) => requirement.met(gates))) continue
178
+ if (pair.step.when && !pair.step.when(gates)) continue
179
+ if (navItemVisible(pair.item, gates)) continue
180
+ const absent = absentGates(gates).length
181
+ if (absent < fewestAbsent) {
182
+ fewestAbsent = absent
183
+ smallest = gates
184
+ }
185
+ }
186
+ return smallest
187
+ }
188
+
103
189
  /** Every static test id this layer actually renders. */
104
190
  function renderedTestIds(): Set<string> {
105
191
  const ids = new Set<string>()
@@ -190,14 +276,52 @@ describe('the built-in tutorial tour catalog', () => {
190
276
  expect(missing).toEqual([])
191
277
  })
192
278
 
279
+ it('requires, of every step that clicks a nav entry, whatever renders that entry', () => {
280
+ // The other drift guard, and the one the availability cases below CANNOT stand in for.
281
+ // Those assert this pairing by restating the permission in a hand-built gate set, which
282
+ // says nothing about the nav catalog: both edits that really break the pairing happen in
283
+ // `nav-contributions.ts` and touch no tour.
284
+ //
285
+ // - an entry's `gate` gains a clause the tour doesn't require;
286
+ // - an entry is marked `advanced: true`, which removes it from BASIC mode — and basic is
287
+ // the SHIPPED DEFAULT, so the tour would then be offered to nearly every user and find
288
+ // nothing.
289
+ //
290
+ // Both land on the same production failure: the tour is offered, hunts for an anchor that
291
+ // this user's sidebar never renders, skips the step (no `when`, so the miss COUNTS) and
292
+ // leaves them on a permanent "you missed N steps" notice about a walkthrough that could
293
+ // not have gone any other way. So the requirement is derived from the entry's OWN
294
+ // visibility rule (`navItemVisible`, the function `navSlotFilter` itself filters with)
295
+ // rather than spelled out a second time here.
296
+ const pairs = navAnchoredSteps()
297
+ // Guard the guard, twice over. A pairing that matched nothing would pass vacuously, and
298
+ // there is one per tour that opens a sidebar surface: `add-service` plus the four platform
299
+ // tours. And a gate field that is not a boolean would silently never vary across the
300
+ // matrix, leaving whatever it gates unexercised.
301
+ expect(pairs.length).toBeGreaterThanOrEqual(5)
302
+ expect(Object.values(ALL_GATES).every((value) => typeof value === 'boolean')).toBe(true)
303
+
304
+ const drifted = pairs.flatMap((pair) => {
305
+ const witness = navRequirementDrift(pair)
306
+ if (witness === undefined) return []
307
+ return [`${pair.label} is offered without ${absentGates(witness).join(' / ')}`]
308
+ })
309
+ expect(drifted).toEqual([])
310
+ })
311
+
193
312
  it('is contributed to the tutorialTours slot by the module', () => {
194
313
  expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
195
314
  })
196
315
  })
197
316
 
198
317
  describe('tour availability across the catalog', () => {
199
- /** The ids a board can START right now — what the launch prompt offers. */
318
+ /** The ids a board can START right now — every startable tour, whatever offers it. */
200
319
  const ready = (gates: NavGates) => resolveTours(TUTORIAL_TOURS, gates).map((t) => t.id)
320
+ /** The narrower set the LAUNCH PROMPT asks about (`useTutorialTours().offered`). */
321
+ const offered = (gates: NavGates) =>
322
+ resolveTours(TUTORIAL_TOURS, gates)
323
+ .filter(isLaunchOffer)
324
+ .map((t) => t.id)
201
325
  /** The catalogue's own view: every tour, with what is holding each one back. */
202
326
  const entry = (gates: NavGates, tourId: string) =>
203
327
  resolveTourCatalogue(TUTORIAL_TOURS, gates).find((e) => e.tour.id === tourId)
@@ -207,14 +331,19 @@ describe('tour availability across the catalog', () => {
207
331
  })
208
332
 
209
333
  it('lists the whole catalog whatever the gates say, holding back rather than hiding', () => {
210
- // The catalogue surface's contract. A fresh board can run two of the six walkthroughs;
211
- // dropping the other four (all a slot filter could do) would misrepresent the product as
212
- // shipping two, to exactly the user who came looking for the rest.
334
+ // The catalogue surface's contract. A fresh board can run the two delivery-loop tours that
335
+ // need no board state plus the whole platform half; dropping the rest (all a slot filter
336
+ // could do) would misrepresent the product as shipping fewer walkthroughs than it does, to
337
+ // exactly the user who came looking for them.
213
338
  const catalogue = resolveTourCatalogue(TUTORIAL_TOURS, FRESH_BOARD)
214
339
  expect(catalogue.map((e) => e.tour.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
215
340
  expect(catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id)).toEqual([
216
341
  'board-basics',
217
342
  'add-service',
343
+ 'wire-models',
344
+ 'design-pipeline',
345
+ 'agent-standards',
346
+ 'connect-systems',
218
347
  ])
219
348
  })
220
349
 
@@ -234,18 +363,68 @@ describe('tour availability across the catalog', () => {
234
363
  })
235
364
 
236
365
  it('offers a brand-new board the orientation tour AND the way out of being empty', () => {
237
- // The state the launch prompt actually auto-opens in. Orientation alone would leave a
238
- // new workspace with a tour of an empty canvas and no route to a first service, which
239
- // is what `add-service` exists to fix — so it must survive exactly this gate set.
240
- expect(ready(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
366
+ // The state the launch prompt actually auto-opens in, asserted on what it ASKS ABOUT.
367
+ // Orientation alone would leave a new workspace with a tour of an empty canvas and no route
368
+ // to a first service, which is what `add-service` exists to fix — so it must survive exactly
369
+ // this gate set. And nothing else may join it here: this is a modal with one question, and
370
+ // the platform tours are all startable on a fresh board (they need only a permission), so
371
+ // without the offer/library split they would bury both of these four-to-two.
372
+ expect(offered(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
373
+ })
374
+
375
+ it('keeps the platform tours in the catalogue rather than in the launch offer', () => {
376
+ // The split, stated as a table so promoting a tour into the first-launch question has to be
377
+ // written down here. The delivery loop is the arc a first-time user is answering about; the
378
+ // platform half is reference material they go and get from the catalogue, where it is listed,
379
+ // counted and startable exactly like the rest.
380
+ const LAUNCH_ARC = [
381
+ 'board-basics',
382
+ 'add-service',
383
+ 'first-task',
384
+ 'run-task',
385
+ 'answer-park',
386
+ 'review-merge',
387
+ ]
388
+ const CATALOGUE_ONLY = ['wire-models', 'design-pipeline', 'agent-standards', 'connect-systems']
389
+ expect(TUTORIAL_TOURS.filter(isLaunchOffer).map((t) => t.id)).toEqual(LAUNCH_ARC)
390
+ expect(TUTORIAL_TOURS.filter((t) => !isLaunchOffer(t)).map((t) => t.id)).toEqual(CATALOGUE_ONLY)
391
+ // Un-offered is not un-runnable: it thins the offer, never the library.
392
+ expect(ready(ALL_GATES)).toEqual(expect.arrayContaining(CATALOGUE_ONLY))
241
393
  })
242
394
 
243
395
  it('names the missing connection when no source control can list repositories', () => {
244
396
  const noSource: NavGates = { ...FRESH_BOARD, githubAvailable: false }
245
- expect(ready(noSource)).toEqual(['board-basics'])
397
+ expect(offered(noSource)).toEqual(['board-basics'])
246
398
  expect(entry(noSource, 'add-service')?.unmet.map((r) => r.id)).toEqual(['source-control'])
247
399
  })
248
400
 
401
+ it('holds each platform tour back on the permission its own sidebar entry needs', () => {
402
+ // Every one of these tours clicks a sidebar entry as its second step, so its requirement has
403
+ // to be the SAME fact that renders the entry. A weaker one offers the tour to a user with no
404
+ // such control: it would hunt for the anchor, skip the rest and report itself abridged.
405
+ const member: NavGates = {
406
+ ...ALL_GATES,
407
+ canManageIntegrations: false,
408
+ canManageSettings: false,
409
+ }
410
+ expect(ready(member)).not.toContain('wire-models')
411
+ expect(ready(member)).not.toContain('connect-systems')
412
+ expect(ready(member)).not.toContain('agent-standards')
413
+ expect(entry(member, 'wire-models')?.unmet.map((r) => r.id)).toEqual(['integrations-manage'])
414
+ expect(entry(member, 'connect-systems')?.unmet.map((r) => r.id)).toEqual([
415
+ 'integrations-manage',
416
+ ])
417
+ expect(entry(member, 'agent-standards')?.unmet.map((r) => r.id)).toEqual(['settings-manage'])
418
+ // A viewer keeps the builder tour out too: it opens a board-write surface.
419
+ expect(ready({ ...ALL_GATES, canWriteBoard: false })).not.toContain('design-pipeline')
420
+ })
421
+
422
+ it('names the disabled library when the deployment ships no fragment surface', () => {
423
+ const noLibrary: NavGates = { ...ALL_GATES, libraryAvailable: false }
424
+ expect(ready(noLibrary)).not.toContain('agent-standards')
425
+ expect(entry(noLibrary, 'agent-standards')?.unmet.map((r) => r.id)).toEqual(['library'])
426
+ })
427
+
249
428
  it('offers the run tour once a task exists, and the review tour once a run finished', () => {
250
429
  const withTask: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
251
430
  expect(ready(withTask)).toContain('run-task')