@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
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
SIDEBAR_GROUP_ORDER,
|
|
11
11
|
sortToolbar,
|
|
12
12
|
} from './nav-contributions'
|
|
13
|
-
import type { AppSlots, NavGates } from './nav-contributions'
|
|
13
|
+
import type { AppSlots, NavGatedContribution, NavGates } from './nav-contributions'
|
|
14
|
+
import type { ExternalToolContribution } from './external-tools'
|
|
14
15
|
|
|
15
16
|
/** Prove every referenced key resolves in the layer's base catalog (see `test/i18nKeys`). */
|
|
16
17
|
const hasKey = hasI18nKey
|
|
@@ -25,9 +26,11 @@ const NO_GATES: NavGates = {
|
|
|
25
26
|
infrastructureAvailable: false,
|
|
26
27
|
accountsEnabled: false,
|
|
27
28
|
isAccountAdmin: false,
|
|
28
|
-
// The permission axis is what these cases vary; keep the interface tier at `advanced`
|
|
29
|
-
// so a dropped item is unambiguously an RBAC/availability drop
|
|
29
|
+
// The permission axis is what these cases vary; keep the interface tier at `advanced` and the
|
|
30
|
+
// role at the full surface, so a dropped item is unambiguously an RBAC/availability drop rather
|
|
31
|
+
// than a tier or role drop.
|
|
30
32
|
advancedMode: true,
|
|
33
|
+
fullSurface: true,
|
|
31
34
|
boardHasService: false,
|
|
32
35
|
boardHasTask: false,
|
|
33
36
|
boardHasRun: false,
|
|
@@ -48,6 +51,7 @@ const ALL_GATES: NavGates = {
|
|
|
48
51
|
accountsEnabled: true,
|
|
49
52
|
isAccountAdmin: true,
|
|
50
53
|
advancedMode: true,
|
|
54
|
+
fullSurface: true,
|
|
51
55
|
boardHasService: true,
|
|
52
56
|
boardHasTask: true,
|
|
53
57
|
boardHasRun: true,
|
|
@@ -185,6 +189,76 @@ describe('navSlotFilter', () => {
|
|
|
185
189
|
}
|
|
186
190
|
})
|
|
187
191
|
|
|
192
|
+
it('drops every non-intake destination for a narrowed role', () => {
|
|
193
|
+
const gates: NavGates = { ...ALL_GATES, fullSurface: false }
|
|
194
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
195
|
+
const intakeOnly = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
|
|
196
|
+
expect(kept.sort()).toEqual(intakeOnly.sort())
|
|
197
|
+
// Fully permitted and on the advanced tier, so every drop here is the ROLE and nothing else:
|
|
198
|
+
// the platform configuration goes, and what teaches the product plus the way out stays.
|
|
199
|
+
expect(kept).toContain('tutorial')
|
|
200
|
+
expect(kept).toContain('ui-role')
|
|
201
|
+
expect(kept).not.toContain('build-pipeline')
|
|
202
|
+
expect(kept).not.toContain('add-from-repo')
|
|
203
|
+
expect(kept).not.toContain('integrations-hub')
|
|
204
|
+
expect(kept).not.toContain('workspace-settings')
|
|
205
|
+
expect(kept).not.toContain('model-providers')
|
|
206
|
+
expect(kept).not.toContain('account-settings')
|
|
207
|
+
// A narrowed sidebar is short by design, but never a shell with nothing in it: the sidebar
|
|
208
|
+
// is where the tutorials live, and `groupSidebar` drops an empty section upstream, so a
|
|
209
|
+
// surface whose every item was palette-only would render as a broken navbar.
|
|
210
|
+
const groups = groupSidebar((navSlotFilter(slots(), { gates }) as AppSlots).nav)
|
|
211
|
+
expect(groups.length).toBeGreaterThan(0)
|
|
212
|
+
for (const group of groups) expect(group.items.length).toBeGreaterThan(0)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('states, per intake item, why the narrowed role keeps it', () => {
|
|
216
|
+
// The mirror of the advanced-item table above, and it earns its place for the opposite
|
|
217
|
+
// reason: `intake` is opt-IN, so the risk is not a silent capability loss but a silent
|
|
218
|
+
// WIDENING: one flag on a destination that configures the platform and the simplified
|
|
219
|
+
// surface has quietly stopped being simple. The table is the claim; adding `intake: true`
|
|
220
|
+
// fails here until the reason is written down.
|
|
221
|
+
const REASON: Record<string, string> = {
|
|
222
|
+
tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
|
|
223
|
+
'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
|
|
224
|
+
'ui-role': 'the way BACK out of the narrowed role',
|
|
225
|
+
}
|
|
226
|
+
const intake = NAV_CONTRIBUTIONS.filter((i) => i.intake).map((i) => i.id)
|
|
227
|
+
expect(intake.sort()).toEqual(Object.keys(REASON).sort())
|
|
228
|
+
for (const [id, why] of Object.entries(REASON)) {
|
|
229
|
+
expect(why.length, `${id} has no stated reason`).toBeGreaterThan(0)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('keeps the role switch reachable from inside the narrowed role, and the tier switch out', () => {
|
|
234
|
+
// The role decides which surfaces exist at all, so its own entry must survive the narrowing
|
|
235
|
+
// it causes, or a designer who needs a pipeline has no route to say so.
|
|
236
|
+
const roleItem = NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-role')
|
|
237
|
+
expect(roleItem?.intake).toBe(true)
|
|
238
|
+
expect(roleItem?.gate).toBeUndefined()
|
|
239
|
+
// The TIER toggle is deliberately the other way: a narrowed role's tier is capped at basic
|
|
240
|
+
// (`resolveUiMode`), so an entry that flipped it would write a preference nothing honours.
|
|
241
|
+
expect(NAV_CONTRIBUTIONS.find((i) => i.id === 'ui-mode')?.intake).toBeUndefined()
|
|
242
|
+
|
|
243
|
+
const gates: NavGates = { ...NO_GATES, fullSurface: false }
|
|
244
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
245
|
+
expect(kept).toContain('ui-role')
|
|
246
|
+
expect(kept).not.toContain('ui-mode')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('keeps the tier, role and permission axes independent, and all three must pass', () => {
|
|
250
|
+
// `tutorial` is `intake` and ungated, `sandbox` is advanced + permissioned + not intake:
|
|
251
|
+
// no single axis reveals the second, and narrowing the role cannot reveal anything.
|
|
252
|
+
const narrowedButPermitted: NavGates = { ...ALL_GATES, fullSurface: false }
|
|
253
|
+
expect(ids(navSlotFilter(slots(), { gates: narrowedButPermitted }))).not.toContain('sandbox')
|
|
254
|
+
|
|
255
|
+
const fullButBasic: NavGates = { ...ALL_GATES, advancedMode: false }
|
|
256
|
+
expect(ids(navSlotFilter(slots(), { gates: fullButBasic }))).not.toContain('sandbox')
|
|
257
|
+
|
|
258
|
+
const fullButUnpermitted: NavGates = { ...NO_GATES, canManageIntegrations: false }
|
|
259
|
+
expect(ids(navSlotFilter(slots(), { gates: fullButUnpermitted }))).not.toContain('sandbox')
|
|
260
|
+
})
|
|
261
|
+
|
|
188
262
|
it('keeps the tier switch itself reachable in basic mode', () => {
|
|
189
263
|
// Basic is the shipped default, so this palette entry is how a user who never finds the
|
|
190
264
|
// (icon-only, in the basic rail) sidebar switcher gets to the advanced half at all. Marking
|
|
@@ -226,26 +300,107 @@ describe('navSlotFilter', () => {
|
|
|
226
300
|
})
|
|
227
301
|
|
|
228
302
|
describe('navSlotFilter external tools', () => {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
303
|
+
const tool = (id: string, axes: NavGatedContribution = {}): ExternalToolContribution => ({
|
|
304
|
+
id,
|
|
305
|
+
title: id,
|
|
306
|
+
icon: 'i-lucide-link',
|
|
307
|
+
url: `https://${id}.dev`,
|
|
308
|
+
...axes,
|
|
309
|
+
})
|
|
310
|
+
const tools: ExternalToolContribution[] = [
|
|
311
|
+
tool('acme:open'),
|
|
312
|
+
tool('acme:permissioned', { gate: (g: NavGates) => g.canManageIntegrations }),
|
|
313
|
+
tool('acme:power-user', { advanced: true }),
|
|
314
|
+
tool('acme:intake', { intake: true }),
|
|
238
315
|
]
|
|
316
|
+
const withTools = (): AppSlots => ({ ...slots(), externalTools: [...tools] })
|
|
239
317
|
const toolIds = (s: unknown) => (s as AppSlots).externalTools.map((t) => t.id)
|
|
240
318
|
|
|
241
319
|
it('gates registered tools in the SAME filter as the nav catalog', () => {
|
|
242
320
|
// They become nav items downstream (`useNavContributions` projects them), so gating them
|
|
243
321
|
// anywhere else would let a tool the caller can't use reach the palette while its sidebar
|
|
244
322
|
// twin was correctly hidden.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
expect(toolIds(navSlotFilter(withTools(), {}))).toEqual([
|
|
323
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: ALL_GATES }))).toEqual(
|
|
324
|
+
tools.map((t) => t.id),
|
|
325
|
+
)
|
|
326
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: NO_GATES }))).toEqual([
|
|
327
|
+
'acme:open',
|
|
328
|
+
'acme:power-user',
|
|
329
|
+
'acme:intake',
|
|
330
|
+
])
|
|
331
|
+
// No gates service wired (tests, a bare install): everything passes, matching the dev-open
|
|
332
|
+
// "absent access allows all" parity the nav half keeps.
|
|
333
|
+
expect(toolIds(navSlotFilter(withTools(), {}))).toEqual(tools.map((t) => t.id))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('drops a tool that has not opted into the intake surface', () => {
|
|
337
|
+
// The axis a separate external-tools filter used to miss entirely: a `designer` kept the
|
|
338
|
+
// deployment's whole External tools section while every first-party destination around it
|
|
339
|
+
// was hidden, which inverts the opt-in default rather than merely leaking one entry.
|
|
340
|
+
const narrowed: NavGates = { ...ALL_GATES, fullSurface: false }
|
|
341
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: narrowed }))).toEqual(['acme:intake'])
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('keeps a tool and a nav entry declaring the same axes in lockstep', () => {
|
|
345
|
+
// The structural half, and the one a per-slot case cannot make: both slots run the same
|
|
346
|
+
// predicate today, so assert the PROPERTY that makes that worth keeping. A future axis
|
|
347
|
+
// wired into one slot and not the other fails here, whatever the axis turns out to be.
|
|
348
|
+
const AXES: NavGatedContribution[] = [
|
|
349
|
+
{},
|
|
350
|
+
{ advanced: true },
|
|
351
|
+
{ intake: true },
|
|
352
|
+
{ gate: (g: NavGates) => g.canManageIntegrations },
|
|
353
|
+
{ advanced: true, intake: true },
|
|
354
|
+
{ advanced: true, gate: (g: NavGates) => g.canManageIntegrations },
|
|
355
|
+
{ intake: true, gate: (g: NavGates) => g.canManageIntegrations },
|
|
356
|
+
{ advanced: true, intake: true, gate: (g: NavGates) => g.canManageIntegrations },
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
for (const [index, axes] of AXES.entries()) {
|
|
360
|
+
const id = `twin-${index}`
|
|
361
|
+
const paired = (gates: NavGates) => {
|
|
362
|
+
const filtered = navSlotFilter(
|
|
363
|
+
{
|
|
364
|
+
...slots(),
|
|
365
|
+
nav: [
|
|
366
|
+
{
|
|
367
|
+
id,
|
|
368
|
+
labelKey: 'nav.kaizen',
|
|
369
|
+
icon: 'i-lucide-link',
|
|
370
|
+
surfaces: ['command'] as const,
|
|
371
|
+
...axes,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
externalTools: [tool(id, axes)],
|
|
375
|
+
},
|
|
376
|
+
{ gates },
|
|
377
|
+
) as AppSlots
|
|
378
|
+
return {
|
|
379
|
+
nav: filtered.nav.some((i) => i.id === id),
|
|
380
|
+
tool: filtered.externalTools.some((t) => t.id === id),
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Every setting of the three gate fields these axes read; the rest of `NavGates` is
|
|
385
|
+
// invariant here, so varying it would only restate the same eight verdicts.
|
|
386
|
+
for (const advancedMode of [true, false]) {
|
|
387
|
+
for (const fullSurface of [true, false]) {
|
|
388
|
+
for (const canManageIntegrations of [true, false]) {
|
|
389
|
+
const gates: NavGates = {
|
|
390
|
+
...ALL_GATES,
|
|
391
|
+
advancedMode,
|
|
392
|
+
fullSurface,
|
|
393
|
+
canManageIntegrations,
|
|
394
|
+
}
|
|
395
|
+
const verdict = paired(gates)
|
|
396
|
+
expect(
|
|
397
|
+
verdict.tool,
|
|
398
|
+
`${JSON.stringify(axes)} under ${JSON.stringify({ advancedMode, fullSurface, canManageIntegrations })}`,
|
|
399
|
+
).toBe(verdict.nav)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
249
404
|
})
|
|
250
405
|
})
|
|
251
406
|
|
|
@@ -348,7 +503,10 @@ describe('nav grouping helpers', () => {
|
|
|
348
503
|
it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
|
|
349
504
|
const workspace = groupCommands(NAV_CONTRIBUTIONS).find((g) => g.group === 'workspace')
|
|
350
505
|
// Same order the old CommandBar pushed them in (parity, not a reorder), with genuinely
|
|
351
|
-
// new entries appended after it rather than interleaved.
|
|
506
|
+
// new entries appended after it rather than interleaved. `ui-role` is the one deliberate
|
|
507
|
+
// exception, and it changes no existing entry's RELATIVE position: it sits beside `ui-mode`
|
|
508
|
+
// because the two answer one question ("how much of the app do I see"), and a user who finds
|
|
509
|
+
// one has found the other.
|
|
352
510
|
expect(workspace?.items.map((ci) => ci.item.id)).toEqual([
|
|
353
511
|
'fragments',
|
|
354
512
|
'merge-thresholds',
|
|
@@ -359,6 +517,7 @@ describe('nav grouping helpers', () => {
|
|
|
359
517
|
'sandbox',
|
|
360
518
|
'keyboard-shortcuts',
|
|
361
519
|
'ui-mode',
|
|
520
|
+
'ui-role',
|
|
362
521
|
'tutorial',
|
|
363
522
|
'foundational-services',
|
|
364
523
|
])
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { defineModule } from '@modular-vue/core'
|
|
2
|
-
import { filterExternalTools } from './external-tools'
|
|
3
2
|
import type { AppSlots } from './slots'
|
|
4
3
|
|
|
5
4
|
// Re-exported for the slice-1 importers that reach `AppSlots` through this
|
|
@@ -111,6 +110,18 @@ export interface NavGates {
|
|
|
111
110
|
* available to a `gate` predicate that needs to combine it with something else.
|
|
112
111
|
*/
|
|
113
112
|
advancedMode: boolean
|
|
113
|
+
/**
|
|
114
|
+
* The person's ROLE sees the whole product (`stores/uiRole.ts`): true for `engineer` and
|
|
115
|
+
* `product-manager`, false for the `intake` surface a `designer` gets.
|
|
116
|
+
*
|
|
117
|
+
* Read by {@link navSlotFilter} for every item that does NOT declare
|
|
118
|
+
* {@link NavContribution.intake}, i.e. the narrowing is opt-IN for the small surface rather
|
|
119
|
+
* than opt-out for the large one. Stated positively (a role that sees everything, not a
|
|
120
|
+
* "restricted" flag) for the same reason `advancedMode` is: this is the third axis a
|
|
121
|
+
* contribution answers to, and one of the three reading inverted is a destination shown to
|
|
122
|
+
* exactly the person it was hidden from.
|
|
123
|
+
*/
|
|
124
|
+
fullSurface: boolean
|
|
114
125
|
/**
|
|
115
126
|
* The open board has at least one service frame. Availability, not permission: a
|
|
116
127
|
* surface that operates ON a service (today the task-creation tutorial tour) has
|
|
@@ -206,12 +217,50 @@ export const NAV_ACTIONS = [
|
|
|
206
217
|
'shortcuts',
|
|
207
218
|
'tutorial',
|
|
208
219
|
'toggleUiMode',
|
|
220
|
+
'chooseRole',
|
|
209
221
|
] as const
|
|
210
222
|
|
|
211
223
|
export type NavActionId = (typeof NAV_ACTIONS)[number]
|
|
212
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The axes a rendered destination answers to, and the whole of what {@link navItemVisible}
|
|
227
|
+
* reads. Extended by {@link NavContribution} and by an external tool
|
|
228
|
+
* (`modular/external-tools.ts`), which is projected onto a nav item downstream.
|
|
229
|
+
*
|
|
230
|
+
* Shared as an interface rather than restated per contribution kind, so the two cannot declare
|
|
231
|
+
* different axes. That is not hypothetical: the role axis first landed on `NavContribution`
|
|
232
|
+
* alone, and external tools went on rendering for a role every first-party destination was
|
|
233
|
+
* hidden from. A fourth axis added here is one a tool must answer too, and the typecheck plus
|
|
234
|
+
* the single predicate are what say so.
|
|
235
|
+
*/
|
|
236
|
+
export interface NavGatedContribution {
|
|
237
|
+
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
238
|
+
gate?: (g: NavGates) => boolean
|
|
239
|
+
/**
|
|
240
|
+
* A power-user destination: shown only in ADVANCED interface mode (basic mode is the
|
|
241
|
+
* everyday surface — see `stores/uiMode.ts`). Declarative rather than folded into
|
|
242
|
+
* {@link gate}, so an item keeps its RBAC/availability predicate unchanged and the two
|
|
243
|
+
* axes stay independently assertable. Absent = visible in both tiers.
|
|
244
|
+
*/
|
|
245
|
+
advanced?: boolean
|
|
246
|
+
/**
|
|
247
|
+
* Offered on the INTAKE surface: the narrowed set a `designer` role gets (see
|
|
248
|
+
* `utils/uiRole.ts`). Absent = the full-surface roles only.
|
|
249
|
+
*
|
|
250
|
+
* Opt-in, and that direction is the point: a destination added later defaults to the roles
|
|
251
|
+
* that configure the platform, so a new entry can never quietly widen the simplified surface:
|
|
252
|
+
* the cost of getting it wrong is one flag, where the other default is a persona that stopped
|
|
253
|
+
* being simple without anyone deciding to un-simplify it. Independent of {@link advanced} and
|
|
254
|
+
* of {@link gate}: all three must pass.
|
|
255
|
+
*
|
|
256
|
+
* A deployment's own external tool declares it the same way, and defaults the same way: a
|
|
257
|
+
* registered application is platform surface until somebody says it is intake surface.
|
|
258
|
+
*/
|
|
259
|
+
intake?: boolean
|
|
260
|
+
}
|
|
261
|
+
|
|
213
262
|
/** One destination, declared once and rendered per surface. */
|
|
214
|
-
export interface NavContribution {
|
|
263
|
+
export interface NavContribution extends NavGatedContribution {
|
|
215
264
|
id: string
|
|
216
265
|
/** Default (sidebar) label i18n key. */
|
|
217
266
|
labelKey: string
|
|
@@ -225,15 +274,6 @@ export interface NavContribution {
|
|
|
225
274
|
description?: string
|
|
226
275
|
icon: string
|
|
227
276
|
surfaces: readonly NavSurface[]
|
|
228
|
-
/** Reactive predicate over {@link NavGates}; absent = always visible. */
|
|
229
|
-
gate?: (g: NavGates) => boolean
|
|
230
|
-
/**
|
|
231
|
-
* A power-user destination: shown only in ADVANCED interface mode (basic mode is the
|
|
232
|
-
* everyday surface — see `stores/uiMode.ts`). Declarative rather than folded into
|
|
233
|
-
* {@link gate}, so an item keeps its RBAC/availability predicate unchanged and the two
|
|
234
|
-
* axes stay independently assertable. Absent = visible in both tiers.
|
|
235
|
-
*/
|
|
236
|
-
advanced?: boolean
|
|
237
277
|
/**
|
|
238
278
|
* First-party action id, resolved to a `run()` against the host `ui` store by
|
|
239
279
|
* `useNavContributions`. A consumer module that has its own stores instead
|
|
@@ -292,6 +332,13 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
|
292
332
|
* (`fragments`), the ephemeral-env + runner plumbing (`infrastructure`, which is also the only route
|
|
293
333
|
* to the guided per-service Compose environment setup), and the workspace/model configuration
|
|
294
334
|
* a run actually reads (`workspace-settings`, `model-config`).
|
|
335
|
+
*
|
|
336
|
+
* `intake: true` marks the OTHER axis (see `utils/uiRole.ts`): the three entries a narrowed role
|
|
337
|
+
* keeps. It is opt-in, and the set is short on purpose, because the intake surface brings work IN and
|
|
338
|
+
* watches it, and every destination here that is not `tutorial`, `keyboard-shortcuts` or
|
|
339
|
+
* `ui-role` configures the platform the work runs on. What that role creates instead lives on the
|
|
340
|
+
* BOARD (a frame's add-task / from-ticket / from-design buttons), which is why dropping nearly
|
|
341
|
+
* the whole sidebar costs it nothing it is there to do.
|
|
295
342
|
*/
|
|
296
343
|
export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
297
344
|
{
|
|
@@ -575,6 +622,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
575
622
|
labelKey: 'nav.tutorials',
|
|
576
623
|
icon: 'i-lucide-graduation-cap',
|
|
577
624
|
surfaces: S('sidebar', 'command'),
|
|
625
|
+
// On the intake surface too, and it is the surface that needs it most: a narrowed role has
|
|
626
|
+
// the fewest destinations to learn the product from, and the delivery-loop tours are exactly
|
|
627
|
+
// the ones it can take (the platform half gates on the full surface: see `tutorial-tours.ts`).
|
|
628
|
+
intake: true,
|
|
578
629
|
action: 'tutorial',
|
|
579
630
|
testId: 'nav-tutorial',
|
|
580
631
|
sidebar: { group: 'help', order: 10 },
|
|
@@ -594,6 +645,9 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
594
645
|
labelKey: 'layout.commandBar.cmd.shortcuts',
|
|
595
646
|
icon: 'i-lucide-keyboard',
|
|
596
647
|
surfaces: S('command'),
|
|
648
|
+
// Help, like the tutorials: the cheatsheet describes the board and the palette, which every
|
|
649
|
+
// role has, and nothing it lists is a destination the intake surface hides.
|
|
650
|
+
intake: true,
|
|
597
651
|
action: 'shortcuts',
|
|
598
652
|
command: {
|
|
599
653
|
group: 'workspace',
|
|
@@ -611,6 +665,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
611
665
|
labelKey: 'layout.commandBar.cmd.toggleUiMode',
|
|
612
666
|
icon: 'i-lucide-toggle-right',
|
|
613
667
|
surfaces: S('command'),
|
|
668
|
+
// Deliberately NOT `intake`: a narrowed role's tier is CAPPED at basic (see `resolveUiMode`),
|
|
669
|
+
// so this entry would flip a preference the resolver then ignores, the same lie the sidebar
|
|
670
|
+
// switcher refuses to be under an env pin. The way out of a narrowed role is the entry below,
|
|
671
|
+
// which changes the surface for real.
|
|
614
672
|
action: 'toggleUiMode',
|
|
615
673
|
command: {
|
|
616
674
|
group: 'workspace',
|
|
@@ -618,6 +676,25 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
618
676
|
keywordsKey: 'layout.commandBar.keywords.toggleUiMode',
|
|
619
677
|
},
|
|
620
678
|
},
|
|
679
|
+
{
|
|
680
|
+
// The way BACK from a narrowed role, and the reason the intake surface is safe to ship: the
|
|
681
|
+
// role decides which surfaces exist at all, so a person who picked `designer` and then needs
|
|
682
|
+
// a pipeline has to be able to say so from inside that surface. Palette-only because the
|
|
683
|
+
// sidebar already carries the switcher itself (`UiRoleSwitcher`, above the destinations it
|
|
684
|
+
// gates, in EVERY role) rather than a shortcut to this question, the same relationship
|
|
685
|
+
// `ui-mode` has with the tier switcher.
|
|
686
|
+
id: 'ui-role',
|
|
687
|
+
labelKey: 'layout.commandBar.cmd.chooseRole',
|
|
688
|
+
icon: 'i-lucide-user-round-cog',
|
|
689
|
+
surfaces: S('command'),
|
|
690
|
+
intake: true,
|
|
691
|
+
action: 'chooseRole',
|
|
692
|
+
command: {
|
|
693
|
+
group: 'workspace',
|
|
694
|
+
order: 95,
|
|
695
|
+
keywordsKey: 'layout.commandBar.keywords.chooseRole',
|
|
696
|
+
},
|
|
697
|
+
},
|
|
621
698
|
]
|
|
622
699
|
|
|
623
700
|
/**
|
|
@@ -633,21 +710,25 @@ export const navigationModule = defineModule({
|
|
|
633
710
|
/**
|
|
634
711
|
* Does a shell render this contribution under `gates`?
|
|
635
712
|
*
|
|
636
|
-
* The
|
|
637
|
-
* pass: an `advanced` item is dropped in basic mode,
|
|
638
|
-
* `gate`. Order doesn't matter (it's a
|
|
639
|
-
* the cheaper
|
|
713
|
+
* The three axes a destination is gated on, in ONE place. They are independent and ALL must
|
|
714
|
+
* pass: an `advanced` item is dropped in basic mode, an item that is not `intake` is dropped for
|
|
715
|
+
* a narrowed role, and every item still answers to its own `gate`. Order doesn't matter (it's a
|
|
716
|
+
* conjunction) but the two flag reads come first, since they're the cheaper ones.
|
|
640
717
|
*
|
|
641
|
-
* Named rather than inlined in {@link navSlotFilter} because
|
|
718
|
+
* Named rather than inlined in {@link navSlotFilter} because other readers have to agree with
|
|
642
719
|
* it exactly: a tutorial tour whose step CLICKS a nav entry declares the requirement that
|
|
643
720
|
* renders it, and `tutorial-tours.spec.ts` pairs the two through this function. Spelling the
|
|
644
|
-
* conjunction out
|
|
721
|
+
* conjunction out anywhere else would be a copy that keeps passing while this one changes —
|
|
645
722
|
* and the drift it would miss (an entry gaining a gate clause, or being marked `advanced` and
|
|
646
723
|
* so leaving the DEFAULT interface tier) is precisely a tour offered to a user who then finds
|
|
647
724
|
* no such control.
|
|
648
725
|
*/
|
|
649
|
-
export function navItemVisible(item:
|
|
650
|
-
return (
|
|
726
|
+
export function navItemVisible(item: NavGatedContribution, gates: NavGates): boolean {
|
|
727
|
+
return (
|
|
728
|
+
(item.advanced ? gates.advancedMode : true) &&
|
|
729
|
+
(gates.fullSurface || item.intake === true) &&
|
|
730
|
+
(item.gate ? item.gate(gates) : true)
|
|
731
|
+
)
|
|
651
732
|
}
|
|
652
733
|
|
|
653
734
|
/**
|
|
@@ -675,10 +756,12 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
675
756
|
// richer value than a thinned list, so tour resolution lives in `resolveTourCatalogue`
|
|
676
757
|
// (pure, gates-nullable) and runs once in `useTutorialTours`, whose `tours` is the same
|
|
677
758
|
// gated set the launch prompt and the overlay always saw.
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
759
|
+
// A deployment's external tools run through the SAME predicate as `nav`, on the same line
|
|
760
|
+
// shape, because they become nav items downstream (`useNavContributions` projects them):
|
|
761
|
+
// gating them by any other expression lets a tool reach the palette while its first-party
|
|
762
|
+
// neighbours are correctly hidden. That is exactly what a separate `filterExternalTools`
|
|
763
|
+
// spelling its own conjunction did when the role axis landed.
|
|
764
|
+
externalTools: gates ? externalTools.filter((t) => navItemVisible(t, gates)) : externalTools,
|
|
682
765
|
}
|
|
683
766
|
}
|
|
684
767
|
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -15,8 +15,9 @@ import type { NavGates } from '~/modular/nav-contributions'
|
|
|
15
15
|
*
|
|
16
16
|
* This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
|
|
17
17
|
* `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity), plus
|
|
18
|
-
* the interface tier (`advancedMode`)
|
|
19
|
-
* re-gates all three shells through the same
|
|
18
|
+
* the interface tier (`advancedMode`) and the person's role (`fullSurface`), which ride
|
|
19
|
+
* the same service so a mode or role flip re-gates all three shells through the same
|
|
20
|
+
* reactive path as a permission flip.
|
|
20
21
|
*/
|
|
21
22
|
export function createNavGates(): NavGates {
|
|
22
23
|
const access = useWorkspaceAccess()
|
|
@@ -27,6 +28,7 @@ export function createNavGates(): NavGates {
|
|
|
27
28
|
const auth = useAuthStore()
|
|
28
29
|
const providerConnections = useProviderConnectionsStore()
|
|
29
30
|
const uiMode = useUiModeStore()
|
|
31
|
+
const uiRole = useUiRoleStore()
|
|
30
32
|
const board = useBoardStore()
|
|
31
33
|
const execution = useExecutionStore()
|
|
32
34
|
const reviews = useReviewStage()
|
|
@@ -108,6 +110,13 @@ export function createNavGates(): NavGates {
|
|
|
108
110
|
get advancedMode() {
|
|
109
111
|
return uiMode.isAdvanced
|
|
110
112
|
},
|
|
113
|
+
// The role axis rides the same reactive service as the tier, so picking a role re-gates all
|
|
114
|
+
// three shells through one path. It is not a permission: every item it keeps is still gated
|
|
115
|
+
// by its own `gate`, and every item it drops is one the caller may well be allowed to open,
|
|
116
|
+
// which is why the switcher out of a narrowed role is itself `intake`.
|
|
117
|
+
get fullSurface() {
|
|
118
|
+
return uiRole.fullSurface
|
|
119
|
+
},
|
|
111
120
|
get boardHasService() {
|
|
112
121
|
return hasService.value
|
|
113
122
|
},
|
|
@@ -24,6 +24,7 @@ const ALL_GATES: NavGates = {
|
|
|
24
24
|
accountsEnabled: true,
|
|
25
25
|
isAccountAdmin: true,
|
|
26
26
|
advancedMode: true,
|
|
27
|
+
fullSurface: true,
|
|
27
28
|
boardHasService: true,
|
|
28
29
|
boardHasTask: true,
|
|
29
30
|
boardHasRun: true,
|
|
@@ -140,9 +141,10 @@ function navAnchoredSteps(): {
|
|
|
140
141
|
* Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
|
|
141
142
|
* gate sets — anything that satisfies a tour must also render its entry — between two
|
|
142
143
|
* predicates written independently in two files, and nothing about their shape is guaranteed
|
|
143
|
-
* (either may be a conjunction, a disjunction, or read a field the other doesn't).
|
|
144
|
-
*
|
|
145
|
-
*
|
|
144
|
+
* (either may be a conjunction, a disjunction, or read a field the other doesn't). The matrix
|
|
145
|
+
* doubles with every field {@link NavGates} gains, so the cost is worth re-measuring rather than
|
|
146
|
+
* quoting from here: at the field count this spec runs against today it is a fraction of a
|
|
147
|
+
* second, a fair price for a guard that needs no assumption about how either side is spelled.
|
|
146
148
|
*/
|
|
147
149
|
function* everyGateSet(): Generator<NavGates> {
|
|
148
150
|
const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]
|
|
@@ -130,6 +130,17 @@ export const TUTORIAL_REQUIREMENTS = {
|
|
|
130
130
|
labelKey: 'tutorial.requirements.advancedTier',
|
|
131
131
|
met: (gates) => gates.advancedMode,
|
|
132
132
|
},
|
|
133
|
+
// The other SPA-narrowing axis, and it is a requirement for exactly the same reason the tier is:
|
|
134
|
+
// a narrowed ROLE (`utils/uiRole.ts`) keeps only the `intake` nav entries, so every tour whose
|
|
135
|
+
// step CLICKS one of the others would be offered to a designer and then hunt for a sidebar entry
|
|
136
|
+
// their screen does not render. Which tours those are is not a judgement call: it is the pairing
|
|
137
|
+
// `tutorial-tours.spec.ts` derives from `navItemVisible`, so a tour that gains a nav-anchored
|
|
138
|
+
// step fails there until this is declared.
|
|
139
|
+
fullSurface: {
|
|
140
|
+
id: 'full-surface',
|
|
141
|
+
labelKey: 'tutorial.requirements.fullSurface',
|
|
142
|
+
met: (gates) => gates.fullSurface,
|
|
143
|
+
},
|
|
133
144
|
// The platform half's requirements. Each mirrors, exactly, the `gate` of the sidebar entry the
|
|
134
145
|
// tour clicks (`nav-model-providers` / `nav-integrations`, `nav-fragments`). A requirement
|
|
135
146
|
// WEAKER than the gate of the control a step points at offers the tour to a user who has no
|
|
@@ -204,15 +215,33 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
204
215
|
titleKey: 'tutorial.tours.boardBasics.steps.toolbar.title',
|
|
205
216
|
bodyKey: 'tutorial.tours.boardBasics.steps.toolbar.body',
|
|
206
217
|
},
|
|
218
|
+
{
|
|
219
|
+
// The role decides which surfaces exist at all, so the orientation tour names it before
|
|
220
|
+
// the tier it caps. No `when`: the switcher is rendered in EVERY role (it is the way back
|
|
221
|
+
// out of the narrowed one), which is exactly why the tour can always point at it.
|
|
222
|
+
id: 'role',
|
|
223
|
+
target: 'ui-role-switcher',
|
|
224
|
+
altTargets: ['ui-role-toggle'],
|
|
225
|
+
placement: 'right',
|
|
226
|
+
titleKey: 'tutorial.tours.boardBasics.steps.role.title',
|
|
227
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.role.body',
|
|
228
|
+
},
|
|
207
229
|
{
|
|
208
230
|
// Basic mode is the shipped default, and it HIDES a whole half of the product
|
|
209
231
|
// (sandbox, Kaizen, bootstrap, the operator surfaces). A user who never finds the
|
|
210
232
|
// switcher never learns that half exists, so the orientation tour is the one place
|
|
211
233
|
// that has to name it — the switcher is deliberately visible in both tiers for the
|
|
212
234
|
// same reason (see `nav-contributions.ts`).
|
|
235
|
+
//
|
|
236
|
+
// `when` rather than a tour-level requirement, and rather than nothing: a narrowed role's
|
|
237
|
+
// tier is CAPPED at basic, so the sidebar drops this switcher, and a step that merely
|
|
238
|
+
// missed its anchor would be reported as an abridged tour, which is the wrong thing to say
|
|
239
|
+
// about a control that is absent because of a choice the user made. Gating the whole tour
|
|
240
|
+
// would be worse still: orientation is the one walkthrough every role should get.
|
|
213
241
|
id: 'interfaceTier',
|
|
214
242
|
target: 'ui-mode-switcher',
|
|
215
243
|
altTargets: ['ui-mode-toggle'],
|
|
244
|
+
when: (gates) => gates.fullSurface,
|
|
216
245
|
placement: 'right',
|
|
217
246
|
titleKey: 'tutorial.tours.boardBasics.steps.interfaceTier.title',
|
|
218
247
|
bodyKey: 'tutorial.tours.boardBasics.steps.interfaceTier.body',
|
|
@@ -236,7 +265,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
236
265
|
// repo is a board write against a connected source, and in basic interface mode
|
|
237
266
|
// add-from-repo is the ONLY route (bootstrap is advanced), which is what makes this
|
|
238
267
|
// worth a tour rather than a hint.
|
|
239
|
-
requires: [
|
|
268
|
+
requires: [
|
|
269
|
+
TUTORIAL_REQUIREMENTS.boardWrite,
|
|
270
|
+
TUTORIAL_REQUIREMENTS.sourceControl,
|
|
271
|
+
TUTORIAL_REQUIREMENTS.fullSurface,
|
|
272
|
+
],
|
|
240
273
|
steps: [
|
|
241
274
|
{
|
|
242
275
|
id: 'intro',
|
|
@@ -668,7 +701,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
668
701
|
// onboarding advisory, which the launch prompt stands down for) — this is for the person who
|
|
669
702
|
// meets the question later, or who wants to know where the answer lives.
|
|
670
703
|
offeredAtLaunch: false,
|
|
671
|
-
requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
|
|
704
|
+
requires: [TUTORIAL_REQUIREMENTS.integrationsManage, TUTORIAL_REQUIREMENTS.fullSurface],
|
|
672
705
|
steps: [
|
|
673
706
|
{
|
|
674
707
|
id: 'intro',
|
|
@@ -712,7 +745,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
712
745
|
// change. A user who never finds the builder treats the built-in catalog as the product's
|
|
713
746
|
// fixed shape and works around it in task descriptions instead.
|
|
714
747
|
offeredAtLaunch: false,
|
|
715
|
-
requires: [TUTORIAL_REQUIREMENTS.boardWrite],
|
|
748
|
+
requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.fullSurface],
|
|
716
749
|
steps: [
|
|
717
750
|
{
|
|
718
751
|
id: 'intro',
|
|
@@ -774,7 +807,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
774
807
|
// How you steer output without restating your conventions in every task description, which
|
|
775
808
|
// is what people do instead when they never find this.
|
|
776
809
|
offeredAtLaunch: false,
|
|
777
|
-
requires: [
|
|
810
|
+
requires: [
|
|
811
|
+
TUTORIAL_REQUIREMENTS.library,
|
|
812
|
+
TUTORIAL_REQUIREMENTS.settingsManage,
|
|
813
|
+
TUTORIAL_REQUIREMENTS.fullSurface,
|
|
814
|
+
],
|
|
778
815
|
steps: [
|
|
779
816
|
{
|
|
780
817
|
id: 'intro',
|
|
@@ -813,7 +850,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
813
850
|
// Each integration changes what a run can SEE or SAY, and none of them announces itself:
|
|
814
851
|
// a board with no tracker linked simply never mentions that issues could arrive on their own.
|
|
815
852
|
offeredAtLaunch: false,
|
|
816
|
-
requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
|
|
853
|
+
requires: [TUTORIAL_REQUIREMENTS.integrationsManage, TUTORIAL_REQUIREMENTS.fullSurface],
|
|
817
854
|
steps: [
|
|
818
855
|
{
|
|
819
856
|
id: 'intro',
|
|
@@ -854,7 +891,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
854
891
|
// itself, and the cost of not finding it is a run that fails on provisioning with a banner
|
|
855
892
|
// pointing at a window the user has never opened.
|
|
856
893
|
offeredAtLaunch: false,
|
|
857
|
-
requires: [TUTORIAL_REQUIREMENTS.infrastructure],
|
|
894
|
+
requires: [TUTORIAL_REQUIREMENTS.infrastructure, TUTORIAL_REQUIREMENTS.fullSurface],
|
|
858
895
|
steps: [
|
|
859
896
|
{
|
|
860
897
|
id: 'intro',
|
|
@@ -906,7 +943,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
906
943
|
// workspace that has never made a group renders nothing for the anchored step to find. The
|
|
907
944
|
// guard only pairs a tour against the visibility of a NAV entry, so a section hiding itself
|
|
908
945
|
// one level in is exactly the case that has to be declared by hand.
|
|
909
|
-
requires: [
|
|
946
|
+
requires: [
|
|
947
|
+
TUTORIAL_REQUIREMENTS.settingsManage,
|
|
948
|
+
TUTORIAL_REQUIREMENTS.advancedTier,
|
|
949
|
+
TUTORIAL_REQUIREMENTS.fullSurface,
|
|
950
|
+
],
|
|
910
951
|
steps: [
|
|
911
952
|
{
|
|
912
953
|
id: 'intro',
|
|
@@ -950,7 +991,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
950
991
|
// renders the entry this tour clicks. Declared rather than assumed: `navRequirementDrift`
|
|
951
992
|
// enumerates the whole gate matrix against that entry's own visibility rule and fails
|
|
952
993
|
// without it.
|
|
953
|
-
requires: [
|
|
994
|
+
requires: [
|
|
995
|
+
TUTORIAL_REQUIREMENTS.settingsManage,
|
|
996
|
+
TUTORIAL_REQUIREMENTS.advancedTier,
|
|
997
|
+
TUTORIAL_REQUIREMENTS.fullSurface,
|
|
998
|
+
],
|
|
954
999
|
steps: [
|
|
955
1000
|
{
|
|
956
1001
|
id: 'intro',
|