@cat-factory/app 0.152.1 → 0.153.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.
@@ -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>
@@ -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
+ }
@@ -69,6 +69,7 @@ export default defineNuxtPlugin(() => {
69
69
  | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
70
70
  | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
71
71
  | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
72
+ | Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
72
73
  | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
73
74
  | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
74
75
 
@@ -135,6 +136,33 @@ task created with it round-trips with zero host edits.
135
136
  > silently — the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
136
137
  > descriptor `fields`. Prefer backend registration when you want the fail-fast guardrail.
137
138
 
139
+ ### Top-level overlays (`appOverlays`)
140
+
141
+ A nav item's `run` closure — or any consumer code — often needs to open a full-screen panel of
142
+ its own: a dashboard, a wizard, a settings surface. The layer's first-party modals are
143
+ hand-mounted in `pages/index.vue`, which a consumer can't edit, so the `appOverlays` slot + the
144
+ single `<AppOverlayHost>` are the seam:
145
+
146
+ 1. Contribute `{ id: '<ns>:<name>', component }` to the `appOverlays` slot (see
147
+ `acme:security-dashboard-overlay` in the example module).
148
+ 2. Open it from anywhere with the auto-imported `useAppOverlays().open('<ns>:<name>', subject?)`
149
+ — typically a nav item's `run` closure. The optional `subject` is any value your overlay
150
+ renders against (e.g. a block id); it reaches the component as a `subject` prop.
151
+ 3. `<AppOverlayHost>` resolves the slot with `resolveComponentRegistry` (the same pick-one
152
+ primitive `resultViews` uses) and mounts the matching component, wiring its `close` emit to
153
+ `useAppOverlays().close()`.
154
+
155
+ It is a **pick-one** host: opening a second overlay replaces the first, and `close()` clears it.
156
+ Compose the shared `ResultWindowShell` (via `#components`) for chrome so your overlay inherits
157
+ focus-trap / scroll-lock / shared-stack Escape — emit `close` from its `@close`. A dangling open
158
+ (`open('<ns>:x')` with no registered component — e.g. a stale closure after the extension was
159
+ removed) degrades to nothing (a dev-console warning names the id), never a crash. Duplicate ids
160
+ across modules throw at boot, like every other slot.
161
+
162
+ > **Scope.** This seam is for CONSUMER overlays. The layer's own ~34 first-party modals stay
163
+ > hand-mounted in `index.vue` and are migrated only opportunistically — don't reach for
164
+ > `appOverlays` to replace a first-party fast-path modal.
165
+
138
166
  ## Reuse the shared building blocks — don't reinvent them
139
167
 
140
168
  The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
@@ -152,6 +180,7 @@ below). Compose these:
152
180
  | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
153
181
  | `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
182
  | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
183
+ | `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
184
 
156
185
  > **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
157
186
  > 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.0",
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",