@cat-factory/app 0.207.0 → 0.208.1

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,13 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  // The tutorial launch prompt: asks once on first launch whether the user wants a guided tour,
3
- // listing the tours this board can actually run right now (first-party + consumer, resolved
4
- // against the same gates the nav uses), so it grows with the catalog rather than hard-coding
5
- // tours.
3
+ // listing the first-run tours this board can actually run right now (first-party + consumer,
4
+ // resolved against the same gates the nav uses), so it grows with the catalog rather than
5
+ // hard-coding tours.
6
6
  //
7
- // It is the OFFER, not the library: the full list — including the walkthroughs this board
8
- // can't run yet and what would unlock them is `TutorialCatalogue.vue`, one button away in
9
- // the footer and permanently reachable from the sidebar's Help section. That split is why
10
- // this stays a short, answerable question instead of growing into a browsing surface.
7
+ // It is the OFFER, not the library: the full list — the platform walkthroughs that are kept out
8
+ // of this question (`offeredAtLaunch: false`), plus the ones this board can't run yet and what
9
+ // would unlock them is `TutorialCatalogue.vue`, one button away in the footer and permanently
10
+ // reachable from the sidebar's Help section. That split is why this stays a short, answerable
11
+ // question instead of growing into a browsing surface, and why it reads `offered` rather than
12
+ // every startable tour.
11
13
  //
12
14
  // The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
13
15
  // prompt never auto-opens again), while closing without answering defers to next launch.
@@ -15,7 +17,7 @@ import { TUTORIAL_ACTION_KEYS } from '~/utils/tutorial'
15
17
 
16
18
  const { t } = useI18n()
17
19
  const tutorial = useTutorialStore()
18
- const { tours } = useTutorialTours()
20
+ const { offered } = useTutorialTours()
19
21
  // Start / Resume / Repeat is decided in one place for both surfaces — see `useTutorialLaunch`.
20
22
  const { actionFor, launch } = useTutorialLaunch()
21
23
 
@@ -41,7 +43,7 @@ const undecided = computed(() => tutorial.decision === null)
41
43
  <p class="text-sm text-slate-300">{{ t('tutorial.prompt.intro') }}</p>
42
44
  <ul class="space-y-2">
43
45
  <li
44
- v-for="tour in tours"
46
+ v-for="tour in offered"
45
47
  :key="tour.id"
46
48
  class="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
47
49
  >
@@ -75,9 +77,15 @@ const undecided = computed(() => tutorial.decision === null)
75
77
  </UButton>
76
78
  </li>
77
79
  </ul>
78
- <!-- Every tour gated away (e.g. a viewer on a write-only catalog): say so rather
79
- than showing an unexplained empty list. -->
80
- <p v-if="tours.length === 0" class="text-sm text-slate-400">
80
+ <!-- Every first-run tour gated away: say so rather than showing an unexplained empty
81
+ list. The copy points at the catalogue rather than declaring the deployment empty,
82
+ because with the split this state no longer implies there is nothing to take: what
83
+ is missing is the FIRST-RUN arc, and the catalogue-only walkthroughs gate on a
84
+ permission that this user may well hold. (Unreachable with the built-in catalog
85
+ alone, where `board-basics` requires nothing at all — but a consumer's own slot
86
+ filter can produce it, and "no tours exist" would be the wrong thing to say then.)
87
+ The footer's browse button is the way on, so it stays. -->
88
+ <p v-if="offered.length === 0" class="text-sm text-slate-400">
81
89
  {{ t('tutorial.prompt.empty') }}
82
90
  </p>
83
91
  </div>
@@ -1,7 +1,7 @@
1
1
  import { computed } from 'vue'
2
2
  import { useReactiveSlots } from '@modular-vue/runtime'
3
3
  import { createSharedComposables } from '@modular-vue/vue'
4
- import { resolveTourCatalogue } from '~/utils/tutorial'
4
+ import { isLaunchOffer, resolveTourCatalogue } from '~/utils/tutorial'
5
5
  import type { TutorialCatalogueEntry, TutorialTour } from '~/utils/tutorial'
6
6
  import type { AppDeps } from '~/modular/registry'
7
7
  import type { AppSlots } from '~/modular/nav-contributions'
@@ -21,10 +21,13 @@ const { useOptional } = createSharedComposables<AppDeps>()
21
21
  /**
22
22
  * The tutorial catalog as this board sees it, resolved ONCE for every surface that reads it.
23
23
  *
24
- * Two views over one resolution, and the difference between them is the point:
24
+ * Three views over one resolution, and the differences between them are the point:
25
25
  *
26
- * - `tours` — what can be started right now. The launch prompt offers these, and the overlay
27
- * resolves a running tour from them, exactly as when this gating lived in `navSlotFilter`.
26
+ * - `tours` — what can be started right now. The overlay resolves a running tour from these,
27
+ * exactly as when this gating lived in `navSlotFilter`.
28
+ * - `offered` — the subset the launch prompt asks about: startable AND part of the first-run
29
+ * arc (see `TutorialTour.offeredAtLaunch`). The prompt is one answerable question, so it
30
+ * stays the delivery loop even as the catalog grows to cover the platform surfaces.
28
31
  * - `catalogue` — EVERY tour this deployment ships, each carrying why it is or isn't
29
32
  * available. The catalogue surface needs the unavailable ones: a list that quietly omits
30
33
  * four of six tours is indistinguishable from a deployment that ships two, and the user it
@@ -42,5 +45,6 @@ export function useTutorialTours() {
42
45
  const tours = computed<TutorialTour[]>(() =>
43
46
  catalogue.value.filter((entry) => entry.availability === 'ready').map((entry) => entry.tour),
44
47
  )
45
- return { tours, catalogue }
48
+ const offered = computed<TutorialTour[]>(() => tours.value.filter(isLaunchOffer))
49
+ return { tours, offered, catalogue }
46
50
  }
@@ -1,4 +1,4 @@
1
- # Frontend architecture state & data flow
1
+ # Frontend architecture: state & data flow
2
2
 
3
3
  How the SPA stays in sync with the backend. The app is a **thin client**: it holds
4
4
  no business logic, calls the Worker for every mutation, and hydrates its stores
@@ -14,11 +14,11 @@ REST (useApi) ─────────────▶ Worker ────
14
14
  stores (Pinia) ◀── patch ── useWorkspaceStream ◀── WebSocket push (events hub)
15
15
  ```
16
16
 
17
- - **Read path** the `workspace` store loads the full snapshot and fans it into
17
+ - **Read path**: the `workspace` store loads the full snapshot and fans it into
18
18
  `board`, `pipelines`, `execution`, `spend`, etc.
19
- - **Write path** components call `useApi` → Worker; the response (or a pushed
19
+ - **Write path**: components call `useApi` → Worker; the response (or a pushed
20
20
  event) patches the relevant store. No optimistic business logic.
21
- - **Live path** `useWorkspaceStream` opens one WebSocket to
21
+ - **Live path**: `useWorkspaceStream` opens one WebSocket to
22
22
  `GET /workspaces/:ws/events?token=…`, patches `execution` / `agentRuns` /
23
23
  `board` as events arrive, and refreshes on reconnect to reconcile anything
24
24
  missed.
@@ -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