@cat-factory/app 0.274.0 → 0.276.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.
Files changed (69) hide show
  1. package/README.md +158 -4
  2. package/app/components/board/LaneViewControl.vue +87 -0
  3. package/app/components/board/nodes/BlockNode.vue +31 -11
  4. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  5. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  6. package/app/components/board/nodes/LaneGroup.vue +93 -0
  7. package/app/components/board/nodes/LaneTask.vue +66 -0
  8. package/app/components/board/nodes/TaskCard.vue +16 -2
  9. package/app/components/board/nodes/TaskLane.vue +82 -0
  10. package/app/components/layout/BoardToolbar.vue +4 -0
  11. package/app/components/layout/CommandBar.vue +8 -1
  12. package/app/components/layout/RolePrompt.vue +75 -0
  13. package/app/components/layout/SideBar.vue +22 -13
  14. package/app/components/layout/UiRoleSwitcher.vue +73 -0
  15. package/app/components/panels/InspectorPanel.vue +15 -2
  16. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  17. package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
  18. package/app/composables/useBlockDrag.ts +47 -17
  19. package/app/composables/useBlockQueries.ts +27 -24
  20. package/app/composables/useFrameLanes.ts +177 -0
  21. package/app/composables/useNavContributions.ts +3 -0
  22. package/app/composables/useTaskExpansion.ts +1 -1
  23. package/app/docs/consumer-extensions.md +20 -6
  24. package/app/modular/external-tools.spec.ts +0 -45
  25. package/app/modular/external-tools.ts +12 -23
  26. package/app/modular/nav-contributions.spec.ts +176 -17
  27. package/app/modular/nav-contributions.ts +106 -23
  28. package/app/modular/nav-gates.ts +11 -2
  29. package/app/modular/registry.spec.ts +1 -0
  30. package/app/modular/tutorial-tours.spec.ts +5 -3
  31. package/app/modular/tutorial-tours.ts +53 -8
  32. package/app/pages/index.vue +36 -7
  33. package/app/stores/board/placement.ts +7 -0
  34. package/app/stores/board.spec.ts +119 -14
  35. package/app/stores/laneView.spec.ts +61 -0
  36. package/app/stores/laneView.ts +85 -0
  37. package/app/stores/launchPrompt.ts +63 -0
  38. package/app/stores/taskExpansion.spec.ts +1 -1
  39. package/app/stores/taskExpansion.ts +1 -1
  40. package/app/stores/tutorial.ts +4 -4
  41. package/app/stores/uiMode.spec.ts +11 -0
  42. package/app/stores/uiMode.ts +14 -2
  43. package/app/stores/uiRole.spec.ts +185 -0
  44. package/app/stores/uiRole.ts +86 -0
  45. package/app/stores/workspaceSettings.ts +4 -0
  46. package/app/utils/framePlacement.ts +9 -4
  47. package/app/utils/laneGeometry.spec.ts +69 -0
  48. package/app/utils/laneGeometry.ts +104 -0
  49. package/app/utils/laneSort.spec.ts +236 -0
  50. package/app/utils/laneSort.ts +306 -0
  51. package/app/utils/swimlanes.spec.ts +259 -0
  52. package/app/utils/swimlanes.ts +355 -0
  53. package/app/utils/uiMode.spec.ts +12 -0
  54. package/app/utils/uiMode.ts +24 -6
  55. package/app/utils/uiRole.ts +123 -0
  56. package/i18n/locales/de.json +104 -3
  57. package/i18n/locales/en.json +110 -3
  58. package/i18n/locales/es.json +104 -3
  59. package/i18n/locales/fr.json +104 -3
  60. package/i18n/locales/he.json +104 -3
  61. package/i18n/locales/it.json +104 -3
  62. package/i18n/locales/ja.json +104 -3
  63. package/i18n/locales/pl.json +104 -3
  64. package/i18n/locales/tr.json +104 -3
  65. package/i18n/locales/uk.json +104 -3
  66. package/package.json +2 -2
  67. package/app/components/board/nodes/DraggableTask.vue +0 -58
  68. package/app/components/board/nodes/ModuleFrame.vue +0 -73
  69. package/app/stores/tutorial.prompt.ts +0 -59
@@ -2,13 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
2
2
  import { missingI18nKeys } from '../../test/i18nKeys'
3
3
  import {
4
4
  EXTERNAL_TOOL_UNAVAILABLE_KEYS,
5
- filterExternalTools,
6
5
  projectExternalTools,
7
6
  resolveExternalToolUrl,
8
7
  type ExternalToolContext,
9
8
  type ExternalToolContribution,
10
9
  } from './external-tools'
11
- import type { NavGates } from './nav-contributions'
12
10
 
13
11
  const CONTEXT: ExternalToolContext = {
14
12
  userId: 'usr_1',
@@ -27,26 +25,6 @@ const MAP_EDITOR: ExternalToolContribution = {
27
25
  `https://maps.acme.dev/edit?game=${ctx.metadata.gameId}&ws=${ctx.workspaceId}&user=${ctx.userId ?? ''}`,
28
26
  }
29
27
 
30
- const GATES: NavGates = {
31
- canWriteBoard: true,
32
- canManageIntegrations: true,
33
- canManageSettings: true,
34
- githubAvailable: true,
35
- libraryAvailable: true,
36
- designSourceConnected: true,
37
- infrastructureAvailable: true,
38
- accountsEnabled: true,
39
- isAccountAdmin: true,
40
- advancedMode: true,
41
- boardHasService: true,
42
- boardHasTask: true,
43
- boardHasRun: true,
44
- boardHasOpenDecision: true,
45
- boardHasPendingApproval: true,
46
- boardHasFinishedRun: true,
47
- boardHasFailedRun: true,
48
- }
49
-
50
28
  describe('resolveExternalToolUrl', () => {
51
29
  it('folds the invocation context into the resolved URL', () => {
52
30
  // The whole point of a resolver over a static link: the tool opens on the right game,
@@ -250,29 +228,6 @@ describe('projectExternalTools', () => {
250
228
  })
251
229
  })
252
230
 
253
- describe('filterExternalTools', () => {
254
- const tools: ExternalToolContribution[] = [
255
- { id: 'a', title: 'A', icon: 'i', url: 'https://a.dev' },
256
- { id: 'b', title: 'B', icon: 'i', url: 'https://b.dev', gate: (g) => g.canManageIntegrations },
257
- { id: 'c', title: 'C', icon: 'i', url: 'https://c.dev', advanced: true },
258
- ]
259
-
260
- it('applies the RBAC gate and the interface tier independently', () => {
261
- expect(filterExternalTools(tools, GATES).map((t) => t.id)).toEqual(['a', 'b', 'c'])
262
- expect(
263
- filterExternalTools(tools, { ...GATES, canManageIntegrations: false }).map((t) => t.id),
264
- ).toEqual(['a', 'c'])
265
- expect(filterExternalTools(tools, { ...GATES, advancedMode: false }).map((t) => t.id)).toEqual([
266
- 'a',
267
- 'b',
268
- ])
269
- })
270
-
271
- it('passes everything through with no gates service wired (dev-open parity)', () => {
272
- expect(filterExternalTools(tools, undefined).map((t) => t.id)).toEqual(['a', 'b', 'c'])
273
- })
274
- })
275
-
276
231
  describe('EXTERNAL_TOOL_UNAVAILABLE_KEYS', () => {
277
232
  it('names copy that exists for every reason', () => {
278
233
  // The exhaustive `Record` proves each reason HAS an entry; only this proves the entry still
@@ -1,5 +1,5 @@
1
1
  import { metadataValue } from './workspace-metadata'
2
- import type { NavContribution, NavGates } from './nav-contributions'
2
+ import type { NavContribution, NavGatedContribution } from './nav-contributions'
3
3
 
4
4
  /**
5
5
  * EXTERNAL TOOLS — a deployment's own web applications, registered programmatically and
@@ -56,8 +56,17 @@ export interface ExternalToolContext {
56
56
  */
57
57
  export type ExternalToolUrlResolver = (context: ExternalToolContext) => string | null
58
58
 
59
- /** One registered external tool. */
60
- export interface ExternalToolContribution {
59
+ /**
60
+ * One registered external tool.
61
+ *
62
+ * The visibility axes are INHERITED from {@link NavGatedContribution} rather than redeclared:
63
+ * a tool is projected onto a nav contribution and filtered by the same `navItemVisible`, so
64
+ * every axis a first-party destination answers to (the interface tier, the role's surface, the
65
+ * RBAC/availability predicate) is one this answers to as well. `intake` defaults the same way
66
+ * it does there, which for a registered application means the full-surface roles only until
67
+ * the deployment says otherwise.
68
+ */
69
+ export interface ExternalToolContribution extends NavGatedContribution {
61
70
  /** Namespaced id (`<ns>:<name>`), like every other consumer contribution. */
62
71
  id: string
63
72
  /** Display name. Literal copy, not an i18n key: a tool's name is deployment DATA (the same
@@ -83,10 +92,6 @@ export interface ExternalToolContribution {
83
92
  requiredMetadata?: readonly string[]
84
93
  /** Sidebar/palette order within the External tools section. Defaults to 0. */
85
94
  order?: number
86
- /** Reactive RBAC/availability predicate, exactly as on a {@link NavContribution}. */
87
- gate?: (gates: NavGates) => boolean
88
- /** Show only in advanced interface mode. */
89
- advanced?: boolean
90
95
  /** Stable selector for e2e. Defaults to `nav-external-tool-<id>`. */
91
96
  testId?: string
92
97
  }
@@ -247,19 +252,3 @@ export function projectExternalTools(
247
252
  return { tool, resolution, contribution }
248
253
  })
249
254
  }
250
-
251
- /**
252
- * Drop the tools the caller may not see, on the same two independent axes as `navSlotFilter`
253
- * applies to `nav`: the interface tier, then the item's own RBAC/availability predicate. With
254
- * no gates service wired (tests, a bare install) everything passes, matching the dev-open
255
- * "absent access allows all" parity the nav filter keeps.
256
- */
257
- export function filterExternalTools(
258
- tools: readonly ExternalToolContribution[],
259
- gates: NavGates | undefined,
260
- ): ExternalToolContribution[] {
261
- if (!gates) return [...tools]
262
- return tools.filter(
263
- (t) => (t.advanced ? gates.advancedMode : true) && (t.gate ? t.gate(gates) : true),
264
- )
265
- }
@@ -10,7 +10,8 @@ import {
10
10
  SIDEBAR_GROUP_ORDER,
11
11
  sortToolbar,
12
12
  } from './nav-contributions'
13
- import type { AppSlots, NavGates } from './nav-contributions'
13
+ import type { AppSlots, NavGatedContribution, NavGates } from './nav-contributions'
14
+ import type { ExternalToolContribution } from './external-tools'
14
15
 
15
16
  /** Prove every referenced key resolves in the layer's base catalog (see `test/i18nKeys`). */
16
17
  const hasKey = hasI18nKey
@@ -25,9 +26,11 @@ const NO_GATES: NavGates = {
25
26
  infrastructureAvailable: false,
26
27
  accountsEnabled: false,
27
28
  isAccountAdmin: false,
28
- // The permission axis is what these cases vary; keep the interface tier at `advanced`
29
- // so a dropped item is unambiguously an RBAC/availability drop, not a tier drop.
29
+ // The permission axis is what these cases vary; keep the interface tier at `advanced` and the
30
+ // role at the full surface, so a dropped item is unambiguously an RBAC/availability drop rather
31
+ // than a tier or role drop.
30
32
  advancedMode: true,
33
+ fullSurface: true,
31
34
  boardHasService: false,
32
35
  boardHasTask: false,
33
36
  boardHasRun: false,
@@ -48,6 +51,7 @@ const ALL_GATES: NavGates = {
48
51
  accountsEnabled: true,
49
52
  isAccountAdmin: true,
50
53
  advancedMode: true,
54
+ fullSurface: true,
51
55
  boardHasService: true,
52
56
  boardHasTask: true,
53
57
  boardHasRun: true,
@@ -185,6 +189,76 @@ describe('navSlotFilter', () => {
185
189
  }
186
190
  })
187
191
 
192
+ it('drops every non-intake destination for a narrowed role', () => {
193
+ const gates: NavGates = { ...ALL_GATES, fullSurface: false }
194
+ const kept = ids(navSlotFilter(slots(), { gates }))
195
+ const intakeOnly = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
196
+ expect(kept.sort()).toEqual(intakeOnly.sort())
197
+ // Fully permitted and on the advanced tier, so every drop here is the ROLE and nothing else:
198
+ // the platform configuration goes, and what teaches the product plus the way out stays.
199
+ expect(kept).toContain('tutorial')
200
+ expect(kept).toContain('ui-role')
201
+ expect(kept).not.toContain('build-pipeline')
202
+ expect(kept).not.toContain('add-from-repo')
203
+ expect(kept).not.toContain('integrations-hub')
204
+ expect(kept).not.toContain('workspace-settings')
205
+ expect(kept).not.toContain('model-providers')
206
+ expect(kept).not.toContain('account-settings')
207
+ // A narrowed sidebar is short by design, but never a shell with nothing in it: the sidebar
208
+ // is where the tutorials live, and `groupSidebar` drops an empty section upstream, so a
209
+ // surface whose every item was palette-only would render as a broken navbar.
210
+ const groups = groupSidebar((navSlotFilter(slots(), { gates }) as AppSlots).nav)
211
+ expect(groups.length).toBeGreaterThan(0)
212
+ for (const group of groups) expect(group.items.length).toBeGreaterThan(0)
213
+ })
214
+
215
+ it('states, per intake item, why the narrowed role keeps it', () => {
216
+ // The mirror of the advanced-item table above, and it earns its place for the opposite
217
+ // reason: `intake` is opt-IN, so the risk is not a silent capability loss but a silent
218
+ // WIDENING: one flag on a destination that configures the platform and the simplified
219
+ // surface has quietly stopped being simple. The table is the claim; adding `intake: true`
220
+ // fails here until the reason is written down.
221
+ const REASON: Record<string, string> = {
222
+ tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
223
+ 'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
224
+ 'ui-role': 'the way BACK out of the narrowed role',
225
+ }
226
+ const intake = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
227
+ expect(intake.sort()).toEqual(Object.keys(REASON).sort())
228
+ for (const [id, why] of Object.entries(REASON)) {
229
+ expect(why.length, `${id} has no stated reason`).toBeGreaterThan(0)
230
+ }
231
+ })
232
+
233
+ it('keeps the role switch reachable from inside the narrowed role, and the tier switch out', () => {
234
+ // The role decides which surfaces exist at all, so its own entry must survive the narrowing
235
+ // it causes, or a designer who needs a pipeline has no route to say so.
236
+ const roleItem = NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-role')
237
+ expect(roleItem?.intake).toBe(true)
238
+ expect(roleItem?.gate).toBeUndefined()
239
+ // The TIER toggle is deliberately the other way: a narrowed role's tier is capped at basic
240
+ // (`resolveUiMode`), so an entry that flipped it would write a preference nothing honours.
241
+ expect(NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-mode')?.intake).toBeUndefined()
242
+
243
+ const gates: NavGates = { ...NO_GATES, fullSurface: false }
244
+ const kept = ids(navSlotFilter(slots(), { gates }))
245
+ expect(kept).toContain('ui-role')
246
+ expect(kept).not.toContain('ui-mode')
247
+ })
248
+
249
+ it('keeps the tier, role and permission axes independent, and all three must pass', () => {
250
+ // `tutorial` is `intake` and ungated, `sandbox` is advanced + permissioned + not intake:
251
+ // no single axis reveals the second, and narrowing the role cannot reveal anything.
252
+ const narrowedButPermitted: NavGates = { ...ALL_GATES, fullSurface: false }
253
+ expect(ids(navSlotFilter(slots(), { gates: narrowedButPermitted }))).not.toContain('sandbox')
254
+
255
+ const fullButBasic: NavGates = { ...ALL_GATES, advancedMode: false }
256
+ expect(ids(navSlotFilter(slots(), { gates: fullButBasic }))).not.toContain('sandbox')
257
+
258
+ const fullButUnpermitted: NavGates = { ...NO_GATES, canManageIntegrations: false }
259
+ expect(ids(navSlotFilter(slots(), { gates: fullButUnpermitted }))).not.toContain('sandbox')
260
+ })
261
+
188
262
  it('keeps the tier switch itself reachable in basic mode', () => {
189
263
  // Basic is the shipped default, so this palette entry is how a user who never finds the
190
264
  // (icon-only, in the basic rail) sidebar switcher gets to the advanced half at all. Marking
@@ -226,26 +300,107 @@ describe('navSlotFilter', () => {
226
300
  })
227
301
 
228
302
  describe('navSlotFilter external tools', () => {
229
- const tools = [
230
- { id: 'acme:a', title: 'A', icon: 'i-lucide-link', url: 'https://a.dev' },
231
- {
232
- id: 'acme:b',
233
- title: 'B',
234
- icon: 'i-lucide-link',
235
- url: 'https://b.dev',
236
- gate: (g: NavGates) => g.canManageIntegrations,
237
- },
303
+ const tool = (id: string, axes: NavGatedContribution = {}): ExternalToolContribution => ({
304
+ id,
305
+ title: id,
306
+ icon: 'i-lucide-link',
307
+ url: `https://${id}.dev`,
308
+ ...axes,
309
+ })
310
+ const tools: ExternalToolContribution[] = [
311
+ tool('acme:open'),
312
+ tool('acme:permissioned', { gate: (g: NavGates) => g.canManageIntegrations }),
313
+ tool('acme:power-user', { advanced: true }),
314
+ tool('acme:intake', { intake: true }),
238
315
  ]
316
+ const withTools = (): AppSlots => ({ ...slots(), externalTools: [...tools] })
239
317
  const toolIds = (s: unknown) => (s as AppSlots).externalTools.map((t) => t.id)
240
318
 
241
319
  it('gates registered tools in the SAME filter as the nav catalog', () => {
242
320
  // They become nav items downstream (`useNavContributions` projects them), so gating them
243
321
  // anywhere else would let a tool the caller can't use reach the palette while its sidebar
244
322
  // twin was correctly hidden.
245
- const withTools = (): AppSlots => ({ ...slots(), externalTools: [...tools] })
246
- expect(toolIds(navSlotFilter(withTools(), { gates: ALL_GATES }))).toEqual(['acme:a', 'acme:b'])
247
- expect(toolIds(navSlotFilter(withTools(), { gates: NO_GATES }))).toEqual(['acme:a'])
248
- expect(toolIds(navSlotFilter(withTools(), {}))).toEqual(['acme:a', 'acme:b'])
323
+ expect(toolIds(navSlotFilter(withTools(), { gates: ALL_GATES }))).toEqual(
324
+ tools.map((t) => t.id),
325
+ )
326
+ expect(toolIds(navSlotFilter(withTools(), { gates: NO_GATES }))).toEqual([
327
+ 'acme:open',
328
+ 'acme:power-user',
329
+ 'acme:intake',
330
+ ])
331
+ // No gates service wired (tests, a bare install): everything passes, matching the dev-open
332
+ // "absent access allows all" parity the nav half keeps.
333
+ expect(toolIds(navSlotFilter(withTools(), {}))).toEqual(tools.map((t) => t.id))
334
+ })
335
+
336
+ it('drops a tool that has not opted into the intake surface', () => {
337
+ // The axis a separate external-tools filter used to miss entirely: a `designer` kept the
338
+ // deployment's whole External tools section while every first-party destination around it
339
+ // was hidden, which inverts the opt-in default rather than merely leaking one entry.
340
+ const narrowed: NavGates = { ...ALL_GATES, fullSurface: false }
341
+ expect(toolIds(navSlotFilter(withTools(), { gates: narrowed }))).toEqual(['acme:intake'])
342
+ })
343
+
344
+ it('keeps a tool and a nav entry declaring the same axes in lockstep', () => {
345
+ // The structural half, and the one a per-slot case cannot make: both slots run the same
346
+ // predicate today, so assert the PROPERTY that makes that worth keeping. A future axis
347
+ // wired into one slot and not the other fails here, whatever the axis turns out to be.
348
+ const AXES: NavGatedContribution[] = [
349
+ {},
350
+ { advanced: true },
351
+ { intake: true },
352
+ { gate: (g: NavGates) => g.canManageIntegrations },
353
+ { advanced: true, intake: true },
354
+ { advanced: true, gate: (g: NavGates) => g.canManageIntegrations },
355
+ { intake: true, gate: (g: NavGates) => g.canManageIntegrations },
356
+ { advanced: true, intake: true, gate: (g: NavGates) => g.canManageIntegrations },
357
+ ]
358
+
359
+ for (const [index, axes] of AXES.entries()) {
360
+ const id = `twin-${index}`
361
+ const paired = (gates: NavGates) => {
362
+ const filtered = navSlotFilter(
363
+ {
364
+ ...slots(),
365
+ nav: [
366
+ {
367
+ id,
368
+ labelKey: 'nav.kaizen',
369
+ icon: 'i-lucide-link',
370
+ surfaces: ['command'] as const,
371
+ ...axes,
372
+ },
373
+ ],
374
+ externalTools: [tool(id, axes)],
375
+ },
376
+ { gates },
377
+ ) as AppSlots
378
+ return {
379
+ nav: filtered.nav.some((i) => i.id === id),
380
+ tool: filtered.externalTools.some((t) => t.id === id),
381
+ }
382
+ }
383
+
384
+ // Every setting of the three gate fields these axes read; the rest of `NavGates` is
385
+ // invariant here, so varying it would only restate the same eight verdicts.
386
+ for (const advancedMode of [true, false]) {
387
+ for (const fullSurface of [true, false]) {
388
+ for (const canManageIntegrations of [true, false]) {
389
+ const gates: NavGates = {
390
+ ...ALL_GATES,
391
+ advancedMode,
392
+ fullSurface,
393
+ canManageIntegrations,
394
+ }
395
+ const verdict = paired(gates)
396
+ expect(
397
+ verdict.tool,
398
+ `${JSON.stringify(axes)} under ${JSON.stringify({ advancedMode, fullSurface, canManageIntegrations })}`,
399
+ ).toBe(verdict.nav)
400
+ }
401
+ }
402
+ }
403
+ }
249
404
  })
250
405
  })
251
406
 
@@ -348,7 +503,10 @@ describe('nav grouping helpers', () => {
348
503
  it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
349
504
  const workspace = groupCommands(NAV_CONTRIBUTIONS).find((g) => g.group === 'workspace')
350
505
  // Same order the old CommandBar pushed them in (parity, not a reorder), with genuinely
351
- // new entries appended after it rather than interleaved.
506
+ // new entries appended after it rather than interleaved. `ui-role` is the one deliberate
507
+ // exception, and it changes no existing entry's RELATIVE position: it sits beside `ui-mode`
508
+ // because the two answer one question ("how much of the app do I see"), and a user who finds
509
+ // one has found the other.
352
510
  expect(workspace?.items.map((ci) => ci.item.id)).toEqual([
353
511
  'fragments',
354
512
  'merge-thresholds',
@@ -359,6 +517,7 @@ describe('nav grouping helpers', () => {
359
517
  'sandbox',
360
518
  'keyboard-shortcuts',
361
519
  'ui-mode',
520
+ 'ui-role',
362
521
  'tutorial',
363
522
  'foundational-services',
364
523
  ])
@@ -1,5 +1,4 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
- import { filterExternalTools } from './external-tools'
3
2
  import type { AppSlots } from './slots'
4
3
 
5
4
  // Re-exported for the slice-1 importers that reach `AppSlots` through this
@@ -111,6 +110,18 @@ export interface NavGates {
111
110
  * available to a `gate` predicate that needs to combine it with something else.
112
111
  */
113
112
  advancedMode: boolean
113
+ /**
114
+ * The person's ROLE sees the whole product (`stores/uiRole.ts`): true for `engineer` and
115
+ * `product-manager`, false for the `intake` surface a `designer` gets.
116
+ *
117
+ * Read by {@link navSlotFilter} for every item that does NOT declare
118
+ * {@link NavContribution.intake}, i.e. the narrowing is opt-IN for the small surface rather
119
+ * than opt-out for the large one. Stated positively (a role that sees everything, not a
120
+ * "restricted" flag) for the same reason `advancedMode` is: this is the third axis a
121
+ * contribution answers to, and one of the three reading inverted is a destination shown to
122
+ * exactly the person it was hidden from.
123
+ */
124
+ fullSurface: boolean
114
125
  /**
115
126
  * The open board has at least one service frame. Availability, not permission: a
116
127
  * surface that operates ON a service (today the task-creation tutorial tour) has
@@ -206,12 +217,50 @@ export const NAV_ACTIONS = [
206
217
  'shortcuts',
207
218
  'tutorial',
208
219
  'toggleUiMode',
220
+ 'chooseRole',
209
221
  ] as const
210
222
 
211
223
  export type NavActionId = (typeof NAV_ACTIONS)[number]
212
224
 
225
+ /**
226
+ * The axes a rendered destination answers to, and the whole of what {@link navItemVisible}
227
+ * reads. Extended by {@link NavContribution} and by an external tool
228
+ * (`modular/external-tools.ts`), which is projected onto a nav item downstream.
229
+ *
230
+ * Shared as an interface rather than restated per contribution kind, so the two cannot declare
231
+ * different axes. That is not hypothetical: the role axis first landed on `NavContribution`
232
+ * alone, and external tools went on rendering for a role every first-party destination was
233
+ * hidden from. A fourth axis added here is one a tool must answer too, and the typecheck plus
234
+ * the single predicate are what say so.
235
+ */
236
+ export interface NavGatedContribution {
237
+ /** Reactive predicate over {@link NavGates}; absent = always visible. */
238
+ gate?: (g: NavGates) => boolean
239
+ /**
240
+ * A power-user destination: shown only in ADVANCED interface mode (basic mode is the
241
+ * everyday surface — see `stores/uiMode.ts`). Declarative rather than folded into
242
+ * {@link gate}, so an item keeps its RBAC/availability predicate unchanged and the two
243
+ * axes stay independently assertable. Absent = visible in both tiers.
244
+ */
245
+ advanced?: boolean
246
+ /**
247
+ * Offered on the INTAKE surface: the narrowed set a `designer` role gets (see
248
+ * `utils/uiRole.ts`). Absent = the full-surface roles only.
249
+ *
250
+ * Opt-in, and that direction is the point: a destination added later defaults to the roles
251
+ * that configure the platform, so a new entry can never quietly widen the simplified surface:
252
+ * the cost of getting it wrong is one flag, where the other default is a persona that stopped
253
+ * being simple without anyone deciding to un-simplify it. Independent of {@link advanced} and
254
+ * of {@link gate}: all three must pass.
255
+ *
256
+ * A deployment's own external tool declares it the same way, and defaults the same way: a
257
+ * registered application is platform surface until somebody says it is intake surface.
258
+ */
259
+ intake?: boolean
260
+ }
261
+
213
262
  /** One destination, declared once and rendered per surface. */
214
- export interface NavContribution {
263
+ export interface NavContribution extends NavGatedContribution {
215
264
  id: string
216
265
  /** Default (sidebar) label i18n key. */
217
266
  labelKey: string
@@ -225,15 +274,6 @@ export interface NavContribution {
225
274
  description?: string
226
275
  icon: string
227
276
  surfaces: readonly NavSurface[]
228
- /** Reactive predicate over {@link NavGates}; absent = always visible. */
229
- gate?: (g: NavGates) => boolean
230
- /**
231
- * A power-user destination: shown only in ADVANCED interface mode (basic mode is the
232
- * everyday surface — see `stores/uiMode.ts`). Declarative rather than folded into
233
- * {@link gate}, so an item keeps its RBAC/availability predicate unchanged and the two
234
- * axes stay independently assertable. Absent = visible in both tiers.
235
- */
236
- advanced?: boolean
237
277
  /**
238
278
  * First-party action id, resolved to a `run()` against the host `ui` store by
239
279
  * `useNavContributions`. A consumer module that has its own stores instead
@@ -292,6 +332,13 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
292
332
  * (`fragments`), the ephemeral-env + runner plumbing (`infrastructure`, which is also the only route
293
333
  * to the guided per-service Compose environment setup), and the workspace/model configuration
294
334
  * a run actually reads (`workspace-settings`, `model-config`).
335
+ *
336
+ * `intake: true` marks the OTHER axis (see `utils/uiRole.ts`): the three entries a narrowed role
337
+ * keeps. It is opt-in, and the set is short on purpose, because the intake surface brings work IN and
338
+ * watches it, and every destination here that is not `tutorial`, `keyboard-shortcuts` or
339
+ * `ui-role` configures the platform the work runs on. What that role creates instead lives on the
340
+ * BOARD (a frame's add-task / from-ticket / from-design buttons), which is why dropping nearly
341
+ * the whole sidebar costs it nothing it is there to do.
295
342
  */
296
343
  export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
297
344
  {
@@ -575,6 +622,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
575
622
  labelKey: 'nav.tutorials',
576
623
  icon: 'i-lucide-graduation-cap',
577
624
  surfaces: S('sidebar', 'command'),
625
+ // On the intake surface too, and it is the surface that needs it most: a narrowed role has
626
+ // the fewest destinations to learn the product from, and the delivery-loop tours are exactly
627
+ // the ones it can take (the platform half gates on the full surface: see `tutorial-tours.ts`).
628
+ intake: true,
578
629
  action: 'tutorial',
579
630
  testId: 'nav-tutorial',
580
631
  sidebar: { group: 'help', order: 10 },
@@ -594,6 +645,9 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
594
645
  labelKey: 'layout.commandBar.cmd.shortcuts',
595
646
  icon: 'i-lucide-keyboard',
596
647
  surfaces: S('command'),
648
+ // Help, like the tutorials: the cheatsheet describes the board and the palette, which every
649
+ // role has, and nothing it lists is a destination the intake surface hides.
650
+ intake: true,
597
651
  action: 'shortcuts',
598
652
  command: {
599
653
  group: 'workspace',
@@ -611,6 +665,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
611
665
  labelKey: 'layout.commandBar.cmd.toggleUiMode',
612
666
  icon: 'i-lucide-toggle-right',
613
667
  surfaces: S('command'),
668
+ // Deliberately NOT `intake`: a narrowed role's tier is CAPPED at basic (see `resolveUiMode`),
669
+ // so this entry would flip a preference the resolver then ignores, the same lie the sidebar
670
+ // switcher refuses to be under an env pin. The way out of a narrowed role is the entry below,
671
+ // which changes the surface for real.
614
672
  action: 'toggleUiMode',
615
673
  command: {
616
674
  group: 'workspace',
@@ -618,6 +676,25 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
618
676
  keywordsKey: 'layout.commandBar.keywords.toggleUiMode',
619
677
  },
620
678
  },
679
+ {
680
+ // The way BACK from a narrowed role, and the reason the intake surface is safe to ship: the
681
+ // role decides which surfaces exist at all, so a person who picked `designer` and then needs
682
+ // a pipeline has to be able to say so from inside that surface. Palette-only because the
683
+ // sidebar already carries the switcher itself (`UiRoleSwitcher`, above the destinations it
684
+ // gates, in EVERY role) rather than a shortcut to this question, the same relationship
685
+ // `ui-mode` has with the tier switcher.
686
+ id: 'ui-role',
687
+ labelKey: 'layout.commandBar.cmd.chooseRole',
688
+ icon: 'i-lucide-user-round-cog',
689
+ surfaces: S('command'),
690
+ intake: true,
691
+ action: 'chooseRole',
692
+ command: {
693
+ group: 'workspace',
694
+ order: 95,
695
+ keywordsKey: 'layout.commandBar.keywords.chooseRole',
696
+ },
697
+ },
621
698
  ]
622
699
 
623
700
  /**
@@ -633,21 +710,25 @@ export const navigationModule = defineModule({
633
710
  /**
634
711
  * Does a shell render this contribution under `gates`?
635
712
  *
636
- * The two axes a destination is gated on, in ONE place. They are independent and BOTH must
637
- * pass: an `advanced` item is dropped in basic mode, and every item still answers to its own
638
- * `gate`. Order doesn't matter (it's a conjunction) but the tier is checked first, since it's
639
- * the cheaper read.
713
+ * The three axes a destination is gated on, in ONE place. They are independent and ALL must
714
+ * pass: an `advanced` item is dropped in basic mode, an item that is not `intake` is dropped for
715
+ * a narrowed role, and every item still answers to its own `gate`. Order doesn't matter (it's a
716
+ * conjunction) but the two flag reads come first, since they're the cheaper ones.
640
717
  *
641
- * Named rather than inlined in {@link navSlotFilter} because a second reader has to agree with
718
+ * Named rather than inlined in {@link navSlotFilter} because other readers have to agree with
642
719
  * it exactly: a tutorial tour whose step CLICKS a nav entry declares the requirement that
643
720
  * renders it, and `tutorial-tours.spec.ts` pairs the two through this function. Spelling the
644
- * conjunction out there instead would be a copy that keeps passing while this one changes —
721
+ * conjunction out anywhere else would be a copy that keeps passing while this one changes —
645
722
  * and the drift it would miss (an entry gaining a gate clause, or being marked `advanced` and
646
723
  * so leaving the DEFAULT interface tier) is precisely a tour offered to a user who then finds
647
724
  * no such control.
648
725
  */
649
- export function navItemVisible(item: NavContribution, gates: NavGates): boolean {
650
- return (item.advanced ? gates.advancedMode : true) && (item.gate ? item.gate(gates) : true)
726
+ export function navItemVisible(item: NavGatedContribution, gates: NavGates): boolean {
727
+ return (
728
+ (item.advanced ? gates.advancedMode : true) &&
729
+ (gates.fullSurface || item.intake === true) &&
730
+ (item.gate ? item.gate(gates) : true)
731
+ )
651
732
  }
652
733
 
653
734
  /**
@@ -675,10 +756,12 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
675
756
  // richer value than a thinned list, so tour resolution lives in `resolveTourCatalogue`
676
757
  // (pure, gates-nullable) and runs once in `useTutorialTours`, whose `tours` is the same
677
758
  // gated set the launch prompt and the overlay always saw.
678
- // External tools gate on the same two axes as `nav` they become nav items downstream
679
- // (`useNavContributions` projects them), so gating them anywhere else would let a tool the
680
- // caller can't use reach the palette while its sidebar twin was correctly hidden.
681
- externalTools: filterExternalTools(externalTools, gates),
759
+ // A deployment's external tools run through the SAME predicate as `nav`, on the same line
760
+ // shape, because they become nav items downstream (`useNavContributions` projects them):
761
+ // gating them by any other expression lets a tool reach the palette while its first-party
762
+ // neighbours are correctly hidden. That is exactly what a separate `filterExternalTools`
763
+ // spelling its own conjunction did when the role axis landed.
764
+ externalTools: gates ? externalTools.filter((t) => navItemVisible(t, gates)) : externalTools,
682
765
  }
683
766
  }
684
767
 
@@ -15,8 +15,9 @@ import type { NavGates } from '~/modular/nav-contributions'
15
15
  *
16
16
  * This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
17
17
  * `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity), plus
18
- * the interface tier (`advancedMode`), which rides the same service so a mode flip
19
- * re-gates all three shells through the same reactive path as a permission flip.
18
+ * the interface tier (`advancedMode`) and the person's role (`fullSurface`), which ride
19
+ * the same service so a mode or role flip re-gates all three shells through the same
20
+ * reactive path as a permission flip.
20
21
  */
21
22
  export function createNavGates(): NavGates {
22
23
  const access = useWorkspaceAccess()
@@ -27,6 +28,7 @@ export function createNavGates(): NavGates {
27
28
  const auth = useAuthStore()
28
29
  const providerConnections = useProviderConnectionsStore()
29
30
  const uiMode = useUiModeStore()
31
+ const uiRole = useUiRoleStore()
30
32
  const board = useBoardStore()
31
33
  const execution = useExecutionStore()
32
34
  const reviews = useReviewStage()
@@ -108,6 +110,13 @@ export function createNavGates(): NavGates {
108
110
  get advancedMode() {
109
111
  return uiMode.isAdvanced
110
112
  },
113
+ // The role axis rides the same reactive service as the tier, so picking a role re-gates all
114
+ // three shells through one path. It is not a permission: every item it keeps is still gated
115
+ // by its own `gate`, and every item it drops is one the caller may well be allowed to open,
116
+ // which is why the switcher out of a narrowed role is itself `intake`.
117
+ get fullSurface() {
118
+ return uiRole.fullSurface
119
+ },
111
120
  get boardHasService() {
112
121
  return hasService.value
113
122
  },
@@ -14,6 +14,7 @@ const NO_GATES: NavGates = {
14
14
  accountsEnabled: false,
15
15
  isAccountAdmin: false,
16
16
  advancedMode: false,
17
+ fullSurface: false,
17
18
  boardHasService: false,
18
19
  boardHasTask: false,
19
20
  boardHasRun: false,
@@ -24,6 +24,7 @@ const ALL_GATES: NavGates = {
24
24
  accountsEnabled: true,
25
25
  isAccountAdmin: true,
26
26
  advancedMode: true,
27
+ fullSurface: true,
27
28
  boardHasService: true,
28
29
  boardHasTask: true,
29
30
  boardHasRun: true,
@@ -140,9 +141,10 @@ function navAnchoredSteps(): {
140
141
  * Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
141
142
  * gate sets — anything that satisfies a tour must also render its entry — between two
142
143
  * predicates written independently in two files, and nothing about their shape is guaranteed
143
- * (either may be a conjunction, a disjunction, or read a field the other doesn't). At fifteen
144
- * fields the whole matrix costs milliseconds, which is a fair price for a guard that needs no
145
- * assumption about how either side is spelled.
144
+ * (either may be a conjunction, a disjunction, or read a field the other doesn't). The matrix
145
+ * doubles with every field {@link NavGates} gains, so the cost is worth re-measuring rather than
146
+ * quoting from here: at the field count this spec runs against today it is a fraction of a
147
+ * second, a fair price for a guard that needs no assumption about how either side is spelled.
146
148
  */
147
149
  function* everyGateSet(): Generator<NavGates> {
148
150
  const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]