@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.
- package/app/components/panels/AppOverlayHost.logic.spec.ts +38 -0
- package/app/components/panels/AppOverlayHost.logic.ts +24 -0
- package/app/components/panels/AppOverlayHost.vue +57 -0
- package/app/composables/useAppOverlays.ts +26 -0
- package/app/docs/consumer-extensions.md +29 -0
- package/app/modular/nav-contributions.spec.ts +1 -0
- package/app/modular/registry.spec.ts +23 -0
- package/app/modular/registry.ts +1 -0
- package/app/modular/slots.ts +21 -0
- package/app/pages/index.vue +4 -0
- package/app/plugins/modular.client.ts +6 -1
- package/app/stores/ui/modals.ts +29 -0
- package/app/stores/ui/overlays.spec.ts +43 -0
- package/package.json +1 -1
|
@@ -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`
|
|
@@ -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
|
})
|
package/app/modular/registry.ts
CHANGED
package/app/modular/slots.ts
CHANGED
|
@@ -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>
|
package/app/pages/index.vue
CHANGED
|
@@ -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[])
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -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.
|
|
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",
|