@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
package/README.md CHANGED
@@ -15,6 +15,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
15
15
  - [What it is](#what-it-is)
16
16
  - [Tech stack](#tech-stack)
17
17
  - [Layout](#layout)
18
+ - [Roles (engineer / product manager / designer)](#roles-engineer--product-manager--designer)
18
19
  - [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
19
20
  - [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
20
21
  - [In-app tutorial tours](#in-app-tutorial-tours)
@@ -239,6 +240,70 @@ surface rather than a toast, and are tracked as G4 in
239
240
 
240
241
  A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.
241
242
 
243
+ ## Roles (engineer / product manager / designer)
244
+
245
+ The outermost of the three narrowing axes, and the only one the app **asks about**: on a first-ever
246
+ launch it puts up one question, "what do you work on?", offering `engineer`, `product-manager` and
247
+ `designer` with a line each on what picking it gives you. Vocabulary, resolution and the
248
+ presentation table are in `app/utils/uiRole.ts`; the choice and its once-per-session prompt live in
249
+ the `uiRole` store.
250
+
251
+ Two roles, three names. `engineer` and `product-manager` both map to the **`full`** surface, and
252
+ that is the product decision rather than an oversight: the two do the same job in this app (plan
253
+ work on a board, run it, review and merge it), and what makes the question answerable is the copy a
254
+ person recognises themselves in. `designer` maps to **`intake`**: the services already on the board,
255
+ the work in flight on them, and the routes that bring new work IN (a new task, a task from a tracker
256
+ ticket, a task from a design). None of the platform configuration behind that. They are separate
257
+ `UiRole` members precisely so one of them can gain a surface the other does not without a migration.
258
+
259
+ - **The default is the FULL surface, and an unanswered question changes nothing.** Closing the prompt
260
+ writes no choice, so the next launch asks again and the person keeps the whole product in the
261
+ meantime. There is deliberately no "don't ask me again": an unanswered question costs nothing,
262
+ where a wrongly-recorded one costs somebody destinations they need. It is also why the prompt
263
+ yields to every startup advisory (and why the tour offer, in turn, yields to it: the role decides
264
+ which surfaces exist, so a tour picked ahead of it could be about half a product).
265
+ - **It is not authorization.** Workspace RBAC (ADR 0025) decides what a request may do and is
266
+ enforced server-side; this decides what the SPA OFFERS, and every role's surface is still gated by
267
+ the caller's permissions on top. Nothing here can widen what a person may do, and everything it
268
+ hides is something they may well be allowed to open, which is why the **way back is reachable from
269
+ inside the narrowed role**: the switcher at the top of the sidebar (rendered in every role) plus a
270
+ command-palette entry, both `intake`.
271
+ - **There is NO deployment env pin**, unlike the interface tier. Which tier a fleet of kiosk-ish
272
+ deployments shows is a decision an operator can reasonably make; which JOB the person at the
273
+ keyboard does is not something a build can know.
274
+ - **A narrowed role CAPS the interface tier at basic** (`resolveUiMode` takes the surface and answers
275
+ `basic` for `intake`, ahead of the env pin). Resolved rather than merely hidden, so every
276
+ `isAdvanced` reader inside a surface agrees with the narrowed nav without restating the role, and
277
+ the tier switcher is dropped for that role: a control that flipped a tier the resolver fixes would
278
+ be the same lie it refuses to be under an env pin.
279
+
280
+ The seams, and what a new feature should use rather than reading the store ad hoc:
281
+
282
+ - **A nav destination** declares `intake: true` in `app/modular/nav-contributions.ts` to survive a
283
+ narrowed role. It is **opt-in**, so a destination added later defaults to the full-surface roles:
284
+ getting that wrong costs one flag, where the other default is a persona that stopped being simple
285
+ without anyone deciding to un-simplify it. Today's set is three (`tutorial`,
286
+ `keyboard-shortcuts`, `ui-role`), and `nav-contributions.spec.ts` pins it against a table naming
287
+ each one's reason, so adding a fourth forces the claim to be written down. The `fullSurface` gate
288
+ rides the same reactive `NavGates` service as `advancedMode`, so a role switch re-gates all three
289
+ shells with no reload; all three axes (role, tier, `gate`) must pass.
290
+ - **A deployment's own external tool** declares the same flag, and defaults the same way. The three
291
+ axes live on one `NavGatedContribution` that both `NavContribution` and `ExternalToolContribution`
292
+ extend, and `navSlotFilter` runs the one `navItemVisible` over both slots, because a tool is
293
+ projected onto a nav contribution downstream. A tool filtered by any other expression is a
294
+ registered application that outlives the narrowing every destination beside it obeys, which is how
295
+ the role axis first shipped; `nav-contributions.spec.ts` pins the two slots' verdicts in lockstep
296
+ across the axes rather than trusting the two spellings to stay equal.
297
+ - **A surface that narrows inline** reads `useUiRoleStore().fullSurface` (the frame header's bug-hunt
298
+ button, the palette's per-connection integration commands). Same rule as the tier: what remains
299
+ must be exactly what the full surface would have shown, only less of it.
300
+ - **A tutorial tour whose step clicks a non-`intake` nav entry declares
301
+ `TUTORIAL_REQUIREMENTS.fullSurface`.** Which tours those are is not a judgement call:
302
+ `tutorial-tours.spec.ts` derives the pairing from `navItemVisible`, so a tour that gains such a
303
+ step fails until the requirement is declared. A single STEP that the role removes (the orientation
304
+ tour's interface-tier step) declares `when` instead, so it is dropped rather than reported as an
305
+ abridged tour.
306
+
242
307
  ## Interface modes (basic / advanced)
243
308
 
244
309
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
@@ -247,6 +312,8 @@ options that only exist to override a workspace-level default are left at that d
247
312
  the nav is trimmed to what that loop needs. `advanced` shows everything. The tier resolves in
248
313
  a fixed order, first match wins:
249
314
 
315
+ 0. **The [role](#roles-engineer--product-manager--designer)'s surface**, as a ceiling: an `intake`
316
+ role renders `basic` whatever the two below say.
250
317
  1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`): the deployment pin. Like
251
318
  `NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
252
319
  set the in-app switcher is a read-only indicator, since a preference the resolver ignores
@@ -274,8 +341,8 @@ hoc where it can be avoided:
274
341
 
275
342
  - **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
276
343
  shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
277
- palette, toolbar), independently of its RBAC `gate`: both must pass. A consumer module's
278
- own contributions take the same flag. The bar is **whether the everyday delivery loop needs
344
+ palette, toolbar), independently of its RBAC `gate` and of the role's `intake` flag: all
345
+ three must pass. A consumer module's own contributions take the same flag. The bar is **whether the everyday delivery loop needs
279
346
  it**, and marking an item does one of two distinguishable things:
280
347
  - **Reached another way**; a shortcut whose surface a basic destination also opens, so
281
348
  nothing is lost (the Merge / Service-best-practices palette entries into Workspace
@@ -26,6 +26,7 @@ const services = useServicesStore()
26
26
  const reviews = useReviewStage()
27
27
  const access = useWorkspaceAccess()
28
28
  const uiMode = useUiModeStore()
29
+ const uiRole = useUiRoleStore()
29
30
  const { t } = useI18n()
30
31
  const { lod } = useSemanticZoom()
31
32
  // Coarse-pointer (touch) bumps the frame-header actions from `xs` to `sm` so
@@ -460,8 +461,13 @@ const ITEM_ICON: Record<string, string> = {
460
461
  :title="t('board.frame.createInitiativeTitle')"
461
462
  @click.stop="createInitiative"
462
463
  />
464
+ <!-- Hunting a tracker board for a bug worth adopting is TRIAGE, not intake: it
465
+ rates open bugs against each other and picks one to take on, which is the
466
+ engineer/PM judgement call. So it is dropped for a narrowed role, whose three
467
+ routes in (a new task, a task from a named ticket, a task from a design) all
468
+ start from work somebody has already decided to do. -->
463
469
  <UButton
464
- v-if="tasks.anyOffered"
470
+ v-if="tasks.anyOffered && uiRole.fullSurface"
465
471
  class="nodrag"
466
472
  data-testid="frame-hunt-bugs"
467
473
  :size="isTouch ? 'sm' : 'xs'"
@@ -24,6 +24,7 @@ const documents = useDocumentsStore()
24
24
  const tasks = useTasksStore()
25
25
  const library = useFragmentLibraryStore()
26
26
  const access = useWorkspaceAccess()
27
+ const uiRole = useUiRoleStore()
27
28
 
28
29
  // The static destination catalog + its RBAC/availability gating now comes from
29
30
  // the shared nav manifest (backend/docs/adr/0049-modular-vue-adoption.md, slice 1),
@@ -44,8 +45,14 @@ const activeIndex = ref(0)
44
45
  // carry: their label (connect vs manage) and set (one per document/task source)
45
46
  // depend on live connection state. Gated by `integrations.manage`, they render
46
47
  // under the palette's Integrations group.
48
+ //
49
+ // Also gated on the ROLE's surface, which the manifest entries get for free from `navSlotFilter`
50
+ // (see `NavGates.fullSurface`): these are the platform-configuration half: connecting a source,
51
+ // managing a connection, importing across the whole board. A narrowed role that happens to
52
+ // hold `integrations.manage` would otherwise reach through the palette exactly the surfaces its
53
+ // sidebar dropped. What it keeps is on the board: a frame's own from-ticket / from-design buttons.
47
54
  const dynamicIntegrationCommands = computed<Command[]>(() => {
48
- if (!access.canManageIntegrations.value) return []
55
+ if (!access.canManageIntegrations.value || !uiRole.fullSurface) return []
49
56
  const groupIntegrations = t('layout.commandBar.groups.integrations')
50
57
  const list: Command[] = []
51
58
  if (github.available) {
@@ -32,6 +32,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
32
32
  merge_tag_request: { icon: 'i-lucide-tag', color: 'primary' },
33
33
  ci_failed: { icon: 'i-lucide-triangle-alert', color: 'error' },
34
34
  test_failed: { icon: 'i-lucide-flask-conical', color: 'error' },
35
+ // The deploy-fixer gave up on an environment that would not come up. Same disposition as
36
+ // `ci_failed`: the run failed, and "act" retries it once the files are fixed.
37
+ deploy_blocked: { icon: 'i-lucide-server-off', color: 'error' },
35
38
  // Clicking the title opens the review window for the task (see `reveal`); "act" just marks
36
39
  // it read (the server performs no side-effect for this type).
37
40
  requirement_review: { icon: 'i-lucide-clipboard-list', color: 'primary' },
@@ -97,6 +100,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
97
100
  merge_tag_request: 'layout.notifications.action.merge_tag_request',
98
101
  ci_failed: 'layout.notifications.action.ci_failed',
99
102
  test_failed: 'layout.notifications.action.test_failed',
103
+ deploy_blocked: 'layout.notifications.action.deploy_blocked',
100
104
  requirement_review: 'layout.notifications.action.requirement_review',
101
105
  clarity_review: 'layout.notifications.action.clarity_review',
102
106
  release_regression: 'layout.notifications.action.release_regression',
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ // The first-run role question: asks once what the person is here to do, so the SPA can open on
3
+ // the surfaces that job needs. Offered at launch (at most once per session, and only until it is
4
+ // answered: see `stores/uiRole.ts`) and re-openable at any time from the command palette.
5
+ //
6
+ // It states what each role GIVES you rather than only naming it, because the narrowed role
7
+ // genuinely removes destinations: a person picking blind would either avoid the choice or make it
8
+ // and not know what happened to their sidebar. Same reason the footer names the way back.
9
+ //
10
+ // Closing without picking writes nothing: the role stays the default (the FULL surface), and the
11
+ // next launch asks again. There is deliberately no "don't ask me again": an unanswered question
12
+ // costs nothing here, where a wrongly-recorded answer costs a person destinations they need.
13
+ import { ROLE_PRESENTATION, UI_ROLES, type UiRole } from '~/utils/uiRole'
14
+
15
+ const { t } = useI18n()
16
+ const uiRole = useUiRoleStore()
17
+
18
+ const open = computed({
19
+ get: () => uiRole.promptOpen,
20
+ set: (v: boolean) => (v ? uiRole.openPrompt() : uiRole.closePrompt()),
21
+ })
22
+
23
+ function pick(role: UiRole) {
24
+ uiRole.setRole(role)
25
+ }
26
+ </script>
27
+
28
+ <template>
29
+ <UModal v-model:open="open" :title="t('uiRole.prompt.title')" :ui="{ content: 'max-w-lg' }">
30
+ <template #body>
31
+ <div class="space-y-4" data-testid="role-prompt">
32
+ <p class="text-sm text-slate-300">{{ t('uiRole.prompt.intro') }}</p>
33
+ <div class="space-y-2">
34
+ <button
35
+ v-for="role in UI_ROLES"
36
+ :key="role"
37
+ type="button"
38
+ :data-testid="`role-option-${role}`"
39
+ :aria-pressed="role === uiRole.role && uiRole.chosen"
40
+ class="flex w-full items-center gap-3 rounded-lg border p-3 text-start transition"
41
+ :class="
42
+ role === uiRole.role && uiRole.chosen
43
+ ? 'border-indigo-500/60 bg-indigo-500/10'
44
+ : 'border-slate-800 bg-slate-900/60 hover:border-slate-600 hover:bg-slate-800/60'
45
+ "
46
+ @click="pick(role)"
47
+ >
48
+ <UIcon :name="ROLE_PRESENTATION[role].icon" class="h-5 w-5 shrink-0 text-primary-400" />
49
+ <div class="min-w-0 flex-1">
50
+ <div class="text-sm font-medium text-slate-100">
51
+ {{ t(ROLE_PRESENTATION[role].labelKey) }}
52
+ </div>
53
+ <p class="text-xs text-slate-400">{{ t(ROLE_PRESENTATION[role].hintKey) }}</p>
54
+ </div>
55
+ </button>
56
+ </div>
57
+ <!-- The choice is not a commitment, and saying so is what makes the narrowed role
58
+ pickable: it is one dropdown at the top of the sidebar to leave again. -->
59
+ <p class="text-[11px] leading-snug text-slate-500">{{ t('uiRole.prompt.change') }}</p>
60
+ </div>
61
+ </template>
62
+ <template #footer>
63
+ <div class="flex w-full justify-end">
64
+ <UButton
65
+ color="neutral"
66
+ variant="soft"
67
+ data-testid="role-prompt-close"
68
+ @click="uiRole.closePrompt()"
69
+ >
70
+ {{ uiRole.chosen ? t('common.close') : t('uiRole.prompt.later') }}
71
+ </UButton>
72
+ </div>
73
+ </template>
74
+ </UModal>
75
+ </template>
@@ -6,15 +6,16 @@
6
6
  // context-fragment library, and workspace configuration (merge thresholds +
7
7
  // default models).
8
8
  //
9
- // Two orthogonal ways this panel shrinks. WHICH destinations exist is the interface
10
- // TIER (basic hides the `advanced` contributions, filtered upstream in `navSlotFilter`);
11
- // how much room they take is the COLLAPSE state (the icon-only rail). Basic mode starts
12
- // railed, but either can be changed independently from the tier switcher at the top /
13
- // the rail toggle.
9
+ // Three orthogonal ways this panel shrinks. WHICH destinations exist is the person's ROLE (a
10
+ // narrowed one keeps only the `intake` contributions) and then the interface TIER (basic hides the
11
+ // `advanced` ones), both filtered upstream in `navSlotFilter`; how much room they take is the
12
+ // COLLAPSE state (the icon-only rail). Basic mode starts railed, but each can be changed
13
+ // independently from the role / tier switchers at the top and the rail toggle.
14
14
  import { useEventListener, useScrollLock } from '@vueuse/core'
15
15
  import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
16
16
  import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
17
17
  import UiModeSwitcher from '~/components/layout/UiModeSwitcher.vue'
18
+ import UiRoleSwitcher from '~/components/layout/UiRoleSwitcher.vue'
18
19
  import UserMenu from '~/components/auth/UserMenu.vue'
19
20
  import { useViewport } from '~/composables/useViewport'
20
21
  import type { NavContribution } from '~/modular/nav-contributions'
@@ -67,6 +68,7 @@ const { isCompact } = useViewport()
67
68
  // it only to find a rail would be two taps for one destination. The tier decides the default
68
69
  // (basic starts collapsed), the user's toggle wins from there — see `stores/uiMode.ts`.
69
70
  const uiMode = useUiModeStore()
71
+ const uiRole = useUiRoleStore()
70
72
  const railed = computed(() => !isCompact.value && uiMode.navCollapsed)
71
73
 
72
74
  // The off-canvas drawer is a modal surface on compact viewports, so give it the
@@ -201,15 +203,22 @@ watch(
201
203
 
202
204
  <BoardSwitcher :collapsed="railed" />
203
205
 
204
- <!-- The interface tier sits ABOVE the destinations it gates, not in the footer: basic is the
205
- shipped default, so this row is most users' only sight of the tier, and below the fold in
206
- a scrolled navbar it is a thin thread to hang the advanced half of the product on. The
207
- wrapper is what keeps the control and its hint together the aside's own `gap-4` would
208
- otherwise push them apart. Kept OUT of the `onNavAction` group deliberately: switching
209
- tiers opens nothing, and closing the compact drawer would hide the destinations the
210
- switch just revealed. -->
206
+ <!-- The two "how much of the app do I see" controls sit ABOVE the destinations they gate, not
207
+ in the footer: basic is the shipped default, so this row is most users' only sight of the
208
+ tier, and below the fold in a scrolled navbar it is a thin thread to hang the advanced
209
+ half of the product on. The wrapper is what keeps each control with its hint, since the aside's
210
+ own `gap-4` would otherwise push them apart. Kept OUT of the `onNavAction` group
211
+ deliberately: switching tier or role opens nothing, and closing the compact drawer would
212
+ hide the destinations the switch just revealed.
213
+
214
+ The ROLE comes first because it is the outer of the two: it caps the tier (see
215
+ `resolveUiMode`), which is also why the tier switcher is dropped on a narrowed role:
216
+ with the tier fixed at basic, a control that flipped it would be advertising a choice the
217
+ resolver ignores, exactly as it refuses to be under an env pin. The role switcher itself
218
+ is rendered in EVERY role: it is the way back. -->
211
219
  <div class="space-y-1">
212
- <UiModeSwitcher :collapsed="railed" />
220
+ <UiRoleSwitcher :collapsed="railed" />
221
+ <UiModeSwitcher v-if="uiRole.fullSurface" :collapsed="railed" />
213
222
  </div>
214
223
 
215
224
  <div class="contents" @click="onNavAction">
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { ROLE_PRESENTATION, UI_ROLES } from '~/utils/uiRole'
4
+
5
+ // Role picker, at the TOP of the sidebar beside the interface-tier switcher: one place answers
6
+ // "how much of the app do I see", and the role is the outer of the two (it can cap the tier, see
7
+ // `resolveUiMode`), so it sits above it.
8
+ //
9
+ // A DROPDOWN rather than the tier switcher's segmented control, and the difference is not
10
+ // cosmetic: three role names do not fit legibly across a 15rem sidebar, and the choice has already
11
+ // been made explicitly once (the first-run prompt states what each role gives you), so this
12
+ // control's job is to NAME the current role and offer the way out, not to advertise that a choice
13
+ // exists. It is rendered in EVERY role, including the narrowed one, because it is the way back.
14
+ //
15
+ // In the collapsed rail it keeps the menu and drops to the glyph plus the role's name, so the rail
16
+ // still says which role is on (the same reason the tier button keeps its label there).
17
+ withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
18
+
19
+ const uiRole = useUiRoleStore()
20
+ const { t } = useI18n()
21
+
22
+ // Name / glyph / one-line description all come from the shared presentation table, so this
23
+ // control and the first-run prompt can never describe the same role differently.
24
+ const current = computed(() => ROLE_PRESENTATION[uiRole.role])
25
+ const currentLabel = computed(() => t(current.value.labelKey))
26
+
27
+ const items = computed(() =>
28
+ UI_ROLES.map((role) => ({
29
+ label: t(ROLE_PRESENTATION[role].labelKey),
30
+ icon: ROLE_PRESENTATION[role].icon,
31
+ // The tick, so an open menu says which role is current as well as which are available.
32
+ trailingIcon: role === uiRole.role ? 'i-lucide-check' : undefined,
33
+ onSelect: () => uiRole.setRole(role),
34
+ })),
35
+ )
36
+ </script>
37
+
38
+ <template>
39
+ <UDropdownMenu :items="items" :ui="{ content: 'min-w-48' }">
40
+ <!-- Rail: glyph over the role name, matching the tier button beside it. -->
41
+ <button
42
+ v-if="collapsed"
43
+ type="button"
44
+ data-testid="ui-role-toggle"
45
+ :aria-label="`${t('uiRole.switcher')}: ${currentLabel}`"
46
+ :title="`${t('uiRole.switcher')}: ${currentLabel}`"
47
+ class="flex w-full flex-col items-center gap-0.5 rounded-lg border border-slate-700 bg-slate-900/60 px-1 py-1.5 transition hover:border-indigo-500/60 hover:bg-slate-800/60"
48
+ >
49
+ <UIcon :name="current.icon" class="h-4 w-4 shrink-0 text-indigo-400" />
50
+ <span class="w-full truncate text-center text-[9px] font-medium uppercase text-slate-300">
51
+ {{ currentLabel }}
52
+ </span>
53
+ </button>
54
+
55
+ <button
56
+ v-else
57
+ type="button"
58
+ data-testid="ui-role-switcher"
59
+ :aria-label="t('uiRole.switcher')"
60
+ :title="t(current.hintKey)"
61
+ class="flex w-full items-center gap-2 rounded-lg border border-slate-700 bg-slate-900/60 p-2 text-start transition hover:border-indigo-500/60 hover:bg-slate-800/60"
62
+ >
63
+ <UIcon :name="current.icon" class="h-4 w-4 shrink-0 text-indigo-400" />
64
+ <div class="min-w-0 flex-1">
65
+ <div class="truncate text-[10px] uppercase tracking-wide text-slate-500">
66
+ {{ t('uiRole.switcher') }}
67
+ </div>
68
+ <div class="truncate text-xs font-medium text-slate-200">{{ currentLabel }}</div>
69
+ </div>
70
+ <UIcon name="i-lucide-chevron-down" class="h-3.5 w-3.5 shrink-0 text-slate-500" />
71
+ </button>
72
+ </UDropdownMenu>
73
+ </template>
@@ -36,6 +36,7 @@ const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
36
36
  { type: 'merge_tag_request', label: t('slack.routable.merge_tag_request') },
37
37
  { type: 'ci_failed', label: t('slack.routable.ci_failed') },
38
38
  { type: 'test_failed', label: t('slack.routable.test_failed') },
39
+ { type: 'deploy_blocked', label: t('slack.routable.deploy_blocked') },
39
40
  { type: 'requirement_review', label: t('slack.routable.requirement_review') },
40
41
  { type: 'clarity_review', label: t('slack.routable.clarity_review') },
41
42
  { type: 'release_regression', label: t('slack.routable.release_regression') },
@@ -60,6 +61,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
60
61
  merge_tag_request: { enabled: false, channel: '' },
61
62
  ci_failed: { enabled: false, channel: '' },
62
63
  test_failed: { enabled: false, channel: '' },
64
+ deploy_blocked: { enabled: false, channel: '' },
63
65
  requirement_review: { enabled: false, channel: '' },
64
66
  clarity_review: { enabled: false, channel: '' },
65
67
  release_regression: { enabled: false, channel: '' },
@@ -66,6 +66,9 @@ export function useNavContributions() {
66
66
  // No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
67
67
  // switcher's read-only state rather than pretending to flip a tier the resolver fixes.
68
68
  toggleUiMode: () => useUiModeStore().toggleMode(),
69
+ // The QUESTION, not a toggle: with three roles there is no unambiguous "next one", and the
70
+ // prompt is the one surface that states what each role gives you before you pick it.
71
+ chooseRole: () => useUiRoleStore().openPrompt(),
69
72
  }
70
73
 
71
74
  /** Run a contribution's action (consumer `run` closure wins over the id map). */
@@ -61,18 +61,29 @@ export default defineNuxtPlugin(() => {
61
61
  | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
62
62
  | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
63
63
  | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, defaultFragmentIds?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
64
- | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
64
+ | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, intake?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
65
65
  | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
66
66
  | Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
67
- | External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
67
+ | External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, intake?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
68
68
  | Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }` | the Metadata tab of Workspace settings |
69
69
  | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
70
70
  | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
71
71
 
72
- A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
73
- (the shipped default) exactly as it does for the first-party destinations: see
72
+ Beyond `gate`, a destination answers to two narrowing axes, both applied to your entries exactly
73
+ as they are to the first-party ones. All three are independent, and all three must pass.
74
+
75
+ `advanced: true` hides an entry in **basic** interface mode, the shipped default: see
74
76
  [the layer README](../../README.md#interface-modes-basic--advanced). Use it for a power-user
75
- destination; the flag is independent of `gate`, so both must pass for the item to render.
77
+ destination.
78
+
79
+ `intake: true` keeps an entry for a role narrowed to the intake surface, today `designer`: see
80
+ [Roles](../../README.md#roles-engineer--product-manager--designer). This one is opt-IN, so an
81
+ entry that says nothing is offered to the full-surface roles only. Declare it where your
82
+ destination is somewhere work comes IN from rather than somewhere the platform is configured; a
83
+ design-handoff console qualifies, an admin panel does not.
84
+
85
+ Both flags live on `NavGatedContribution`, which an external tool extends too, so a tool answers
86
+ the same axes as a `nav` entry and there is one predicate (`navItemVisible`) deciding both.
76
87
 
77
88
  ### Run-detail windows (`resultViews` + `agentKinds`)
78
89
 
@@ -153,7 +164,10 @@ workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork'
153
164
  reason (`resolver-failed`) with the cause logged to the console: the sidebar, the palette and
154
165
  the toolbar all render from one catalog, so an uncaught throw would otherwise blank all three.
155
166
  Do not rely on it: `requiredMetadata` is how you say a field must be there.
156
- - **`gate` and `advanced`** work exactly as on a `nav` entry; both must pass.
167
+ - **`gate`, `advanced` and `intake`** work exactly as on a `nav` entry, and for the same reason:
168
+ a tool is projected onto a nav contribution and filtered by the same predicate. All must pass,
169
+ and `intake` defaults the same way, so a registered application is dropped for a narrowed role
170
+ until you say it belongs there.
157
171
 
158
172
  **The metadata half** is a deployment-declared FIELD list (here) whose VALUES are per workspace,
159
173
  typed in under _Workspace settings → Metadata_ and persisted on the workspace settings row. The
@@ -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
- }