@cat-factory/app 0.152.1 → 0.153.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.
@@ -29,8 +29,7 @@ const { t } = useI18n()
29
29
  <pre
30
30
  class="max-h-32 overflow-auto whitespace-pre-wrap rounded p-1.5 pe-9"
31
31
  :class="preClass"
32
- >{{ detail }}</pre
33
- >
32
+ >{{ detail }}</pre>
34
33
  </div>
35
34
  </details>
36
35
  </template>
@@ -68,8 +68,7 @@ const entries = computed<Entry[]>(() =>
68
68
  <CopyButton :text="entry.output.output" class="absolute end-1 top-1 z-10" />
69
69
  <pre
70
70
  class="max-h-40 overflow-auto whitespace-pre-wrap rounded bg-slate-950/80 p-1.5 pe-9 text-[10px] leading-snug text-slate-300"
71
- >{{ entry.output.output }}</pre
72
- >
71
+ >{{ entry.output.output }}</pre>
73
72
  </div>
74
73
  <p v-if="entry.output.truncated" class="mt-1 text-[10px] text-slate-500">
75
74
  {{ t('panels.stepDetail.outputTruncated') }}
@@ -111,8 +111,7 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
111
111
  <pre
112
112
  v-if="session.brief"
113
113
  class="whitespace-pre-wrap break-words text-[13px] leading-relaxed text-slate-300"
114
- >{{ session.brief }}</pre
115
- >
114
+ >{{ session.brief }}</pre>
116
115
  <p v-else class="text-[13px] text-slate-400">{{ t('docInterview.converged') }}</p>
117
116
  </div>
118
117
 
@@ -128,8 +128,7 @@ const ENV_STATUS_META = computed<
128
128
  (environment.status === 'failed' || environment.status === 'expired')
129
129
  "
130
130
  class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded border border-rose-900/60 bg-rose-950/40 p-1.5 text-[11px] text-rose-200/90"
131
- >{{ environment.lastError }}</pre
132
- >
131
+ >{{ environment.lastError }}</pre>
133
132
  </div>
134
133
  <p v-else class="text-[12px] text-slate-500">
135
134
  {{ degradedReason ?? t('environments.empty') }}
@@ -94,8 +94,7 @@ const PREFLIGHT_COLOR: Record<'pass' | 'warn' | 'fail', 'success' | 'warning' |
94
94
  <pre
95
95
  v-if="r.status !== 'pass' && r.remediation"
96
96
  class="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-amber-900/40 bg-amber-950/20 p-1.5 text-[11px] text-amber-200/90"
97
- >{{ r.remediation }}</pre
98
- >
97
+ >{{ r.remediation }}</pre>
99
98
  </li>
100
99
  </ul>
101
100
 
@@ -601,8 +601,7 @@ async function copyOutput() {
601
601
  </div>
602
602
  <pre
603
603
  class="mb-2 max-h-24 overflow-auto whitespace-pre-wrap rounded bg-slate-950/60 p-2 text-[11px] text-slate-300"
604
- >{{ draftTarget.quotedSource }}</pre
605
- >
604
+ >{{ draftTarget.quotedSource }}</pre>
606
605
  <UTextarea
607
606
  v-model="draftBody"
608
607
  :rows="3"
@@ -646,8 +645,7 @@ async function copyOutput() {
646
645
  </div>
647
646
  <pre
648
647
  class="mb-1 max-h-20 overflow-auto whitespace-pre-wrap rounded bg-slate-950/50 p-1.5 text-[10px] text-slate-400"
649
- >{{ c.quotedSource }}</pre
650
- >
648
+ >{{ c.quotedSource }}</pre>
651
649
  <p class="text-[12px] text-slate-200">{{ c.body }}</p>
652
650
  </div>
653
651
 
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { selectOverlay } from './AppOverlayHost.logic'
3
+ import type { OverlayContribution } from '~/modular/slots'
4
+
5
+ /**
6
+ * The pure selection behind `<AppOverlayHost>` (extension slice D — the frontend-extension-mechanism
7
+ * initiative). Pins the resilience the seam advertises: a matching id mounts its component, a
8
+ * dangling open (no registered component) degrades to nothing (`missing: true`, the host's dev-warn
9
+ * signal), no open renders nothing, and duplicate ids fail fast (the boot guard's contract).
10
+ */
11
+ // A plain object stands in for the SFC — `selectOverlay` only ever keys by id.
12
+ const overlay = (id: string): OverlayContribution => ({ id, component: { name: id } as never })
13
+
14
+ describe('selectOverlay (AppOverlayHost slice D)', () => {
15
+ it('returns nothing when no overlay is open (null / undefined id)', () => {
16
+ const overlays = [overlay('acme:security-dashboard-overlay')]
17
+ expect(selectOverlay(overlays, null)).toEqual({ component: null, missing: false })
18
+ expect(selectOverlay(overlays, undefined)).toEqual({ component: null, missing: false })
19
+ })
20
+
21
+ it('picks the component whose id matches the active overlay', () => {
22
+ const entry = overlay('acme:security-dashboard-overlay')
23
+ const { component, missing } = selectOverlay([entry], 'acme:security-dashboard-overlay')
24
+ expect(component).toBe(entry.component)
25
+ expect(missing).toBe(false)
26
+ })
27
+
28
+ it('degrades a dangling open to nothing and flags it missing (no crash)', () => {
29
+ const { component, missing } = selectOverlay([overlay('acme:one')], 'acme:gone')
30
+ expect(component).toBeNull()
31
+ expect(missing).toBe(true)
32
+ })
33
+
34
+ it('fails fast on duplicate overlay ids (the boot guard contract)', () => {
35
+ const dupes = [overlay('acme:dup'), overlay('acme:dup')]
36
+ expect(() => selectOverlay(dupes, 'acme:dup')).toThrow()
37
+ })
38
+ })
@@ -0,0 +1,24 @@
1
+ import type { Component } from 'vue'
2
+ import { resolveComponentRegistry } from '@modular-vue/core'
3
+ import type { OverlayContribution } from '~/modular/slots'
4
+
5
+ /**
6
+ * Pure selection for `<AppOverlayHost>` (extension slice D). Indexes the merged `appOverlays`
7
+ * slot into an id → component registry (`resolveComponentRegistry` — the same pick-one primitive
8
+ * `StepResultViewHost` uses; duplicate ids throw, as validated once at boot) and picks the entry
9
+ * matching the active overlay id.
10
+ *
11
+ * Kept side-effect-free — the host's dev-warn for a dangling open lives in a `watchEffect`, not
12
+ * here — so both the host's `computed` and the unit spec can call it. `missing` is the explicit
13
+ * dangling-open signal (an id with no registered component, e.g. a stale nav closure after an
14
+ * extension was removed): the host degrades that to nothing rather than crashing.
15
+ */
16
+ export function selectOverlay(
17
+ overlays: OverlayContribution[],
18
+ activeId: string | null | undefined,
19
+ ): { component: Component | null; missing: boolean } {
20
+ if (!activeId) return { component: null, missing: false }
21
+ const registry = resolveComponentRegistry(overlays)
22
+ const component = (registry.get(activeId) as Component | undefined) ?? null
23
+ return { component, missing: !component }
24
+ }
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ // Universal CONSUMER-overlay host (extension slice D — the frontend-extension-mechanism
3
+ // initiative, docs/initiatives/frontend-extension-mechanism.md). The one top-level surface a
4
+ // consumer deployment could not extend before: `pages/index.vue` hand-mounts every first-party
5
+ // modal as a `v-if`, so a consumer nav item's `run` closure had nothing to open. This host is
6
+ // the seam — a deployment registers `{ id: '<ns>:<name>', component }` in the `appOverlays` slot
7
+ // and opens it with `ui.openOverlay(id, subject?)` (usually via `useAppOverlays().open(...)`).
8
+ //
9
+ // The mechanism is the same slice-2 pick-one primitive `StepResultViewHost` uses: index the
10
+ // merged `appOverlays` slot into an id → component registry via `resolveComponentRegistry`, and
11
+ // mount the entry whose id matches the active `ui.activeOverlay` pointer. Only ONE consumer
12
+ // overlay is open at a time. The mounted overlay receives the optional `subject` as a prop and
13
+ // emits `close` (the consumer overlay composes `ResultWindowShell` / `useModalBehavior` for its
14
+ // own chrome, so Escape/backdrop/focus-trap are inherited — see the consumer-extensions guide).
15
+ //
16
+ // First-party modals stay hand-mounted in `index.vue`; this seam is deliberately scoped to
17
+ // consumer extensions (strangler discipline — the ~34 existing lazy modals are not migrated).
18
+ import { computed, watchEffect, type Component } from 'vue'
19
+ import { useReactiveSlots } from '@modular-vue/runtime'
20
+ import type { AppSlots } from '~/modular/slots'
21
+ import { selectOverlay } from './AppOverlayHost.logic'
22
+
23
+ const ui = useUiStore()
24
+ const slots = useReactiveSlots<AppSlots>()
25
+
26
+ // Pick the component for the active overlay id via the pure `selectOverlay` helper (indexes the
27
+ // merged `appOverlays` slot with `resolveComponentRegistry`; duplicate ids throw, validated once
28
+ // at boot in `modular.client.ts`). Recomputed if a consumer module's contributions change (they
29
+ // don't after boot, but the read is cheap).
30
+ const selection = computed(() => selectOverlay(slots.value.appOverlays ?? [], ui.activeOverlay?.id))
31
+ const active = computed<Component | null>(() => selection.value.component)
32
+
33
+ // Dev guard: a dangling open (`openOverlay('acme:x')` with no registered component — e.g. a stale
34
+ // nav closure after an extension was removed) degrades to nothing rather than crashing. Kept in a
35
+ // `watchEffect` — not the `computed` above — so the selection stays side-effect-free, mirroring
36
+ // `StepResultViewHost`.
37
+ if (import.meta.dev) {
38
+ watchEffect(() => {
39
+ if (selection.value.missing) {
40
+ const id = ui.activeOverlay?.id
41
+ console.warn(
42
+ `[AppOverlayHost] ui.openOverlay('${id}') has no registered component. ` +
43
+ `Contribute { id: '${id}', component } to the appOverlays slot in a registerAppModule module.`,
44
+ )
45
+ }
46
+ })
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <component
52
+ :is="active"
53
+ v-if="active"
54
+ :subject="ui.activeOverlay?.subject"
55
+ @close="ui.closeOverlay()"
56
+ />
57
+ </template>
@@ -495,8 +495,7 @@ function exportJson() {
495
495
  </div>
496
496
  <pre
497
497
  class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
498
- >{{ prettyPrompt(c.promptText) }}</pre
499
- >
498
+ >{{ prettyPrompt(c.promptText) }}</pre>
500
499
  </div>
501
500
  <div>
502
501
  <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
@@ -504,8 +503,7 @@ function exportJson() {
504
503
  </div>
505
504
  <pre
506
505
  class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
507
- >{{ c.responseText || '—' }}</pre
508
- >
506
+ >{{ c.responseText || '—' }}</pre>
509
507
  </div>
510
508
  <div v-if="c.reasoningText">
511
509
  <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
@@ -513,8 +511,7 @@ function exportJson() {
513
511
  </div>
514
512
  <pre
515
513
  class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-400"
516
- >{{ c.reasoningText }}</pre
517
- >
514
+ >{{ c.reasoningText }}</pre>
518
515
  </div>
519
516
  </div>
520
517
  </li>
@@ -597,8 +594,7 @@ function exportJson() {
597
594
  </div>
598
595
  <pre
599
596
  class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
600
- >{{ s.systemPrompt || '—' }}</pre
601
- >
597
+ >{{ s.systemPrompt || '—' }}</pre>
602
598
  </div>
603
599
  <div>
604
600
  <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
@@ -606,8 +602,7 @@ function exportJson() {
606
602
  </div>
607
603
  <pre
608
604
  class="max-h-72 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-300"
609
- >{{ s.userPrompt || '—' }}</pre
610
- >
605
+ >{{ s.userPrompt || '—' }}</pre>
611
606
  </div>
612
607
  <div v-if="s.fragments.length">
613
608
  <div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
@@ -621,8 +616,7 @@ function exportJson() {
621
616
  <div class="mb-1 text-[11px] text-slate-400">{{ f.id }}</div>
622
617
  <pre
623
618
  class="max-h-48 overflow-auto text-[11px] leading-relaxed text-slate-300"
624
- >{{ f.body }}</pre
625
- >
619
+ >{{ f.body }}</pre>
626
620
  </div>
627
621
  </div>
628
622
  <div v-if="s.contextFiles.length">
@@ -640,8 +634,7 @@ function exportJson() {
640
634
  </div>
641
635
  <pre
642
636
  class="max-h-72 overflow-auto text-[11px] leading-relaxed text-slate-300"
643
- >{{ file.content }}</pre
644
- >
637
+ >{{ file.content }}</pre>
645
638
  </div>
646
639
  </div>
647
640
  <div>
@@ -650,8 +643,7 @@ function exportJson() {
650
643
  </div>
651
644
  <pre
652
645
  class="max-h-48 overflow-auto rounded-lg bg-slate-950/70 p-3 text-[11px] leading-relaxed text-slate-400"
653
- >{{ prettyExtras(s.extras) }}</pre
654
- >
646
+ >{{ prettyExtras(s.extras) }}</pre>
655
647
  </div>
656
648
  </div>
657
649
  </li>
@@ -156,8 +156,7 @@ function when(epochMs: number): string {
156
156
  <pre
157
157
  v-if="entry.error"
158
158
  class="mt-1 max-h-28 overflow-auto whitespace-pre-wrap rounded border border-rose-900/50 bg-rose-950/30 p-1.5 text-[11px] text-rose-200/90"
159
- >{{ entry.error }}</pre
160
- >
159
+ >{{ entry.error }}</pre>
161
160
  </li>
162
161
  </ul>
163
162
  </div>
@@ -147,8 +147,7 @@ const STATUS_META = computed<
147
147
  <CopyButton :text="ralph.lastValidationTail" class="absolute end-1 top-1" />
148
148
  <pre
149
149
  class="whitespace-pre-wrap pe-8 font-mono text-[11px] leading-relaxed text-slate-400"
150
- >{{ ralph.lastValidationTail }}</pre
151
- >
150
+ >{{ ralph.lastValidationTail }}</pre>
152
151
  </div>
153
152
  </template>
154
153
 
@@ -462,8 +462,7 @@ async function archive(prompt: SandboxPromptVersion) {
462
462
  <pre
463
463
  v-if="selectedRun.outputText"
464
464
  class="max-h-48 overflow-auto whitespace-pre-wrap rounded bg-slate-950/60 p-2 text-[11px] text-slate-300"
465
- >{{ selectedRun.outputText }}</pre
466
- >
465
+ >{{ selectedRun.outputText }}</pre>
467
466
  <div v-if="gradeByRun.get(selectedRun.id)" class="mt-2 space-y-0.5">
468
467
  <p
469
468
  v-for="d in gradeByRun.get(selectedRun.id)!.scores"
@@ -419,8 +419,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
419
419
  v-if="showInfraSetupLogs"
420
420
  data-testid="tester-infra-setup-logs"
421
421
  class="mt-2 max-h-64 overflow-auto rounded bg-slate-950/70 p-2 font-mono text-[11px] leading-relaxed text-slate-300"
422
- >{{ infraSetup.logs }}</pre
423
- >
422
+ >{{ infraSetup.logs }}</pre>
424
423
  </template>
425
424
  </div>
426
425
 
@@ -0,0 +1,26 @@
1
+ import { computed } from 'vue'
2
+
3
+ /**
4
+ * The public seam a consumer deployment uses to open its own top-level overlays (extension
5
+ * slice D — the frontend-extension-mechanism initiative). A deployment contributes an overlay
6
+ * component to the `appOverlays` slot (`{ id: '<ns>:<name>', component }`) and opens it from a
7
+ * nav item's `run` closure (or any consumer code) with `useAppOverlays().open('<ns>:<name>')`.
8
+ * The single `<AppOverlayHost>` in `pages/index.vue` resolves the slot and mounts the matching
9
+ * component, handing it the optional `subject`.
10
+ *
11
+ * This is a thin, auto-imported wrapper over the `ui` store's overlay pointer so a consumer
12
+ * never has to reach into the layer's Pinia stores directly — the same decoupling the rest of
13
+ * the consumer surface follows (auto-imported composables + `#components`, no deep layer
14
+ * imports). Opening a second overlay replaces the first (a pick-one host).
15
+ */
16
+ export function useAppOverlays() {
17
+ const ui = useUiStore()
18
+ return {
19
+ /** The active consumer overlay (`{ id, subject? }`) or null when none is open. */
20
+ active: computed(() => ui.activeOverlay),
21
+ /** Open the `appOverlays`-slot overlay with this id, optionally passing it a subject. */
22
+ open: (id: string, subject?: unknown) => ui.openOverlay(id, subject),
23
+ /** Close the active consumer overlay. */
24
+ close: () => ui.closeOverlay(),
25
+ }
26
+ }
@@ -35,15 +35,9 @@ export default defineNuxtPlugin(() => {
35
35
  version: '1.0.0',
36
36
  slots: {
37
37
  resultViews: [{ id: 'acme:security-report', component: AcmeSecurityReport }],
38
- agentKinds: [
39
- /* palette entries — see "Agent kinds" */
40
- ],
41
- nav: [
42
- /* sidebar / command-palette destinations — see "Navigation" */
43
- ],
44
- inspectorPanels: [
45
- /* per-block detail panels — see "Inspector panels" */
46
- ],
38
+ agentKinds: [/* palette entries — see "Agent kinds" */],
39
+ nav: [/* sidebar / command-palette destinations — see "Navigation" */],
40
+ inspectorPanels: [/* per-block detail panels — see "Inspector panels" */],
47
41
  },
48
42
  }),
49
43
  )
@@ -69,6 +63,7 @@ export default defineNuxtPlugin(() => {
69
63
  | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
70
64
  | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
71
65
  | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
66
+ | Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
72
67
  | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
73
68
  | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
74
69
 
@@ -135,6 +130,33 @@ task created with it round-trips with zero host edits.
135
130
  > silently — the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
136
131
  > descriptor `fields`. Prefer backend registration when you want the fail-fast guardrail.
137
132
 
133
+ ### Top-level overlays (`appOverlays`)
134
+
135
+ A nav item's `run` closure — or any consumer code — often needs to open a full-screen panel of
136
+ its own: a dashboard, a wizard, a settings surface. The layer's first-party modals are
137
+ hand-mounted in `pages/index.vue`, which a consumer can't edit, so the `appOverlays` slot + the
138
+ single `<AppOverlayHost>` are the seam:
139
+
140
+ 1. Contribute `{ id: '<ns>:<name>', component }` to the `appOverlays` slot (see
141
+ `acme:security-dashboard-overlay` in the example module).
142
+ 2. Open it from anywhere with the auto-imported `useAppOverlays().open('<ns>:<name>', subject?)`
143
+ — typically a nav item's `run` closure. The optional `subject` is any value your overlay
144
+ renders against (e.g. a block id); it reaches the component as a `subject` prop.
145
+ 3. `<AppOverlayHost>` resolves the slot with `resolveComponentRegistry` (the same pick-one
146
+ primitive `resultViews` uses) and mounts the matching component, wiring its `close` emit to
147
+ `useAppOverlays().close()`.
148
+
149
+ It is a **pick-one** host: opening a second overlay replaces the first, and `close()` clears it.
150
+ Compose the shared `ResultWindowShell` (via `#components`) for chrome so your overlay inherits
151
+ focus-trap / scroll-lock / shared-stack Escape — emit `close` from its `@close`. A dangling open
152
+ (`open('<ns>:x')` with no registered component — e.g. a stale closure after the extension was
153
+ removed) degrades to nothing (a dev-console warning names the id), never a crash. Duplicate ids
154
+ across modules throw at boot, like every other slot.
155
+
156
+ > **Scope.** This seam is for CONSUMER overlays. The layer's own ~34 first-party modals stay
157
+ > hand-mounted in `index.vue` and are migrated only opportunistically — don't reach for
158
+ > `appOverlays` to replace a first-party fast-path modal.
159
+
138
160
  ## Reuse the shared building blocks — don't reinvent them
139
161
 
140
162
  The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
@@ -152,6 +174,7 @@ below). Compose these:
152
174
  | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
153
175
  | `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. |
154
176
  | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
177
+ | `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"). |
155
178
 
156
179
  > **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
157
180
  > layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
@@ -52,6 +52,7 @@ const slots = (): AppSlots => ({
52
52
  inspectorPanels: [],
53
53
  taskTypes: [],
54
54
  taskTypeFormPanels: [],
55
+ appOverlays: [],
55
56
  })
56
57
  const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
57
58
 
@@ -111,4 +111,27 @@ describe('app modular registry', () => {
111
111
  expect(ids).toContain('build-pipeline')
112
112
  expect(ids).toContain('consumer:reports')
113
113
  })
114
+
115
+ it('merges a consumer-contributed overlay into the appOverlays slot (slice-D extensibility)', () => {
116
+ // A deployment contributes its OWN top-level overlay to the `appOverlays` slot; the layer's
117
+ // single `<AppOverlayHost>` mounts it on `ui.openOverlay(id)` — no host edit (the slice-D
118
+ // extensibility promise). A fake component (plain object) stands in for the SFC.
119
+ const fakeOverlay = { name: 'AcmeSecurityDashboard' }
120
+ registerAppModule(
121
+ defineModule({
122
+ id: 'consumer:overlay',
123
+ version: '1.0.0',
124
+ slots: {
125
+ appOverlays: [{ id: 'acme:security-dashboard-overlay', component: fakeOverlay }],
126
+ },
127
+ }),
128
+ )
129
+
130
+ const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
131
+ appOverlays: { id: string }[]
132
+ }
133
+ // The slot exists by default (empty) even with no first-party overlays, and the consumer
134
+ // entry lands in it.
135
+ expect(slots.appOverlays.map((o) => o.id)).toContain('acme:security-dashboard-overlay')
136
+ })
114
137
  })
@@ -108,6 +108,7 @@ export function createAppRegistry(
108
108
  inspectorPanels: [],
109
109
  taskTypes: [],
110
110
  taskTypeFormPanels: [],
111
+ appOverlays: [],
111
112
  },
112
113
  }).use(journeysPlugin())
113
114
  for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
@@ -31,6 +31,14 @@ import type { NavContribution } from './nav-contributions'
31
31
  * per custom task type, addressed by the type's `formPanel` id and paired via
32
32
  * `resolveComponentRegistry` (same shape as `resultViews`); shown INSTEAD of the
33
33
  * descriptor-driven `fields`. An unpaired id degrades to the descriptor fields.
34
+ * - `appOverlays` (extension slice D) — top-level modals/overlays a consumer module
35
+ * contributes ({@link OverlayContribution}, an id → component `ComponentEntry`),
36
+ * opened by `ui.openOverlay(id, subject?)` / `useAppOverlays().open(...)` and
37
+ * mounted by the single `<AppOverlayHost>` in `pages/index.vue` (which selects the
38
+ * active entry through `resolveComponentRegistry`, the same pick-one primitive
39
+ * `resultViews` uses). This is the one host surface a consumer flatly could not
40
+ * extend before — a nav item's `run` closure now has something to open. First-party
41
+ * modals stay hand-mounted in `index.vue`; the seam is for consumer overlays.
34
42
  *
35
43
  * The index signature is mutable (`unknown[]`) to satisfy the runtime's
36
44
  * `SlotMap` constraint while `unknown[]` still meets `useReactiveSlots`'
@@ -43,6 +51,7 @@ export interface AppSlots {
43
51
  inspectorPanels: PanelEntry<Block>[]
44
52
  taskTypes: CustomTaskType[]
45
53
  taskTypeFormPanels: ResultViewContribution[]
54
+ appOverlays: OverlayContribution[]
46
55
  [key: string]: unknown[]
47
56
  }
48
57
 
@@ -57,3 +66,15 @@ export interface AppSlots {
57
66
  * custom task type's `formPanel` id).
58
67
  */
59
68
  export type ResultViewContribution = ComponentEntry<Component>
69
+
70
+ /**
71
+ * One consumer-contributed top-level overlay (a modal/panel with no first-party
72
+ * home), addressed by its namespaced `<ns>:<name>` id. A plain `ComponentEntry`,
73
+ * so `<AppOverlayHost>` indexes the merged `appOverlays` slot with the same
74
+ * `resolveComponentRegistry` pick-one primitive as `resultViews` and mounts the
75
+ * entry whose id matches the active `ui.openOverlay(...)` request. The overlay
76
+ * component receives the (optional) subject as a `subject` prop and emits `close`;
77
+ * it composes the layer's `ResultWindowShell` / `useModalBehavior` for its own
78
+ * chrome (see the consumer-extensions guide).
79
+ */
80
+ export type OverlayContribution = ComponentEntry<Component>
@@ -16,6 +16,7 @@ import InspectorPanel from '~/components/panels/InspectorPanel.vue'
16
16
  import DecisionModal from '~/components/panels/DecisionModal.vue'
17
17
  import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
18
18
  import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
19
+ import AppOverlayHost from '~/components/panels/AppOverlayHost.vue'
19
20
  import AddTaskModal from '~/components/board/AddTaskModal.vue'
20
21
  import ReviewFrictionDialog from '~/components/board/ReviewFrictionDialog.vue'
21
22
  import CreateInitiativeModal from '~/components/board/CreateInitiativeModal.vue'
@@ -377,6 +378,9 @@ watch(
377
378
  <DecisionModal />
378
379
  <AgentStepDetail />
379
380
  <StepResultViewHost />
381
+ <!-- Consumer-contributed top-level overlays (extension slice D). Renders nothing until a
382
+ consumer opens one via `ui.openOverlay` / `useAppOverlays().open(...)`. -->
383
+ <AppOverlayHost />
380
384
  <AddTaskModal />
381
385
  <ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
382
386
  <CreateInitiativeModal />
@@ -15,7 +15,7 @@ import {
15
15
  environmentSetupModule,
16
16
  environmentSetupPersistence,
17
17
  } from '~/modular/journeys/environmentSetup'
18
- import type { AppSlots, ResultViewContribution } from '~/modular/slots'
18
+ import type { AppSlots, OverlayContribution, ResultViewContribution } from '~/modular/slots'
19
19
  import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
20
20
 
21
21
  /**
@@ -102,6 +102,11 @@ export default defineNuxtPlugin({
102
102
  // duplicate `formPanel` id across the first-party + consumer modules throws at BOOT
103
103
  // rather than the first time the create-task form opens a custom type's section.
104
104
  resolveComponentRegistry((slots.taskTypeFormPanels ?? []) as ResultViewContribution[])
105
+ // Same fail-fast for the consumer-overlay registry (extension slice D): a duplicate
106
+ // `appOverlays` id across the first-party + consumer modules throws at BOOT rather than
107
+ // the first time `<AppOverlayHost>` mounts one. The slot is static after this resolve, so
108
+ // the host's own reactive re-resolve is a cheap read this has already validated.
109
+ resolveComponentRegistry((slots.appOverlays ?? []) as OverlayContribution[])
105
110
  // Consumer agent kinds + task types contributed as CODE to the static `agentKinds` /
106
111
  // `taskTypes` slots (module slots resolve once, so the static base is the full set).
107
112
  useAgentsStore().registerConsumerKinds((slots.agentKinds ?? []) as CustomAgentKind[])
@@ -839,6 +839,33 @@ function createAiOnboardingModals() {
839
839
  }
840
840
  }
841
841
 
842
+ /**
843
+ * The generic CONSUMER-overlay host (extension slice D — the frontend-extension-mechanism
844
+ * initiative). Unlike every sub-slice above, this holds NO per-modal boolean: it is a single
845
+ * pick-one pointer to whichever consumer-contributed overlay (the `appOverlays` slot) is
846
+ * currently open, so a consumer nav item's `run` closure finally has something to open. A
847
+ * deployment registers `{ id: '<ns>:<name>', component }` in the `appOverlays` slot and calls
848
+ * `ui.openOverlay(id, subject?)` (usually via the auto-imported `useAppOverlays()` composable);
849
+ * the single `<AppOverlayHost>` in `pages/index.vue` resolves the slot and mounts the matching
850
+ * component, handing it the optional `subject`. First-party modals stay hand-mounted in
851
+ * `index.vue` — this seam is deliberately scoped to consumer extensions (strangler discipline).
852
+ */
853
+ function createConsumerOverlayHost() {
854
+ // The active consumer overlay: its slot id + an optional opaque subject the overlay renders
855
+ // against (e.g. a block id). Null when no consumer overlay is open. Only ONE at a time —
856
+ // opening another replaces it (a pick-one host, like the result-view seam).
857
+ const activeOverlay = ref<{ id: string; subject?: unknown } | null>(null)
858
+
859
+ function openOverlay(id: string, subject?: unknown) {
860
+ activeOverlay.value = { id, subject }
861
+ }
862
+ function closeOverlay() {
863
+ activeOverlay.value = null
864
+ }
865
+
866
+ return { activeOverlay, openOverlay, closeOverlay }
867
+ }
868
+
842
869
  /**
843
870
  * The modal / panel slice of the UI store: every open-close flag for the dozens of modals,
844
871
  * panels and hubs (document + task import, bootstrap, integrations, workspace/account settings,
@@ -904,6 +931,7 @@ export function createUiModals() {
904
931
  const misc = createMiscModals()
905
932
  const documentsTasks = createDocumentTaskModals(resetHubReturn)
906
933
  const overlays = createOverlayModals()
934
+ const consumerOverlays = createConsumerOverlayHost()
907
935
  const panels = createIntegrationPanelModals(resetHubReturn)
908
936
  const settings = createSettingsModals(resetHubReturn)
909
937
  const infra = createInfraModals(resetHubReturn)
@@ -924,6 +952,7 @@ export function createUiModals() {
924
952
  ...misc,
925
953
  ...documentsTasks,
926
954
  ...overlays,
955
+ ...consumerOverlays,
927
956
  ...panels,
928
957
  ...settings,
929
958
  ...infra,
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createUiModals } from '~/stores/ui/modals'
3
+
4
+ /**
5
+ * The consumer-overlay host slice (extension slice D — the frontend-extension-mechanism
6
+ * initiative). It is composed into `createUiModals`, so these tests drive it there directly
7
+ * (plain refs/functions, no Pinia). The pointer is pick-one: opening a second overlay replaces
8
+ * the first, and closing clears it — this is what `<AppOverlayHost>` reads to mount exactly one
9
+ * consumer overlay.
10
+ */
11
+ describe('ui overlay host (slice D)', () => {
12
+ it('starts with no active overlay', () => {
13
+ const ui = createUiModals()
14
+ expect(ui.activeOverlay.value).toBeNull()
15
+ })
16
+
17
+ it('openOverlay sets the active id + subject; closeOverlay clears it', () => {
18
+ const ui = createUiModals()
19
+ ui.openOverlay('acme:security-dashboard-overlay', { blockId: 'blk_1' })
20
+ expect(ui.activeOverlay.value).toEqual({
21
+ id: 'acme:security-dashboard-overlay',
22
+ subject: { blockId: 'blk_1' },
23
+ })
24
+ ui.closeOverlay()
25
+ expect(ui.activeOverlay.value).toBeNull()
26
+ })
27
+
28
+ it('openOverlay without a subject leaves subject undefined', () => {
29
+ const ui = createUiModals()
30
+ ui.openOverlay('acme:security-dashboard-overlay')
31
+ expect(ui.activeOverlay.value).toEqual({
32
+ id: 'acme:security-dashboard-overlay',
33
+ subject: undefined,
34
+ })
35
+ })
36
+
37
+ it('is pick-one — opening a second overlay replaces the first', () => {
38
+ const ui = createUiModals()
39
+ ui.openOverlay('acme:one')
40
+ ui.openOverlay('acme:two', 42)
41
+ expect(ui.activeOverlay.value).toEqual({ id: 'acme:two', subject: 42 })
42
+ })
43
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.152.1",
3
+ "version": "0.153.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,11 +21,11 @@
21
21
  "@modular-frontend/core": "^0.6.0",
22
22
  "@modular-vue/core": "^1.5.0",
23
23
  "@modular-vue/journeys": "^1.4.0",
24
- "@modular-vue/nuxt": "^0.4.0",
24
+ "@modular-vue/nuxt": "^0.4.1",
25
25
  "@modular-vue/runtime": "^1.4.1",
26
- "@modular-vue/vue": "^1.4.0",
26
+ "@modular-vue/vue": "^1.4.1",
27
27
  "@nuxt/ui": "^4.10.0",
28
- "@nuxtjs/i18n": "^10.4.1",
28
+ "@nuxtjs/i18n": "^10.5.0",
29
29
  "@pinia/nuxt": "^1.0.1",
30
30
  "@toad-contracts/core": "0.4.0",
31
31
  "@toad-contracts/frontend-http-client": "0.3.2",
@@ -45,13 +45,13 @@
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",
47
47
  "@types/markdown-it": "^14.1.2",
48
- "happy-dom": "^20.10.6",
48
+ "happy-dom": "^20.11.1",
49
49
  "msw": "^2.15.0",
50
50
  "nuxt": "^4.5.0",
51
51
  "typescript": "^6.0.3",
52
52
  "vitest": "^4.1.10",
53
53
  "vue-i18n-extract": "^2.0.7",
54
- "vue-tsc": "^3.3.7"
54
+ "vue-tsc": "^3.3.8"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "nuxt": "^4.5.0"