@cat-factory/app 0.273.2 → 0.275.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 (42) hide show
  1. package/README.md +69 -2
  2. package/app/components/board/nodes/BlockNode.vue +7 -1
  3. package/app/components/layout/CommandBar.vue +8 -1
  4. package/app/components/layout/NotificationsInbox.vue +4 -0
  5. package/app/components/layout/RolePrompt.vue +75 -0
  6. package/app/components/layout/SideBar.vue +22 -13
  7. package/app/components/layout/UiRoleSwitcher.vue +73 -0
  8. package/app/components/slack/SlackPanel.vue +2 -0
  9. package/app/composables/useNavContributions.ts +3 -0
  10. package/app/docs/consumer-extensions.md +20 -6
  11. package/app/modular/external-tools.spec.ts +0 -45
  12. package/app/modular/external-tools.ts +12 -23
  13. package/app/modular/nav-contributions.spec.ts +176 -17
  14. package/app/modular/nav-contributions.ts +106 -23
  15. package/app/modular/nav-gates.ts +11 -2
  16. package/app/modular/registry.spec.ts +1 -0
  17. package/app/modular/tutorial-tours.spec.ts +5 -3
  18. package/app/modular/tutorial-tours.ts +53 -8
  19. package/app/pages/index.vue +36 -7
  20. package/app/stores/launchPrompt.ts +63 -0
  21. package/app/stores/tutorial.ts +4 -4
  22. package/app/stores/uiMode.spec.ts +11 -0
  23. package/app/stores/uiMode.ts +14 -2
  24. package/app/stores/uiRole.spec.ts +185 -0
  25. package/app/stores/uiRole.ts +86 -0
  26. package/app/utils/catalog.spec.ts +5 -10
  27. package/app/utils/catalog.ts +13 -0
  28. package/app/utils/uiMode.spec.ts +12 -0
  29. package/app/utils/uiMode.ts +24 -6
  30. package/app/utils/uiRole.ts +123 -0
  31. package/i18n/locales/de.json +29 -0
  32. package/i18n/locales/en.json +35 -0
  33. package/i18n/locales/es.json +29 -0
  34. package/i18n/locales/fr.json +29 -0
  35. package/i18n/locales/he.json +29 -0
  36. package/i18n/locales/it.json +29 -0
  37. package/i18n/locales/ja.json +29 -0
  38. package/i18n/locales/pl.json +29 -0
  39. package/i18n/locales/tr.json +29 -0
  40. package/i18n/locales/uk.json +29 -0
  41. package/package.json +2 -2
  42. package/app/stores/tutorial.prompt.ts +0 -59
@@ -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)[]
@@ -130,6 +130,17 @@ export const TUTORIAL_REQUIREMENTS = {
130
130
  labelKey: 'tutorial.requirements.advancedTier',
131
131
  met: (gates) => gates.advancedMode,
132
132
  },
133
+ // The other SPA-narrowing axis, and it is a requirement for exactly the same reason the tier is:
134
+ // a narrowed ROLE (`utils/uiRole.ts`) keeps only the `intake` nav entries, so every tour whose
135
+ // step CLICKS one of the others would be offered to a designer and then hunt for a sidebar entry
136
+ // their screen does not render. Which tours those are is not a judgement call: it is the pairing
137
+ // `tutorial-tours.spec.ts` derives from `navItemVisible`, so a tour that gains a nav-anchored
138
+ // step fails there until this is declared.
139
+ fullSurface: {
140
+ id: 'full-surface',
141
+ labelKey: 'tutorial.requirements.fullSurface',
142
+ met: (gates) => gates.fullSurface,
143
+ },
133
144
  // The platform half's requirements. Each mirrors, exactly, the `gate` of the sidebar entry the
134
145
  // tour clicks (`nav-model-providers` / `nav-integrations`, `nav-fragments`). A requirement
135
146
  // WEAKER than the gate of the control a step points at offers the tour to a user who has no
@@ -204,15 +215,33 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
204
215
  titleKey: 'tutorial.tours.boardBasics.steps.toolbar.title',
205
216
  bodyKey: 'tutorial.tours.boardBasics.steps.toolbar.body',
206
217
  },
218
+ {
219
+ // The role decides which surfaces exist at all, so the orientation tour names it before
220
+ // the tier it caps. No `when`: the switcher is rendered in EVERY role (it is the way back
221
+ // out of the narrowed one), which is exactly why the tour can always point at it.
222
+ id: 'role',
223
+ target: 'ui-role-switcher',
224
+ altTargets: ['ui-role-toggle'],
225
+ placement: 'right',
226
+ titleKey: 'tutorial.tours.boardBasics.steps.role.title',
227
+ bodyKey: 'tutorial.tours.boardBasics.steps.role.body',
228
+ },
207
229
  {
208
230
  // Basic mode is the shipped default, and it HIDES a whole half of the product
209
231
  // (sandbox, Kaizen, bootstrap, the operator surfaces). A user who never finds the
210
232
  // switcher never learns that half exists, so the orientation tour is the one place
211
233
  // that has to name it — the switcher is deliberately visible in both tiers for the
212
234
  // same reason (see `nav-contributions.ts`).
235
+ //
236
+ // `when` rather than a tour-level requirement, and rather than nothing: a narrowed role's
237
+ // tier is CAPPED at basic, so the sidebar drops this switcher, and a step that merely
238
+ // missed its anchor would be reported as an abridged tour, which is the wrong thing to say
239
+ // about a control that is absent because of a choice the user made. Gating the whole tour
240
+ // would be worse still: orientation is the one walkthrough every role should get.
213
241
  id: 'interfaceTier',
214
242
  target: 'ui-mode-switcher',
215
243
  altTargets: ['ui-mode-toggle'],
244
+ when: (gates) => gates.fullSurface,
216
245
  placement: 'right',
217
246
  titleKey: 'tutorial.tours.boardBasics.steps.interfaceTier.title',
218
247
  bodyKey: 'tutorial.tours.boardBasics.steps.interfaceTier.body',
@@ -236,7 +265,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
236
265
  // repo is a board write against a connected source, and in basic interface mode
237
266
  // add-from-repo is the ONLY route (bootstrap is advanced), which is what makes this
238
267
  // worth a tour rather than a hint.
239
- requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.sourceControl],
268
+ requires: [
269
+ TUTORIAL_REQUIREMENTS.boardWrite,
270
+ TUTORIAL_REQUIREMENTS.sourceControl,
271
+ TUTORIAL_REQUIREMENTS.fullSurface,
272
+ ],
240
273
  steps: [
241
274
  {
242
275
  id: 'intro',
@@ -668,7 +701,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
668
701
  // onboarding advisory, which the launch prompt stands down for) — this is for the person who
669
702
  // meets the question later, or who wants to know where the answer lives.
670
703
  offeredAtLaunch: false,
671
- requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
704
+ requires: [TUTORIAL_REQUIREMENTS.integrationsManage, TUTORIAL_REQUIREMENTS.fullSurface],
672
705
  steps: [
673
706
  {
674
707
  id: 'intro',
@@ -712,7 +745,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
712
745
  // change. A user who never finds the builder treats the built-in catalog as the product's
713
746
  // fixed shape and works around it in task descriptions instead.
714
747
  offeredAtLaunch: false,
715
- requires: [TUTORIAL_REQUIREMENTS.boardWrite],
748
+ requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.fullSurface],
716
749
  steps: [
717
750
  {
718
751
  id: 'intro',
@@ -774,7 +807,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
774
807
  // How you steer output without restating your conventions in every task description, which
775
808
  // is what people do instead when they never find this.
776
809
  offeredAtLaunch: false,
777
- requires: [TUTORIAL_REQUIREMENTS.library, TUTORIAL_REQUIREMENTS.settingsManage],
810
+ requires: [
811
+ TUTORIAL_REQUIREMENTS.library,
812
+ TUTORIAL_REQUIREMENTS.settingsManage,
813
+ TUTORIAL_REQUIREMENTS.fullSurface,
814
+ ],
778
815
  steps: [
779
816
  {
780
817
  id: 'intro',
@@ -813,7 +850,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
813
850
  // Each integration changes what a run can SEE or SAY, and none of them announces itself:
814
851
  // a board with no tracker linked simply never mentions that issues could arrive on their own.
815
852
  offeredAtLaunch: false,
816
- requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
853
+ requires: [TUTORIAL_REQUIREMENTS.integrationsManage, TUTORIAL_REQUIREMENTS.fullSurface],
817
854
  steps: [
818
855
  {
819
856
  id: 'intro',
@@ -854,7 +891,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
854
891
  // itself, and the cost of not finding it is a run that fails on provisioning with a banner
855
892
  // pointing at a window the user has never opened.
856
893
  offeredAtLaunch: false,
857
- requires: [TUTORIAL_REQUIREMENTS.infrastructure],
894
+ requires: [TUTORIAL_REQUIREMENTS.infrastructure, TUTORIAL_REQUIREMENTS.fullSurface],
858
895
  steps: [
859
896
  {
860
897
  id: 'intro',
@@ -906,7 +943,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
906
943
  // workspace that has never made a group renders nothing for the anchored step to find. The
907
944
  // guard only pairs a tour against the visibility of a NAV entry, so a section hiding itself
908
945
  // one level in is exactly the case that has to be declared by hand.
909
- requires: [TUTORIAL_REQUIREMENTS.settingsManage, TUTORIAL_REQUIREMENTS.advancedTier],
946
+ requires: [
947
+ TUTORIAL_REQUIREMENTS.settingsManage,
948
+ TUTORIAL_REQUIREMENTS.advancedTier,
949
+ TUTORIAL_REQUIREMENTS.fullSurface,
950
+ ],
910
951
  steps: [
911
952
  {
912
953
  id: 'intro',
@@ -950,7 +991,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
950
991
  // renders the entry this tour clicks. Declared rather than assumed: `navRequirementDrift`
951
992
  // enumerates the whole gate matrix against that entry's own visibility rule and fails
952
993
  // without it.
953
- requires: [TUTORIAL_REQUIREMENTS.settingsManage, TUTORIAL_REQUIREMENTS.advancedTier],
994
+ requires: [
995
+ TUTORIAL_REQUIREMENTS.settingsManage,
996
+ TUTORIAL_REQUIREMENTS.advancedTier,
997
+ TUTORIAL_REQUIREMENTS.fullSurface,
998
+ ],
954
999
  steps: [
955
1000
  {
956
1001
  id: 'intro',