@cat-factory/app 0.132.0 → 0.133.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,483 @@
1
+ import { defineModule } from '@modular-vue/core'
2
+
3
+ /**
4
+ * The single nav/command catalog for the layer (slice 1 of the modular-vue
5
+ * adoption — docs/initiatives/modular-vue-adoption.md).
6
+ *
7
+ * Every destination is declared ONCE here as data and rendered three ways —
8
+ * `SideBar`, `CommandBar`, `BoardToolbar` — instead of each shell hand-rolling
9
+ * its own item list + RBAC gating (the pre-slice-1 triple-maintenance). RBAC /
10
+ * availability gating is a reactive `slotFilter` ({@link navSlotFilter}) over a
11
+ * reactive `gates` service (see `nav-gates.ts`), read through `useReactiveSlots`
12
+ * so an item shows/hides the instant a permission or connection flips — no
13
+ * `recalculateSlots()` call. A consumer deployment contributes its own items to
14
+ * the same `nav` slot via `registerAppModule`, so they light up in all three
15
+ * shells with zero shell edits.
16
+ */
17
+
18
+ /** Which shell(s) render a contribution. */
19
+ export type NavSurface = 'sidebar' | 'command' | 'toolbar'
20
+
21
+ /** Sidebar section a contribution lands in (its i18n header is `nav.<group>`). */
22
+ export type NavSidebarGroup =
23
+ | 'create'
24
+ | 'repositories'
25
+ | 'integrations'
26
+ | 'infrastructure'
27
+ | 'workspaceContext'
28
+ | 'configuration'
29
+
30
+ /** Command-palette group (its i18n label is `layout.commandBar.groups.<group>`). */
31
+ export type NavCommandGroup = 'create' | 'repositories' | 'integrations' | 'workspace' | 'account'
32
+
33
+ /**
34
+ * The reactive gate inputs a contribution's `gate` predicate reads. Backed by a
35
+ * plain service object whose getters read the host's reactive RBAC/availability
36
+ * state (see `createNavGates`), so reading them inside the `useReactiveSlots`
37
+ * computed tracks them.
38
+ */
39
+ export interface NavGates {
40
+ /** `board.write` — create pipelines, add repos. */
41
+ canWriteBoard: boolean
42
+ /** `integrations.manage` — bootstrap, connection/infra management, sandbox. */
43
+ canManageIntegrations: boolean
44
+ /** `settings.manage` — workspace/model config, fragment library. */
45
+ canManageSettings: boolean
46
+ /** The GitHub (source-control) integration is enabled on the backend. */
47
+ githubAvailable: boolean
48
+ /** The prompt-fragment library integration is enabled. */
49
+ libraryAvailable: boolean
50
+ /** An execution/test-env backend is reported (runner pool / environment / local). */
51
+ infrastructureAvailable: boolean
52
+ /** Accounts (auth) are enabled on the deployment. */
53
+ accountsEnabled: boolean
54
+ /** The caller is an admin of the active account. */
55
+ isAccountAdmin: boolean
56
+ }
57
+
58
+ /** Command-palette placement + copy for a contribution that appears in the palette. */
59
+ export interface NavCommandSpec {
60
+ group: NavCommandGroup
61
+ order: number
62
+ /** Palette label key; falls back to the contribution's `labelKey`. */
63
+ labelKey?: string
64
+ /** Extra fuzzy-match keywords (i18n key). */
65
+ keywordsKey?: string
66
+ }
67
+
68
+ /**
69
+ * The first-party action ids, each resolved to a host `ui`-store handler in
70
+ * `useNavContributions`. Typing {@link NavContribution.action} against this union
71
+ * (and the handler map as an exhaustive `Record<NavActionId, …>`) makes a drift
72
+ * between the catalog and the handler map a compile error instead of a silently
73
+ * dead button. Consumer modules don't use these — they carry their own `run`.
74
+ */
75
+ export const NAV_ACTIONS = [
76
+ 'buildPipeline',
77
+ 'addFromRepo',
78
+ 'bootstrapRepo',
79
+ 'integrationsHub',
80
+ 'sandbox',
81
+ 'kaizen',
82
+ 'infrastructure',
83
+ 'environmentSetup',
84
+ 'fragmentLibrary',
85
+ 'mergeThresholds',
86
+ 'workspaceSettings',
87
+ 'modelConfiguration',
88
+ 'serviceFragmentDefaults',
89
+ 'localModels',
90
+ 'accountSettings',
91
+ 'operatorDashboard',
92
+ 'shortcuts',
93
+ ] as const
94
+
95
+ export type NavActionId = (typeof NAV_ACTIONS)[number]
96
+
97
+ /** One destination, declared once and rendered per surface. */
98
+ export interface NavContribution {
99
+ id: string
100
+ /** Default (sidebar) label i18n key. */
101
+ labelKey: string
102
+ icon: string
103
+ surfaces: readonly NavSurface[]
104
+ /** Reactive predicate over {@link NavGates}; absent = always visible. */
105
+ gate?: (g: NavGates) => boolean
106
+ /**
107
+ * First-party action id, resolved to a `run()` against the host `ui` store by
108
+ * `useNavContributions`. A consumer module that has its own stores instead
109
+ * supplies {@link run} directly.
110
+ */
111
+ action?: NavActionId
112
+ /** Direct handler (consumer modules); takes precedence over {@link action}. */
113
+ run?: () => void
114
+ /** Stable selector for e2e / existing specs. */
115
+ testId?: string
116
+ /** Sidebar placement (present when `surfaces` includes `'sidebar'`). */
117
+ sidebar?: { group: NavSidebarGroup; order: number }
118
+ /** Command-palette placement (present when `surfaces` includes `'command'`). */
119
+ command?: NavCommandSpec
120
+ /** Toolbar placement (present when `surfaces` includes `'toolbar'`). */
121
+ toolbar?: { order: number }
122
+ }
123
+
124
+ /**
125
+ * The layer's slot map. Consumer modules contribute to the same `nav` key. The
126
+ * index signature is mutable (`unknown[]`) to satisfy the runtime's `SlotMap`
127
+ * constraint; `unknown[]` still satisfies `useReactiveSlots`' `readonly unknown[]`
128
+ * bound, so both the install and the read side accept it.
129
+ */
130
+ export interface AppSlots {
131
+ nav: NavContribution[]
132
+ [key: string]: unknown[]
133
+ }
134
+
135
+ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
136
+
137
+ /**
138
+ * The first-party catalog. Mapped 1:1 from the pre-slice-1 `SideBar` + `CommandBar`
139
+ * gating. Two deliberate consistency unifications noted in the tracker:
140
+ * - `account-settings` gates on `accountsEnabled` in BOTH shells (the palette
141
+ * previously showed it unconditionally; the account modal only makes sense
142
+ * with accounts enabled).
143
+ * - one icon per destination across shells.
144
+ */
145
+ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
146
+ {
147
+ id: 'build-pipeline',
148
+ labelKey: 'nav.buildPipeline',
149
+ icon: 'i-lucide-workflow',
150
+ surfaces: S('sidebar', 'command'),
151
+ gate: (g) => g.canWriteBoard,
152
+ action: 'buildPipeline',
153
+ testId: 'nav-build-pipeline',
154
+ sidebar: { group: 'create', order: 10 },
155
+ command: {
156
+ group: 'create',
157
+ order: 10,
158
+ labelKey: 'layout.commandBar.cmd.newPipeline',
159
+ keywordsKey: 'layout.commandBar.keywords.newPipeline',
160
+ },
161
+ },
162
+ {
163
+ id: 'add-from-repo',
164
+ labelKey: 'nav.addFromRepo',
165
+ icon: 'i-lucide-folder-git-2',
166
+ surfaces: S('sidebar', 'command'),
167
+ gate: (g) => g.githubAvailable && g.canWriteBoard,
168
+ action: 'addFromRepo',
169
+ testId: 'nav-add-from-repo',
170
+ sidebar: { group: 'repositories', order: 10 },
171
+ command: {
172
+ group: 'repositories',
173
+ order: 10,
174
+ labelKey: 'layout.commandBar.cmd.addFromRepo',
175
+ keywordsKey: 'layout.commandBar.keywords.addFromRepo',
176
+ },
177
+ },
178
+ {
179
+ id: 'bootstrap-repo',
180
+ labelKey: 'nav.bootstrapRepo',
181
+ icon: 'i-lucide-git-branch-plus',
182
+ surfaces: S('sidebar', 'command'),
183
+ gate: (g) => g.canManageIntegrations,
184
+ action: 'bootstrapRepo',
185
+ testId: 'nav-bootstrap-repo',
186
+ sidebar: { group: 'repositories', order: 20 },
187
+ command: {
188
+ group: 'repositories',
189
+ order: 20,
190
+ labelKey: 'layout.commandBar.cmd.bootstrapRepo',
191
+ keywordsKey: 'layout.commandBar.keywords.bootstrapRepo',
192
+ },
193
+ },
194
+ {
195
+ id: 'integrations-hub',
196
+ labelKey: 'nav.integrations',
197
+ icon: 'i-lucide-blocks',
198
+ surfaces: S('sidebar'),
199
+ gate: (g) => g.canManageIntegrations,
200
+ action: 'integrationsHub',
201
+ testId: 'nav-integrations',
202
+ sidebar: { group: 'integrations', order: 10 },
203
+ },
204
+ {
205
+ id: 'sandbox',
206
+ labelKey: 'nav.sandbox',
207
+ icon: 'i-lucide-flask-conical',
208
+ surfaces: S('sidebar', 'command'),
209
+ gate: (g) => g.canManageIntegrations,
210
+ action: 'sandbox',
211
+ testId: 'nav-sandbox',
212
+ sidebar: { group: 'integrations', order: 20 },
213
+ command: {
214
+ group: 'workspace',
215
+ order: 70,
216
+ labelKey: 'layout.commandBar.cmd.sandbox',
217
+ keywordsKey: 'layout.commandBar.keywords.sandbox',
218
+ },
219
+ },
220
+ {
221
+ id: 'kaizen',
222
+ labelKey: 'nav.kaizen',
223
+ icon: 'i-lucide-sparkles',
224
+ surfaces: S('sidebar'),
225
+ action: 'kaizen',
226
+ testId: 'nav-kaizen',
227
+ sidebar: { group: 'integrations', order: 30 },
228
+ },
229
+ {
230
+ id: 'infrastructure',
231
+ labelKey: 'nav.infrastructure',
232
+ icon: 'i-lucide-server-cog',
233
+ surfaces: S('sidebar'),
234
+ gate: (g) => g.infrastructureAvailable,
235
+ action: 'infrastructure',
236
+ testId: 'nav-infrastructure',
237
+ sidebar: { group: 'infrastructure', order: 10 },
238
+ },
239
+ {
240
+ id: 'environment-setup',
241
+ labelKey: 'nav.environmentSetup',
242
+ icon: 'i-lucide-flask-conical',
243
+ surfaces: S('sidebar'),
244
+ gate: (g) => g.infrastructureAvailable,
245
+ action: 'environmentSetup',
246
+ testId: 'nav-environment-setup',
247
+ sidebar: { group: 'infrastructure', order: 20 },
248
+ },
249
+ {
250
+ id: 'fragments',
251
+ labelKey: 'nav.contextFragments',
252
+ icon: 'i-lucide-book-marked',
253
+ surfaces: S('sidebar', 'command'),
254
+ gate: (g) => g.libraryAvailable && g.canManageSettings,
255
+ action: 'fragmentLibrary',
256
+ testId: 'nav-fragments',
257
+ sidebar: { group: 'workspaceContext', order: 10 },
258
+ command: {
259
+ group: 'workspace',
260
+ order: 10,
261
+ labelKey: 'layout.commandBar.cmd.fragments',
262
+ keywordsKey: 'layout.commandBar.keywords.fragments',
263
+ },
264
+ },
265
+ {
266
+ id: 'merge-thresholds',
267
+ labelKey: 'layout.commandBar.cmd.mergeThresholds',
268
+ icon: 'i-lucide-git-merge',
269
+ surfaces: S('command'),
270
+ gate: (g) => g.canManageSettings,
271
+ action: 'mergeThresholds',
272
+ command: {
273
+ group: 'workspace',
274
+ order: 20,
275
+ keywordsKey: 'layout.commandBar.keywords.mergeThresholds',
276
+ },
277
+ },
278
+ {
279
+ id: 'workspace-settings',
280
+ labelKey: 'nav.workspaceSettings',
281
+ icon: 'i-lucide-sliders-horizontal',
282
+ surfaces: S('sidebar', 'command'),
283
+ gate: (g) => g.canManageSettings,
284
+ action: 'workspaceSettings',
285
+ testId: 'nav-workspace-settings',
286
+ sidebar: { group: 'configuration', order: 10 },
287
+ command: {
288
+ group: 'workspace',
289
+ order: 30,
290
+ labelKey: 'layout.commandBar.cmd.workspaceSettings',
291
+ keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
292
+ },
293
+ },
294
+ {
295
+ id: 'model-config',
296
+ labelKey: 'nav.modelConfiguration',
297
+ icon: 'i-lucide-cpu',
298
+ surfaces: S('sidebar', 'command'),
299
+ gate: (g) => g.canManageSettings,
300
+ action: 'modelConfiguration',
301
+ testId: 'nav-model-config',
302
+ sidebar: { group: 'configuration', order: 20 },
303
+ command: {
304
+ group: 'workspace',
305
+ order: 40,
306
+ labelKey: 'layout.commandBar.cmd.modelConfiguration',
307
+ keywordsKey: 'layout.commandBar.keywords.modelConfiguration',
308
+ },
309
+ },
310
+ {
311
+ id: 'service-fragment-defaults',
312
+ labelKey: 'layout.commandBar.cmd.serviceFragmentDefaults',
313
+ icon: 'i-lucide-book-open-check',
314
+ surfaces: S('command'),
315
+ gate: (g) => g.canManageSettings,
316
+ action: 'serviceFragmentDefaults',
317
+ command: {
318
+ group: 'workspace',
319
+ order: 50,
320
+ keywordsKey: 'layout.commandBar.keywords.serviceFragmentDefaults',
321
+ },
322
+ },
323
+ {
324
+ id: 'local-models',
325
+ labelKey: 'layout.commandBar.cmd.localModels',
326
+ icon: 'i-lucide-server',
327
+ surfaces: S('command'),
328
+ action: 'localModels',
329
+ command: {
330
+ group: 'workspace',
331
+ order: 60,
332
+ keywordsKey: 'layout.commandBar.keywords.localModels',
333
+ },
334
+ },
335
+ {
336
+ id: 'account-settings',
337
+ labelKey: 'nav.accountSettings',
338
+ icon: 'i-lucide-users',
339
+ surfaces: S('sidebar', 'command'),
340
+ gate: (g) => g.accountsEnabled,
341
+ action: 'accountSettings',
342
+ testId: 'nav-account-settings',
343
+ sidebar: { group: 'configuration', order: 30 },
344
+ command: {
345
+ group: 'account',
346
+ order: 10,
347
+ labelKey: 'layout.commandBar.cmd.accountSettings',
348
+ keywordsKey: 'layout.commandBar.keywords.accountSettings',
349
+ },
350
+ },
351
+ {
352
+ id: 'operator-dashboard',
353
+ labelKey: 'nav.operatorDashboard',
354
+ icon: 'i-lucide-gauge',
355
+ surfaces: S('sidebar'),
356
+ gate: (g) => g.accountsEnabled && g.isAccountAdmin,
357
+ action: 'operatorDashboard',
358
+ testId: 'nav-operator-dashboard',
359
+ sidebar: { group: 'configuration', order: 40 },
360
+ },
361
+ {
362
+ id: 'keyboard-shortcuts',
363
+ labelKey: 'layout.commandBar.cmd.shortcuts',
364
+ icon: 'i-lucide-keyboard',
365
+ surfaces: S('command'),
366
+ action: 'shortcuts',
367
+ command: {
368
+ group: 'workspace',
369
+ order: 80,
370
+ keywordsKey: 'layout.commandBar.keywords.shortcuts',
371
+ },
372
+ },
373
+ ]
374
+
375
+ /**
376
+ * The first-party navigation module: contributes the whole catalog to the `nav`
377
+ * slot. Registered by `createAppRegistry`.
378
+ */
379
+ export const navigationModule = defineModule({
380
+ id: 'cat-factory:navigation',
381
+ version: '1.0.0',
382
+ slots: { nav: [...NAV_CONTRIBUTIONS] },
383
+ })
384
+
385
+ /**
386
+ * Reactive RBAC/availability filter over the merged `nav` slot. Reads
387
+ * `deps.gates.*` (the reactive gate service) per item, so evaluated inside
388
+ * `useReactiveSlots` it re-runs when a permission or connection flips. Passed to
389
+ * `installModularApp` as the global `slotFilter`.
390
+ *
391
+ * Typed against `AppSlots` (not the generic `SlotFilter`) so it matches the
392
+ * filter shape the runtime infers for this registry. `deps` is widened to an
393
+ * optional `gates` to avoid importing `AppDeps` (which would be circular).
394
+ */
395
+ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppSlots {
396
+ const gates = deps.gates
397
+ const nav = slots.nav ?? []
398
+ return {
399
+ ...slots,
400
+ // No gates service wired (tests / bare install) ⇒ show everything, matching
401
+ // the dev-open "absent access allows all" backend parity.
402
+ nav: gates ? nav.filter((i) => (i.gate ? i.gate(gates) : true)) : nav,
403
+ }
404
+ }
405
+
406
+ /** Sidebar sections, in render order; each header is `nav.<group>`. */
407
+ export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
408
+ 'create',
409
+ 'repositories',
410
+ 'integrations',
411
+ 'infrastructure',
412
+ 'workspaceContext',
413
+ 'configuration',
414
+ ]
415
+
416
+ /** Command-palette groups, in render order; each label is `layout.commandBar.groups.<group>`. */
417
+ export const COMMAND_GROUP_ORDER: readonly NavCommandGroup[] = [
418
+ 'create',
419
+ 'repositories',
420
+ 'integrations',
421
+ 'workspace',
422
+ 'account',
423
+ ]
424
+
425
+ export interface SidebarGroup {
426
+ group: NavSidebarGroup
427
+ /** i18n key for the section header. */
428
+ labelKey: string
429
+ items: NavContribution[]
430
+ }
431
+
432
+ export interface CommandItem {
433
+ item: NavContribution
434
+ /** Resolved palette label i18n key. */
435
+ labelKey: string
436
+ /** Resolved palette keywords i18n key, if any. */
437
+ keywordsKey?: string
438
+ }
439
+
440
+ export interface CommandGroup {
441
+ group: NavCommandGroup
442
+ /** i18n key for the group label. */
443
+ labelKey: string
444
+ items: CommandItem[]
445
+ }
446
+
447
+ /**
448
+ * Pure grouping/ordering helpers over an already-gated item list. Kept here (not
449
+ * in the composable) so they're unit-testable without a Vue/Nuxt runtime — the
450
+ * composable just feeds them the reactive `nav` slot. Each returns groups in
451
+ * canonical order with empty groups dropped and items sorted by their per-surface
452
+ * `order`.
453
+ */
454
+ export function groupSidebar(items: readonly NavContribution[]): SidebarGroup[] {
455
+ return SIDEBAR_GROUP_ORDER.map((group) => ({
456
+ group,
457
+ labelKey: `nav.${group}`,
458
+ items: items
459
+ .filter((i) => i.surfaces.includes('sidebar') && i.sidebar?.group === group)
460
+ .sort((a, b) => (a.sidebar?.order ?? 0) - (b.sidebar?.order ?? 0)),
461
+ })).filter((g) => g.items.length > 0)
462
+ }
463
+
464
+ export function groupCommands(items: readonly NavContribution[]): CommandGroup[] {
465
+ return COMMAND_GROUP_ORDER.map((group) => ({
466
+ group,
467
+ labelKey: `layout.commandBar.groups.${group}`,
468
+ items: items
469
+ .filter((i) => i.surfaces.includes('command') && i.command?.group === group)
470
+ .sort((a, b) => (a.command?.order ?? 0) - (b.command?.order ?? 0))
471
+ .map<CommandItem>((item) => ({
472
+ item,
473
+ labelKey: item.command?.labelKey ?? item.labelKey,
474
+ keywordsKey: item.command?.keywordsKey,
475
+ })),
476
+ })).filter((g) => g.items.length > 0)
477
+ }
478
+
479
+ export function sortToolbar(items: readonly NavContribution[]): NavContribution[] {
480
+ return items
481
+ .filter((i) => i.surfaces.includes('toolbar'))
482
+ .sort((a, b) => (a.toolbar?.order ?? 0) - (b.toolbar?.order ?? 0))
483
+ }
@@ -0,0 +1,63 @@
1
+ import { computed } from 'vue'
2
+ import type { NavGates } from '~/modular/nav-contributions'
3
+
4
+ /**
5
+ * Build the reactive `gates` service the nav `slotFilter` reads (slice 1 of the
6
+ * modular-vue adoption). Called once from the modular install plugin, where
7
+ * Pinia + composables are available.
8
+ *
9
+ * Returns a plain object whose getters read the host's reactive RBAC /
10
+ * availability state. Passed to the registry as a `service` (by reference, not
11
+ * snapshotted), so when `navSlotFilter` reads `gates.canWriteBoard` inside the
12
+ * `useReactiveSlots` computed the underlying reactive source is tracked — a
13
+ * permission or connection flip re-gates every shell with no `recalculateSlots()`.
14
+ *
15
+ * This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
16
+ * `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity).
17
+ */
18
+ export function createNavGates(): NavGates {
19
+ const access = useWorkspaceAccess()
20
+ const github = useGitHubStore()
21
+ const library = useFragmentLibraryStore()
22
+ const accounts = useAccountsStore()
23
+ const auth = useAuthStore()
24
+ const providerConnections = useProviderConnectionsStore()
25
+
26
+ const infrastructureAvailable = computed(
27
+ () =>
28
+ auth.infrastructure != null ||
29
+ auth.localMode?.enabled === true ||
30
+ providerConnections.isAvailable('runner-pool') ||
31
+ providerConnections.isAvailable('environment'),
32
+ )
33
+ const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
34
+
35
+ return {
36
+ get canWriteBoard() {
37
+ return access.canWriteBoard.value
38
+ },
39
+ get canManageIntegrations() {
40
+ return access.canManageIntegrations.value
41
+ },
42
+ get canManageSettings() {
43
+ return access.canManageSettings.value
44
+ },
45
+ get githubAvailable() {
46
+ return github.available === true
47
+ },
48
+ get libraryAvailable() {
49
+ return library.available === true
50
+ },
51
+ get infrastructureAvailable() {
52
+ // `integrations.manage` is required to provision/manage infrastructure, so
53
+ // gate the whole section on it too (a member/viewer would only 403 inside).
54
+ return access.canManageIntegrations.value && infrastructureAvailable.value
55
+ },
56
+ get accountsEnabled() {
57
+ return accounts.enabled
58
+ },
59
+ get isAccountAdmin() {
60
+ return isAccountAdmin.value
61
+ },
62
+ }
63
+ }
@@ -1,31 +1,76 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
2
  import { afterEach, describe, expect, it } from 'vitest'
3
3
  import { __resetConsumerModulesForTest, createAppRegistry, registerAppModule } from './registry'
4
+ import type { NavGates } from './nav-contributions'
5
+
6
+ const NO_GATES: NavGates = {
7
+ canWriteBoard: false,
8
+ canManageIntegrations: false,
9
+ canManageSettings: false,
10
+ githubAvailable: false,
11
+ libraryAvailable: false,
12
+ infrastructureAvailable: false,
13
+ accountsEnabled: false,
14
+ isAccountAdmin: false,
15
+ }
4
16
 
5
17
  describe('app modular registry', () => {
6
18
  afterEach(() => {
7
19
  __resetConsumerModulesForTest()
8
20
  })
9
21
 
10
- it('registers the first-party core module', () => {
11
- const manifest = createAppRegistry().resolveManifest()
12
- expect(manifest.modules.map((m) => m.id)).toContain('cat-factory:core')
22
+ it('registers the first-party navigation module', () => {
23
+ const manifest = createAppRegistry({ gates: NO_GATES }).resolveManifest()
24
+ expect(manifest.modules.map((m) => m.id)).toContain('cat-factory:navigation')
13
25
  })
14
26
 
15
27
  it('includes consumer modules contributed via registerAppModule', () => {
16
28
  registerAppModule(defineModule({ id: 'consumer:example', version: '1.0.0' }))
17
29
 
18
- const ids = createAppRegistry()
30
+ const ids = createAppRegistry({ gates: NO_GATES })
19
31
  .resolveManifest()
20
32
  .modules.map((m) => m.id)
21
33
 
22
- expect(ids).toContain('cat-factory:core')
34
+ expect(ids).toContain('cat-factory:navigation')
23
35
  expect(ids).toContain('consumer:example')
24
36
  })
25
37
 
26
38
  it('rejects a module whose id collides with an already-registered one', () => {
27
- registerAppModule(defineModule({ id: 'cat-factory:core', version: '2.0.0' }))
39
+ registerAppModule(defineModule({ id: 'cat-factory:navigation', version: '2.0.0' }))
40
+
41
+ expect(() => createAppRegistry({ gates: NO_GATES }).resolveManifest()).toThrow(/duplicate/i)
42
+ })
43
+
44
+ it('merges a consumer-contributed nav item into the resolved nav slot', () => {
45
+ // A deployment extending the layer contributes its own nav destination to the
46
+ // SAME `nav` slot the first-party module fills — it then renders in every shell
47
+ // with no shell edits (the slice-1 extensibility promise).
48
+ registerAppModule(
49
+ defineModule({
50
+ id: 'consumer:nav',
51
+ version: '1.0.0',
52
+ slots: {
53
+ nav: [
54
+ {
55
+ id: 'consumer:reports',
56
+ labelKey: 'consumer.reports',
57
+ icon: 'i-lucide-bar-chart',
58
+ surfaces: ['sidebar', 'toolbar'],
59
+ action: 'openReports',
60
+ sidebar: { group: 'configuration', order: 99 },
61
+ toolbar: { order: 10 },
62
+ },
63
+ ],
64
+ },
65
+ }),
66
+ )
28
67
 
29
- expect(() => createAppRegistry().resolveManifest()).toThrow(/duplicate/i)
68
+ const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
69
+ nav: { id: string }[]
70
+ }
71
+ const ids = slots.nav.map((i) => i.id)
72
+ // First-party catalog + the consumer item both present in one merged slot.
73
+ expect(ids).toContain('build-pipeline')
74
+ expect(ids).toContain('consumer:reports')
30
75
  })
31
76
  })