@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.
- package/README.md +69 -2
- package/app/components/board/nodes/BlockNode.vue +7 -1
- package/app/components/layout/CommandBar.vue +8 -1
- package/app/components/layout/NotificationsInbox.vue +4 -0
- package/app/components/layout/RolePrompt.vue +75 -0
- package/app/components/layout/SideBar.vue +22 -13
- package/app/components/layout/UiRoleSwitcher.vue +73 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/useNavContributions.ts +3 -0
- package/app/docs/consumer-extensions.md +20 -6
- package/app/modular/external-tools.spec.ts +0 -45
- package/app/modular/external-tools.ts +12 -23
- package/app/modular/nav-contributions.spec.ts +176 -17
- package/app/modular/nav-contributions.ts +106 -23
- package/app/modular/nav-gates.ts +11 -2
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -3
- package/app/modular/tutorial-tours.ts +53 -8
- package/app/pages/index.vue +36 -7
- package/app/stores/launchPrompt.ts +63 -0
- package/app/stores/tutorial.ts +4 -4
- package/app/stores/uiMode.spec.ts +11 -0
- package/app/stores/uiMode.ts +14 -2
- package/app/stores/uiRole.spec.ts +185 -0
- package/app/stores/uiRole.ts +86 -0
- package/app/utils/catalog.spec.ts +5 -10
- package/app/utils/catalog.ts +13 -0
- package/app/utils/uiMode.spec.ts +12 -0
- package/app/utils/uiMode.ts +24 -6
- package/app/utils/uiRole.ts +123 -0
- package/i18n/locales/de.json +29 -0
- package/i18n/locales/en.json +35 -0
- package/i18n/locales/es.json +29 -0
- package/i18n/locales/fr.json +29 -0
- package/i18n/locales/he.json +29 -0
- package/i18n/locales/it.json +29 -0
- package/i18n/locales/ja.json +29 -0
- package/i18n/locales/pl.json +29 -0
- package/i18n/locales/tr.json +29 -0
- package/i18n/locales/uk.json +29 -0
- package/package.json +2 -2
- 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
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// how much room they take is the
|
|
12
|
-
//
|
|
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
|
|
205
|
-
shipped default, so this row is most users' only sight of the
|
|
206
|
-
a scrolled navbar it is a thin thread to hang the advanced
|
|
207
|
-
wrapper is what keeps
|
|
208
|
-
otherwise push them apart. Kept OUT of the `onNavAction` group
|
|
209
|
-
|
|
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
|
-
<
|
|
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? }`
|
|
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? }`
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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 `
|
|
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,
|
|
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
|
-
/**
|
|
60
|
-
|
|
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
|
-
}
|