@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
@@ -155,6 +155,9 @@ const TutorialOverlay = defineAsyncComponent(
155
155
  () => import('~/components/tutorial/TutorialOverlay.vue'),
156
156
  )
157
157
  const TutorialNudge = defineAsyncComponent(() => import('~/components/tutorial/TutorialNudge.vue'))
158
+ // The first-run role question (Engineer / Product manager / Designer). Same shape as the tutorial
159
+ // launch prompt: mounted only while its store flag is set, so an answered question costs nothing.
160
+ const RolePrompt = defineAsyncComponent(() => import('~/components/layout/RolePrompt.vue'))
158
161
 
159
162
  const workspace = useWorkspaceStore()
160
163
  const github = useGitHubStore()
@@ -312,11 +315,11 @@ const needsGitHubInstall = computed(() => github.available === true && !github.c
312
315
  const githubProbePending = computed(() => github.available === null)
313
316
 
314
317
  // Offer the tutorial on launch, once the board is up. Yields to every other startup
315
- // surface — the GitHub onboarding gate and the advisory/onboarding modals above so a
316
- // first launch never stacks the tour prompt on top of a dialog that needs answering
317
- // first; when one of those is open, the flip of its flag re-fires this watcher and the
318
- // prompt appears then. The store guards the rest: only a user who never answered is
319
- // asked, at most once per session.
318
+ // surface — the GitHub onboarding gate, the advisory/onboarding modals above, and the role
319
+ // question below — so a first launch never stacks the tour prompt on top of a dialog that
320
+ // needs answering first; when one of those is open, the flip of its flag re-fires this
321
+ // watcher and the prompt appears then. The store guards the rest: only a user who never
322
+ // answered is asked, at most once per session.
320
323
  //
321
324
  // Yielding runs in BOTH directions: an advisory that opens LATER (a health probe that
322
325
  // resolves a beat after the board) would otherwise land on top of an open tour prompt,
@@ -330,6 +333,7 @@ const githubProbePending = computed(() => github.available === null)
330
333
  // state pays nothing for the launch offer: no watcher, no mounted component (the v-ifs
331
334
  // below), no store reads.
332
335
  const tutorial = useTutorialStore()
336
+ const uiRole = useUiRoleStore()
333
337
  const startupAdvisoryOpen = computed(
334
338
  () =>
335
339
  needsGitHubInstall.value ||
@@ -340,6 +344,30 @@ const startupAdvisoryOpen = computed(
340
344
  ui.aiProviderSetupOpen ||
341
345
  ui.aiPresetMismatchOpen,
342
346
  )
347
+
348
+ // The ROLE question comes before the tour offer, and the ordering is the point: the role decides
349
+ // which surfaces exist, so a tour picked ahead of it could be about half a product the next answer
350
+ // removes. It runs the same launch machine as the tutorial offer (yield to anything the user must
351
+ // actually answer, re-arm when that surface goes, at most one offer per session) and stops itself
352
+ // once it can no longer do anything.
353
+ const roleOfferSettled = () => uiRole.chosen || (uiRole.promptAutoOpened && !uiRole.promptOpen)
354
+ if (!roleOfferSettled()) {
355
+ let stopRoleOffer: (() => void) | undefined
356
+ stopRoleOffer = watch(
357
+ () => [workspace.ready, startupAdvisoryOpen.value, uiRole.promptOpen],
358
+ () => {
359
+ if (startupAdvisoryOpen.value) uiRole.deferPrompt()
360
+ else if (workspace.ready) uiRole.maybeOfferOnLaunch()
361
+ if (roleOfferSettled()) stopRoleOffer?.()
362
+ },
363
+ { immediate: true },
364
+ )
365
+ if (roleOfferSettled()) stopRoleOffer()
366
+ }
367
+ // What the TOUR offer yields to: every startup advisory, plus the role question above it. The
368
+ // role prompt is not in `startupAdvisoryOpen` itself, or the role offer would defer to its own
369
+ // standing offer and withdraw it a tick after making it.
370
+ const tutorialYieldsTo = computed(() => startupAdvisoryOpen.value || uiRole.promptOpen)
343
371
  // Settled = the offer can never need to act again: a decision exists, or the prompt was
344
372
  // auto-opened and is still standing (a deferral clears `promptAutoOpened`, which is
345
373
  // exactly what keeps the watcher alive to re-offer).
@@ -350,9 +378,9 @@ if (!tutorialOfferSettled()) {
350
378
  // inside `watch(...)`, before the handle is assigned — the trailing check covers it.
351
379
  let stopTutorialOffer: (() => void) | undefined
352
380
  stopTutorialOffer = watch(
353
- () => [workspace.ready, startupAdvisoryOpen.value, tutorial.promptOpen],
381
+ () => [workspace.ready, tutorialYieldsTo.value, tutorial.promptOpen],
354
382
  () => {
355
- if (startupAdvisoryOpen.value) tutorial.deferPrompt()
383
+ if (tutorialYieldsTo.value) tutorial.deferPrompt()
356
384
  else if (workspace.ready) tutorial.maybeOfferOnLaunch()
357
385
  if (tutorialOfferSettled()) stopTutorialOffer?.()
358
386
  },
@@ -506,6 +534,7 @@ watch(
506
534
  <VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
507
535
  <AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
508
536
  <AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
537
+ <RolePrompt v-if="uiRole.promptOpen" />
509
538
  <TutorialPrompt v-if="tutorial.promptOpen" />
510
539
  <TutorialCatalogue v-if="tutorial.catalogueOpen" />
511
540
  <TutorialOverlay v-if="tutorial.touring" />
@@ -0,0 +1,63 @@
1
+ import { ref } from 'vue'
2
+
3
+ /**
4
+ * The state machine behind a ONCE-PER-SESSION launch offer: the tutorial's "take a tour?"
5
+ * (`stores/tutorial.ts`) and the role question (`stores/uiRole.ts`) are the same shape, so
6
+ * they share this rather than each re-deriving it.
7
+ *
8
+ * It is a small machine with four distinguishable exits and no other job, which is what makes
9
+ * it a seam worth having rather than four refs among twenty: closing without answering, an
10
+ * explicit decline (the caller's own, since only it knows what a decision IS), a DEFERRAL
11
+ * (something the user must actually answer opened on top), and the once-per-session auto-open
12
+ * are four different things, and only some of them write anything down. The subtlety they share
13
+ * is the ONE-OFFER-PER-SESSION guard, which is why they belong together: `promptAutoOpened`
14
+ * must be spent by an offer the user saw and NOT by one that was withdrawn.
15
+ *
16
+ * `hasDecision` is a bound getter over the caller's persisted record rather than the record
17
+ * itself, so this module never learns what a decision IS — only whether one exists, which is
18
+ * the whole of what the offer needs.
19
+ */
20
+ export function createLaunchPrompt(deps: { hasDecision: () => boolean }) {
21
+ const promptOpen = ref(false)
22
+ /** Once-per-session guard for the launch auto-open; later opens are user-driven. */
23
+ const promptAutoOpened = ref(false)
24
+
25
+ /**
26
+ * Auto-open the launch prompt, at most once per session and only while the user has never
27
+ * answered it. Callers gate on the rest of the launch context (board ready, no other startup
28
+ * advisory open) — see `pages/index.vue`.
29
+ */
30
+ function maybeOfferOnLaunch() {
31
+ if (deps.hasDecision() || promptAutoOpened.value) return
32
+ promptAutoOpened.value = true
33
+ promptOpen.value = true
34
+ }
35
+
36
+ /** User-driven open (command palette), regardless of any saved decision. */
37
+ function openPrompt() {
38
+ promptOpen.value = true
39
+ }
40
+
41
+ /**
42
+ * Withdraw an offer this store made, because something the user actually has to answer (a
43
+ * startup advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so the offer
44
+ * returns once that surface is gone. Distinct from {@link closePrompt}: no decision is written
45
+ * EITHER way, but a deferral was not the user's doing, so it must not consume this session's
46
+ * one offer.
47
+ *
48
+ * Only ever withdraws the AUTO-opened prompt; a prompt the user opened themselves from the
49
+ * palette is theirs to close.
50
+ */
51
+ function deferPrompt() {
52
+ if (!promptAutoOpened.value) return
53
+ promptOpen.value = false
54
+ promptAutoOpened.value = false
55
+ }
56
+
57
+ /** Close without answering: no decision is written, so the next launch asks again. */
58
+ function closePrompt() {
59
+ promptOpen.value = false
60
+ }
61
+
62
+ return { promptOpen, promptAutoOpened, maybeOfferOnLaunch, openPrompt, deferPrompt, closePrompt }
63
+ }
@@ -1,6 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
- import { createTutorialPrompt } from '~/stores/tutorial.prompt'
3
+ import { createLaunchPrompt } from '~/stores/launchPrompt'
4
4
  import { createTutorialRecord } from '~/stores/tutorial.record'
5
5
 
6
6
  // `TutorialDecision` is deliberately NOT re-exported from here even though it used to live here:
@@ -37,9 +37,9 @@ export const useTutorialStore = defineStore(
37
37
  'tutorial',
38
38
  () => {
39
39
  const record = createTutorialRecord()
40
- // The launch offer's own four-exit state machine (`stores/tutorial.prompt.ts`), which needs to
41
- // know only WHETHER an answer exists.
42
- const prompt = createTutorialPrompt({ hasDecision: () => record.decision.value !== null })
40
+ // The launch offer's own four-exit state machine (`stores/launchPrompt.ts`, shared with the
41
+ // role question), which needs to know only WHETHER an answer exists.
42
+ const prompt = createLaunchPrompt({ hasDecision: () => record.decision.value !== null })
43
43
  const { promptOpen } = prompt
44
44
  /**
45
45
  * The tutorial catalogue (every tour this deployment ships, startable at any time) is
@@ -66,6 +66,17 @@ describe('useUiModeStore mode resolution', () => {
66
66
  expect(ui.mode).toBe('advanced')
67
67
  })
68
68
 
69
+ it('ignores an unrecognised RESTORED value on the same terms as the env one', () => {
70
+ // `storedMode`'s type says what `setMode` writes, not what the persistence plugin
71
+ // rehydrated: an older build's blob (or a hand-edited one) arrives typed as a `UiMode`.
72
+ // Left unparsed it resolves to neither tier, and `isAdvanced` then answers a question
73
+ // about a value that is not a tier at all.
74
+ const ui = useUiModeStore()
75
+ ui.storedMode = 'expert' as UiMode
76
+ expect(ui.mode).toBe('basic')
77
+ expect(ui.isAdvanced).toBe(false)
78
+ })
79
+
69
80
  it('toggleMode flips between the two tiers', () => {
70
81
  const ui = useUiModeStore()
71
82
  ui.toggleMode()
@@ -1,11 +1,13 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
+ import { useUiRoleStore } from '~/stores/uiRole'
3
4
  import { DEFAULT_RAIL_COLLAPSED, parseUiMode, resolveUiMode, type UiMode } from '~/utils/uiMode'
4
5
 
5
6
  /**
6
7
  * The interface tier (`basic` / `advanced`) and the side-navbar collapse state.
7
8
  *
8
- * Mode resolution is `env → browser-stored → basic` (see `utils/uiMode.ts`). The env value
9
+ * Mode resolution is `role surface → env → browser-stored → basic` (see `utils/uiMode.ts`); the
10
+ * role is the ceiling, so an `intake` role renders basic whatever the other two say. The env value
9
11
  * is `runtimeConfig.public.uiMode`, i.e. `NUXT_PUBLIC_UI_MODE`: the SPA is `ssr: false`, so
10
12
  * — exactly like `apiBase` — it is baked in at build time and cannot change while the app
11
13
  * is loaded. It is therefore read ONCE here rather than tracked reactively. Only the user's
@@ -31,7 +33,17 @@ export const useUiModeStore = defineStore(
31
33
  /** The rail state each tier was last left in, persisted. Seeded from the per-tier defaults. */
32
34
  const railCollapsed = ref<Record<UiMode, boolean>>({ ...DEFAULT_RAIL_COLLAPSED })
33
35
 
34
- const mode = computed<UiMode>(() => resolveUiMode(envMode, storedMode.value))
36
+ // The role's surface caps the tier (see `resolveUiMode`): an `intake` role renders basic
37
+ // whatever the env pin or the stored choice says, so every `isAdvanced` reader in the app
38
+ // agrees with the narrowed nav without restating the role.
39
+ const uiRole = useUiRoleStore()
40
+ // The restored value goes through `parseUiMode` for the same reason the env string does, and
41
+ // for the reason `railCollapsed` is read defensively just below: `storedMode`'s type says
42
+ // what `setMode` writes, not what the persistence plugin rehydrated. An unrecognised tier
43
+ // would otherwise pass straight through `resolveUiMode` and render as neither tier.
44
+ const mode = computed<UiMode>(() =>
45
+ resolveUiMode(envMode, parseUiMode(storedMode.value), uiRole.surface),
46
+ )
35
47
  const isAdvanced = computed(() => mode.value === 'advanced')
36
48
  /** Pinned by the deployment: the switcher is read-only, since a write would be ignored. */
37
49
  const envPinned = computed(() => envMode !== null)
@@ -0,0 +1,185 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { missingI18nKeys } from '../../test/i18nKeys'
4
+ import { useUiRoleStore } from '~/stores/uiRole'
5
+ import { useUiModeStore } from '~/stores/uiMode'
6
+ import {
7
+ parseUiRole,
8
+ ROLE_PRESENTATION,
9
+ ROLE_SURFACES,
10
+ UI_ROLES,
11
+ type UiRole,
12
+ } from '~/utils/uiRole'
13
+
14
+ describe('useUiRoleStore role resolution', () => {
15
+ it('boots on the FULL surface with nothing stored, and says nobody has chosen', () => {
16
+ // The two halves of the first-run condition, and they must not be conflated: an unanswered
17
+ // question leaves the whole product in place (`fullSurface`) while still being unanswered
18
+ // (`chosen`), which is what lets the prompt ask again without ever having taken anything away.
19
+ const role = useUiRoleStore()
20
+ expect(role.chosen).toBe(false)
21
+ expect(role.role).toBe('engineer')
22
+ expect(role.fullSurface).toBe(true)
23
+ })
24
+
25
+ it('honours a stored choice and narrows only for the intake role', () => {
26
+ const role = useUiRoleStore()
27
+ role.setRole('product-manager')
28
+ expect(role.role).toBe('product-manager')
29
+ expect(role.chosen).toBe(true)
30
+ // Engineer and product-manager resolve to the SAME surface today; that is the product
31
+ // decision, not an accident of the mapping.
32
+ expect(role.fullSurface).toBe(true)
33
+
34
+ role.setRole('designer')
35
+ expect(role.surface).toBe('intake')
36
+ expect(role.fullSurface).toBe(false)
37
+ // Persisted, so a reload restores it (the persist plugin picks `storedRole`).
38
+ expect(role.storedRole).toBe('designer')
39
+ })
40
+
41
+ it('coerces an unknown persisted value on the way IN, not at the call sites', () => {
42
+ // What the persistence plugin restores is a JSON blob an older build wrote or somebody hand
43
+ // edited, so an unknown string arrives typed as a `UiRole` and nothing downstream is
44
+ // forgiving: `ROLE_SURFACES[role]` is `undefined` (the nav narrows to intake without a
45
+ // word) and `ROLE_PRESENTATION[role].labelKey` throws in the switcher. Writing the raw
46
+ // value onto `storedRole` is exactly what hydration does, which is why this drives the
47
+ // STORE rather than calling `parseUiRole` by hand: a guard nothing calls looks identical to
48
+ // a guard that works.
49
+ const role = useUiRoleStore()
50
+ role.storedRole = 'architect' as UiRole
51
+
52
+ expect(role.role).toBe('engineer')
53
+ expect(role.surface).toBe('full')
54
+ expect(role.fullSurface).toBe(true)
55
+ // An unrecognised value is not an ANSWER, so the first-run prompt asks again and the person
56
+ // can replace it. Reading `storedRole !== null` instead pins the browser to the default with
57
+ // the one question that would fix it already marked settled.
58
+ expect(role.chosen).toBe(false)
59
+ // The resolved role indexes both catalogs, which is the property the switcher relies on.
60
+ expect(ROLE_PRESENTATION[role.role].labelKey).toBeTruthy()
61
+ expect(ROLE_SURFACES[role.role]).toBe('full')
62
+ })
63
+
64
+ it('parses a raw value the way the store consumes it', () => {
65
+ expect(parseUiRole('lead-designer')).toBeNull()
66
+ expect(parseUiRole(undefined)).toBeNull()
67
+ expect(parseUiRole(' Designer ')).toBe('designer')
68
+ })
69
+ })
70
+
71
+ describe('useUiRoleStore first-run prompt', () => {
72
+ it('offers itself once per session while unanswered', () => {
73
+ const role = useUiRoleStore()
74
+ role.maybeOfferOnLaunch()
75
+ expect(role.promptOpen).toBe(true)
76
+
77
+ // Closing without answering writes nothing, so the NEXT launch asks again, but this
78
+ // session's one offer is spent.
79
+ role.closePrompt()
80
+ expect(role.storedRole).toBeNull()
81
+ role.maybeOfferOnLaunch()
82
+ expect(role.promptOpen).toBe(false)
83
+ })
84
+
85
+ it('never offers itself again once a role is recorded', () => {
86
+ const role = useUiRoleStore()
87
+ role.setRole('designer')
88
+ role.maybeOfferOnLaunch()
89
+ expect(role.promptOpen).toBe(false)
90
+ })
91
+
92
+ it('re-arms after a deferral, so a startup advisory does not consume the offer', () => {
93
+ // What `pages/index.vue` does when an advisory the user must answer opens on top: the offer
94
+ // is withdrawn and comes back, rather than counting as a question they were asked.
95
+ const role = useUiRoleStore()
96
+ role.maybeOfferOnLaunch()
97
+ role.deferPrompt()
98
+ expect(role.promptOpen).toBe(false)
99
+ role.maybeOfferOnLaunch()
100
+ expect(role.promptOpen).toBe(true)
101
+ })
102
+
103
+ it('leaves a user-opened prompt alone on a deferral', () => {
104
+ // Opened from the command palette: it is the user's, so nothing withdraws it.
105
+ const role = useUiRoleStore()
106
+ role.openPrompt()
107
+ role.deferPrompt()
108
+ expect(role.promptOpen).toBe(true)
109
+ })
110
+
111
+ it('settles the prompt by picking a role', () => {
112
+ const role = useUiRoleStore()
113
+ role.openPrompt()
114
+ role.setRole('engineer')
115
+ expect(role.promptOpen).toBe(false)
116
+ expect(role.chosen).toBe(true)
117
+ })
118
+ })
119
+
120
+ describe('the role as a ceiling on the interface tier', () => {
121
+ beforeEach(() => {
122
+ setActivePinia(createPinia())
123
+ })
124
+
125
+ it('caps a narrowed role at the basic tier, whatever the user stored', () => {
126
+ // Resolved rather than merely hidden: every `isAdvanced` reader inside a surface (the
127
+ // override fields, the authoring affordances) has to agree with the narrowed nav, and only
128
+ // the resolved mode reaches all of them.
129
+ const mode = useUiModeStore()
130
+ mode.setMode('advanced')
131
+ expect(mode.isAdvanced).toBe(true)
132
+
133
+ useUiRoleStore().setRole('designer')
134
+ expect(mode.mode).toBe('basic')
135
+ expect(mode.isAdvanced).toBe(false)
136
+
137
+ // Leaving the narrowed role restores the tier the person had picked: the cap withholds the
138
+ // tier, it does not overwrite the preference.
139
+ useUiRoleStore().setRole('engineer')
140
+ expect(mode.isAdvanced).toBe(true)
141
+ })
142
+ })
143
+
144
+ describe('UI_ROLES catalog integrity', () => {
145
+ it('maps every role to a surface and presents each one', () => {
146
+ // Derived from the vocabulary rather than re-listed, so a new role fails here (and in the
147
+ // exhaustive Records themselves) until it has picked a surface and gained its copy.
148
+ expect(Object.keys(ROLE_SURFACES).sort()).toEqual([...UI_ROLES].sort())
149
+ expect(Object.keys(ROLE_PRESENTATION).sort()).toEqual([...UI_ROLES].sort())
150
+ // Exactly one narrowed role today, and the full ones are the majority: a mapping that
151
+ // narrowed everything would pass every other assertion in this file.
152
+ const surfaces = UI_ROLES.map((role: UiRole) => ROLE_SURFACES[role])
153
+ expect(surfaces.filter((s) => s === 'full').length).toBeGreaterThan(0)
154
+ expect(surfaces.filter((s) => s === 'intake').length).toBeGreaterThan(0)
155
+ })
156
+
157
+ it('names an i18n key that exists for every role label and hint', () => {
158
+ // A table lookup is invisible to typed message keys and to `i18n:check` (neither sees
159
+ // `t(ROLE_PRESENTATION[role].labelKey)`), so deleting one of these keys would otherwise read
160
+ // as a clean removal and render its own key path in the picker.
161
+ const keys = UI_ROLES.flatMap((role: UiRole) => [
162
+ ROLE_PRESENTATION[role].labelKey,
163
+ ROLE_PRESENTATION[role].hintKey,
164
+ ])
165
+ expect(missingI18nKeys(keys)).toEqual([])
166
+ // And the copy the two surfaces around them use, which is written literally there but is
167
+ // just as easy to rename out from under this table.
168
+ expect(
169
+ missingI18nKeys([
170
+ 'uiRole.switcher',
171
+ 'uiRole.prompt.title',
172
+ 'uiRole.prompt.intro',
173
+ 'uiRole.prompt.change',
174
+ 'uiRole.prompt.later',
175
+ ]),
176
+ ).toEqual([])
177
+ })
178
+
179
+ it('gives every role a distinct glyph', () => {
180
+ // The rail renders the glyph alone above a truncated name, so two roles sharing one would be
181
+ // indistinguishable exactly where the label has the least room.
182
+ const icons = UI_ROLES.map((role: UiRole) => ROLE_PRESENTATION[role].icon)
183
+ expect(new Set(icons).size).toBe(icons.length)
184
+ })
185
+ })
@@ -0,0 +1,86 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import { createLaunchPrompt } from '~/stores/launchPrompt'
4
+ import {
5
+ isFullSurfaceRole,
6
+ parseUiRole,
7
+ resolveUiRole,
8
+ roleSurface,
9
+ type UiRole,
10
+ } from '~/utils/uiRole'
11
+
12
+ /**
13
+ * The role the person is here to do (`engineer` / `product-manager` / `designer`) and the
14
+ * first-run question that asks for it. Resolution and the surface each role maps to are in
15
+ * `utils/uiRole.ts`.
16
+ *
17
+ * Only the person's own choice exists: there is deliberately NO deployment env pin, unlike the
18
+ * interface tier. The tier is a fleet-shaped decision an operator can reasonably make for a
19
+ * kiosk deployment; which JOB the person at the keyboard does is not something the build can
20
+ * know, and pinning it would leave a designer's laptop configured as an engineer's with no way
21
+ * to say otherwise.
22
+ *
23
+ * `chosen` is the whole of the first-run condition: no recognised answer is recorded, which is
24
+ * what the prompt asks about, and it is what a browser that has never been asked, one whose
25
+ * answer was cleared, and one carrying a value that is no longer a role all have in common. The
26
+ * resolved role stays the default throughout, so an unanswered question never takes a
27
+ * destination away.
28
+ */
29
+ export const useUiRoleStore = defineStore(
30
+ 'uiRole',
31
+ () => {
32
+ /** The person's explicit pick, persisted. `null` until they choose one. */
33
+ const storedRole = ref<UiRole | null>(null)
34
+
35
+ /**
36
+ * The persisted pick, COERCED, and the only thing anything below reads.
37
+ *
38
+ * `storedRole`'s type describes what {@link setRole} WRITES, not what boot restores into it:
39
+ * the persistence plugin rehydrates a JSON blob a previous build wrote or a person hand-
40
+ * edited, so an unknown string arrives typed as a `UiRole` and every reader believes it.
41
+ * There is nothing forgiving downstream to catch it: `ROLE_SURFACES[role]` is `undefined`,
42
+ * which narrows the nav to intake without a word, and `ROLE_PRESENTATION[role].labelKey`
43
+ * throws in the switcher, i.e. white-screens the board rather than degrading. So the raw
44
+ * value is parsed ONCE here, exactly as `agentTier` does with its own restored level.
45
+ *
46
+ * `chosen` reads it too, and that is the half that makes the degradation honest rather than
47
+ * merely safe: an unrecognised value is not an answer, so the first-run prompt asks again
48
+ * and the person can replace it. Reading `storedRole !== null` instead would leave a browser
49
+ * pinned to the default with the question it needs to be asked already marked settled.
50
+ */
51
+ const pickedRole = computed<UiRole | null>(() => parseUiRole(storedRole.value))
52
+
53
+ const role = computed<UiRole>(() => resolveUiRole(pickedRole.value))
54
+ const surface = computed(() => roleSurface(role.value))
55
+ /**
56
+ * The role sees the whole product. Read by the nav gate of the same name and by the few
57
+ * surfaces that narrow inline; stated positively so no reader has to invert it.
58
+ */
59
+ const fullSurface = computed(() => isFullSurfaceRole(role.value))
60
+ /** A RECOGNISED answer has been recorded, so the first-run prompt has nothing left to ask. */
61
+ const chosen = computed(() => pickedRole.value !== null)
62
+
63
+ // The same once-per-session launch machine the tutorial offer runs on: the question is
64
+ // answered by PICKING a role, so `hasDecision` is exactly `chosen`. Closing without picking
65
+ // writes nothing and the next launch asks again, which is safe here precisely because the
66
+ // default is the full surface.
67
+ const prompt = createLaunchPrompt({ hasDecision: () => chosen.value })
68
+
69
+ /** Record the person's pick and settle the question. */
70
+ function setRole(next: UiRole) {
71
+ storedRole.value = next
72
+ prompt.promptOpen.value = false
73
+ }
74
+
75
+ return {
76
+ role,
77
+ surface,
78
+ fullSurface,
79
+ chosen,
80
+ storedRole,
81
+ ...prompt,
82
+ setRole,
83
+ }
84
+ },
85
+ { persist: { pick: ['storedRole'] } },
86
+ )
@@ -158,16 +158,11 @@ describe('catalog', () => {
158
158
  for (const a of AGENT_ARCHETYPES) {
159
159
  expect(agentKindMeta(a.kind)).toBe(a)
160
160
  }
161
- // Engine system kinds (present in seeded pipelines but not the palette) resolve
162
- // to their system metadata rather than blowing up an undefined access.
163
- for (const kind of [
164
- 'conflicts',
165
- 'conflict-resolver',
166
- 'ci',
167
- 'ci-fixer',
168
- 'merger',
169
- 'post-release-health',
170
- ]) {
161
+ // Engine system kinds (present in seeded pipelines but not the palette) resolve to their
162
+ // system metadata rather than blowing up an undefined access. Read off the map itself rather
163
+ // than a hand-kept sample: a kind added there is exactly the one nobody thinks to add here,
164
+ // and it renders as a generic "Agent" until somebody notices.
165
+ for (const kind of Object.keys(SYSTEM_AGENT_META)) {
171
166
  expect(agentKindMeta(kind)).toBe(SYSTEM_AGENT_META[kind])
172
167
  expect(agentKindMeta(kind).icon).toEqual(expect.any(String))
173
168
  }
@@ -737,6 +737,18 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
737
737
  color: '#38bdf8',
738
738
  description: 'Fixes failing CI and pushes back to the PR branch.',
739
739
  },
740
+ // The deployer's escalation, and the ci-fixer's shape one step earlier in the pipeline: never a
741
+ // palette block (nobody authors it into a pipeline), but it runs an LLM over the checkout, so it
742
+ // needs display metadata for timelines and a pinnable per-workspace model.
743
+ 'deploy-fixer': {
744
+ kind: 'deploy-fixer',
745
+ tier: 'basic',
746
+ label: 'Deploy Fixer',
747
+ icon: 'i-lucide-server-cog',
748
+ color: '#0ea5e9',
749
+ description:
750
+ 'Repairs the deployment files a failed provision was rejected for and pushes back to the PR branch, then the environment is stood up again.',
751
+ },
740
752
  fixer: {
741
753
  kind: 'fixer',
742
754
  tier: 'basic',
@@ -826,6 +838,7 @@ export const MODEL_CONFIGURABLE_SYSTEM_KINDS: AgentArchetype[] = [
826
838
  'initiative-planner',
827
839
  'conflict-resolver',
828
840
  'ci-fixer',
841
+ 'deploy-fixer',
829
842
  'fixer',
830
843
  'merger',
831
844
  'kaizen',
@@ -29,6 +29,18 @@ describe('resolveUiMode', () => {
29
29
  expect(resolveUiMode(null, null)).toBe(DEFAULT_UI_MODE)
30
30
  expect(DEFAULT_UI_MODE).toBe('basic')
31
31
  })
32
+
33
+ it('caps an intake role at basic, above BOTH the env pin and the stored choice', () => {
34
+ // The role is a ceiling rather than another preference: an `intake` surface is offered the
35
+ // delivery loop and none of the platform configuration behind it, which is what the advanced
36
+ // tier is made of. So it wins over the env pin too, the one layer nothing else overrides.
37
+ expect(resolveUiMode('advanced', 'advanced', 'intake')).toBe('basic')
38
+ expect(resolveUiMode(null, 'advanced', 'intake')).toBe('basic')
39
+ // A full-surface role changes nothing, which is what the default argument encodes for every
40
+ // caller that has no role to hand (the pure-logic callers and the specs above).
41
+ expect(resolveUiMode('advanced', null, 'full')).toBe('advanced')
42
+ expect(resolveUiMode(null, 'advanced', 'full')).toBe('advanced')
43
+ })
32
44
  })
33
45
 
34
46
  describe('showOverrideField', () => {
@@ -1,14 +1,17 @@
1
+ import type { RoleSurface } from '~/utils/uiRole'
2
+
1
3
  /**
2
4
  * The interface tier the SPA renders at: `basic` shows the everyday surface, `advanced`
3
5
  * shows every destination and every run/pipeline option. Pure resolution logic, kept out
4
6
  * of the store so it is testable without Pinia or a Nuxt runtime.
5
7
  *
6
- * Precedence is fixed and NOT negotiable per surface: the deployment's env value always
7
- * wins over the browser-stored user choice, which wins over the `basic` default. That
8
- * ordering is what lets an operator pin a fleet of kiosk-ish deployments to one tier
9
- * without a per-browser reset, so `setMode` is a no-op while the env pin is present
8
+ * Precedence is fixed and NOT negotiable per surface: the ROLE's surface caps the tier, then
9
+ * the deployment's env value wins over the browser-stored user choice, which wins over the
10
+ * `basic` default. That ordering is what lets an operator pin a fleet of kiosk-ish deployments
11
+ * to one tier without a per-browser reset, so `setMode` is a no-op while the env pin is present
10
12
  * rather than writing a preference the resolver would then ignore.
11
13
  */
14
+
12
15
  export const UI_MODES = ['basic', 'advanced'] as const
13
16
 
14
17
  export type UiMode = (typeof UI_MODES)[number]
@@ -37,8 +40,23 @@ export function parseUiMode(raw: unknown): UiMode | null {
37
40
  return (UI_MODES as readonly string[]).includes(value) ? (value as UiMode) : null
38
41
  }
39
42
 
40
- /** Apply the precedence: env pin → browser-stored user choice → {@link DEFAULT_UI_MODE}. */
41
- export function resolveUiMode(env: UiMode | null, stored: UiMode | null): UiMode {
43
+ /**
44
+ * Apply the precedence: the ROLE's surface as a ceiling, then env pin browser-stored user
45
+ * choice → {@link DEFAULT_UI_MODE}.
46
+ *
47
+ * The role (`utils/uiRole.ts`) sits ABOVE the env pin rather than beside it, because it is a
48
+ * ceiling and not a preference: an `intake` role is offered the delivery surface and none of the
49
+ * platform configuration behind it, and the advanced tier's whole content is that configuration.
50
+ * Resolved here rather than by hiding the tier switcher alone, so every `isAdvanced` reader
51
+ * inside a surface (the override fields, the authoring affordances) agrees with the nav
52
+ * without each one restating the role.
53
+ */
54
+ export function resolveUiMode(
55
+ env: UiMode | null,
56
+ stored: UiMode | null,
57
+ surface: RoleSurface = 'full',
58
+ ): UiMode {
59
+ if (surface === 'intake') return 'basic'
42
60
  return env ?? stored ?? DEFAULT_UI_MODE
43
61
  }
44
62