@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/app/pages/index.vue
CHANGED
|
@@ -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
|
|
316
|
-
// first launch never stacks the tour prompt on top of a dialog that
|
|
317
|
-
// first; when one of those is open, the flip of its flag re-fires this
|
|
318
|
-
// prompt appears then. The store guards the rest: only a user who never
|
|
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,
|
|
381
|
+
() => [workspace.ready, tutorialYieldsTo.value, tutorial.promptOpen],
|
|
354
382
|
() => {
|
|
355
|
-
if (
|
|
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
|
+
}
|
package/app/stores/tutorial.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
-
import {
|
|
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/
|
|
41
|
-
// know only WHETHER an answer exists.
|
|
42
|
-
const prompt =
|
|
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()
|
package/app/stores/uiMode.ts
CHANGED
|
@@ -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`)
|
|
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
|
-
|
|
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
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
}
|
package/app/utils/catalog.ts
CHANGED
|
@@ -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',
|
package/app/utils/uiMode.spec.ts
CHANGED
|
@@ -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', () => {
|
package/app/utils/uiMode.ts
CHANGED
|
@@ -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
|
|
7
|
-
* wins over the browser-stored user choice, which wins over the
|
|
8
|
-
* ordering is what lets an operator pin a fleet of kiosk-ish deployments
|
|
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
|
-
/**
|
|
41
|
-
|
|
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
|
|