@cat-factory/app 0.196.0 → 0.197.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 +49 -3
- package/app/components/brainstorm/BrainstormWindow.vue +11 -4
- package/app/components/clarity/ClarityReviewWindow.vue +11 -4
- package/app/components/initiative/InitiativePlanReview.vue +44 -37
- package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
- package/app/components/layout/SideBar.vue +13 -2
- package/app/components/layout/UiModeSwitcher.vue +66 -39
- package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
- package/app/components/panels/ResultWindowShell.logic.ts +31 -0
- package/app/components/panels/ResultWindowShell.vue +37 -8
- package/app/components/prReview/PrReviewWindow.vue +15 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
- package/app/components/spec/ServiceSpecWindow.vue +7 -4
- package/app/components/testing/TestReportWindow.vue +11 -6
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
- package/app/components/tutorial/TutorialOverlay.vue +273 -0
- package/app/components/tutorial/TutorialPrompt.vue +102 -0
- package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +6 -164
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +4 -0
- package/app/modular/nav-contributions.ts +38 -3
- package/app/modular/nav-gates.ts +7 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +107 -0
- package/app/modular/tutorial-tours.ts +147 -0
- package/app/pages/index.vue +61 -0
- package/app/stores/board/dependencies.ts +52 -0
- package/app/stores/board/placement.ts +4 -37
- package/app/stores/execution/pendingGates.ts +109 -0
- package/app/stores/execution.ts +7 -94
- package/app/stores/requirements/recommendations.ts +77 -0
- package/app/stores/requirements.ts +17 -43
- package/app/stores/tutorial.spec.ts +135 -0
- package/app/stores/tutorial.ts +145 -0
- package/app/stores/workspace/commands.ts +77 -0
- package/app/stores/workspace.ts +11 -50
- package/app/utils/tutorial.spec.ts +68 -0
- package/app/utils/tutorial.ts +192 -0
- package/i18n/locales/de.json +90 -2
- package/i18n/locales/en.json +96 -2
- package/i18n/locales/es.json +90 -2
- package/i18n/locales/fr.json +90 -2
- package/i18n/locales/he.json +90 -2
- package/i18n/locales/it.json +90 -2
- package/i18n/locales/ja.json +90 -2
- package/i18n/locales/pl.json +90 -2
- package/i18n/locales/tr.json +90 -2
- package/i18n/locales/uk.json +90 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
|
|
|
17
17
|
- [Layout](#layout)
|
|
18
18
|
- [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
|
|
19
19
|
- [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
|
|
20
|
+
- [In-app tutorial tours](#in-app-tutorial-tours)
|
|
20
21
|
- [Key UI surfaces](#key-ui-surfaces)
|
|
21
22
|
- [Develop & test](#develop--test)
|
|
22
23
|
|
|
@@ -77,11 +78,18 @@ a fixed order, first match wins:
|
|
|
77
78
|
set the in-app switcher is a read-only indicator, since a preference the resolver ignores
|
|
78
79
|
would be a lie. An unrecognised value is ignored rather than failing the boot.
|
|
79
80
|
2. **The user's own choice**, persisted client-side (the `uiMode` store) and changed from the
|
|
80
|
-
switcher at the
|
|
81
|
-
deliberately _not_ an advanced item: basic is the default, so the
|
|
82
|
-
advanced half has to exist inside basic mode.
|
|
81
|
+
switcher at the top of the sidebar, under the board switcher — or from the **command
|
|
82
|
+
palette** entry, which is deliberately _not_ an advanced item: basic is the default, so the
|
|
83
|
+
route back to the advanced half has to exist inside basic mode.
|
|
83
84
|
3. **`basic`.**
|
|
84
85
|
|
|
86
|
+
That switcher is a **segmented control showing both tiers**, above the fold rather than in the
|
|
87
|
+
footer, because basic is the shipped default and it is most users' only sight of the tier: a
|
|
88
|
+
dropdown states the current mode but not that another one exists, so the half of the product it
|
|
89
|
+
gates stays invisible to anyone who does not open menus to see what is in them. In the collapsed
|
|
90
|
+
rail it degrades to one button that flips the tier (with only two modes a toggle is
|
|
91
|
+
unambiguous), keeping the current tier's name under the glyph.
|
|
92
|
+
|
|
85
93
|
The sidebar can independently be **collapsed to an icon rail** (the toggle at its top, lg+
|
|
86
94
|
only — below `lg` the navbar is already an off-canvas drawer). The rail preference is
|
|
87
95
|
**per-tier**: basic _defaults_ to railed and advanced to expanded, and each tier remembers its
|
|
@@ -152,6 +160,44 @@ picking what each of them runs on are halves of the same job.
|
|
|
152
160
|
whatever the tier — the same rule `showOverrideField` states for a single field: a row the
|
|
153
161
|
user can neither read nor clear is worse than a longer list.
|
|
154
162
|
|
|
163
|
+
## In-app tutorial tours
|
|
164
|
+
|
|
165
|
+
On first launch (once the board is up and no other startup advisory is open) the app asks
|
|
166
|
+
whether the user wants a guided tour. The answer is SAVED per browser (`stores/tutorial.ts`,
|
|
167
|
+
persisted like the interface tier): "no thanks" stops the prompt for good, closing without
|
|
168
|
+
answering defers it to the next launch, and the command palette's "Take a tour" entry is the
|
|
169
|
+
way back either way.
|
|
170
|
+
|
|
171
|
+
A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
|
|
172
|
+
control by its `data-testid` (the e2e anchor vocabulary — cover a control that has none by
|
|
173
|
+
adding the test id first) and carrying i18n keys for its copy. One shared runtime
|
|
174
|
+
(`components/tutorial/TutorialOverlay.vue`) renders every tour: it highlights the current
|
|
175
|
+
step's control, places the tooltip (`utils/tutorial.ts` owns the pure geometry + types),
|
|
176
|
+
advances on Next or — for `advanceOn: 'target-click'` steps — on the user really clicking
|
|
177
|
+
the control, so the app's real response (the actual modal, the actual task) is what the next
|
|
178
|
+
step anchors to. `target-click` is for BUTTONS, where the click is the completed action; a
|
|
179
|
+
text field keeps Next, or the tooltip would leave the instruction the moment the user clicked
|
|
180
|
+
in to type. A step whose anchor never appears within its wait is SKIPPED, because controls
|
|
181
|
+
come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
|
|
182
|
+
fixed script. Reaching the end having skipped steps is reported on the final card rather than
|
|
183
|
+
congratulating the user on a walkthrough they did not see — and a tour that could only ever
|
|
184
|
+
be abridged should not be offered at all, which is what each tour's `when(gates)` is for (the
|
|
185
|
+
task-creation tour requires board write AND a service frame to add a task to).
|
|
186
|
+
|
|
187
|
+
Two runtime constraints worth knowing before changing the overlay: it must keep
|
|
188
|
+
`pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
|
|
189
|
+
dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
|
|
190
|
+
pointerdown — without both, the tooltip's own buttons go inert and pressing one closes the
|
|
191
|
+
user's half-filled form. And everything that DECIDES (skip direction, wait budget,
|
|
192
|
+
target-click matching) lives in `components/tutorial/TutorialOverlay.logic.ts` so it is
|
|
193
|
+
unit-tested; the SFC keeps only the DOM work.
|
|
194
|
+
|
|
195
|
+
The catalog is the `tutorialTours` slot: first-party tours live in
|
|
196
|
+
`modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
|
|
197
|
+
`registerAppModule` — they appear in the launch prompt beside the built-ins, gated per tour
|
|
198
|
+
by its `when(gates)` predicate (the same reactive gates service the nav uses, filtered in
|
|
199
|
+
`navSlotFilter`). Completion is persisted per tour id, so renaming an id resets its state.
|
|
200
|
+
|
|
155
201
|
## Extending the layer (consumer modules)
|
|
156
202
|
|
|
157
203
|
A deployment can contribute its own components — result windows, nav entries, inspector
|
|
@@ -254,7 +254,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
254
254
|
"
|
|
255
255
|
:subtitle="block?.title"
|
|
256
256
|
variant="centered"
|
|
257
|
-
width="
|
|
257
|
+
width="full"
|
|
258
258
|
@close="close"
|
|
259
259
|
>
|
|
260
260
|
<template v-if="session" #header-extras>
|
|
@@ -361,14 +361,18 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
361
361
|
{{ t(STATUS_LABELS[item.status]) }}
|
|
362
362
|
</UBadge>
|
|
363
363
|
</div>
|
|
364
|
-
|
|
364
|
+
<!-- The proposal itself is prose, so it takes the measure even though the card
|
|
365
|
+
around it takes the span (see the shell's `width` prop: the unit is the
|
|
366
|
+
paragraph, not the section). The badge row above and the choose/dismiss
|
|
367
|
+
control below are what the full width is actually for. -->
|
|
368
|
+
<p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-400">
|
|
365
369
|
{{ item.detail }}
|
|
366
370
|
</p>
|
|
367
371
|
|
|
368
372
|
<!-- recorded choice -->
|
|
369
373
|
<div
|
|
370
374
|
v-if="item.reply"
|
|
371
|
-
class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
|
|
375
|
+
class="mt-2 max-w-3xl rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
|
|
372
376
|
>
|
|
373
377
|
<span class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
374
378
|
{{ t('brainstorm.yourChoice') }}
|
|
@@ -446,7 +450,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
446
450
|
{{ incorporated ? docNoun : t('brainstorm.docDraft', { doc: docNoun }) }}
|
|
447
451
|
</span>
|
|
448
452
|
</div>
|
|
449
|
-
|
|
453
|
+
<!-- The same reading measure the options' own prose takes above (see the shell's
|
|
454
|
+
`width` prop): the window is `full`-width now, and this is continuous prose that
|
|
455
|
+
would otherwise run to 200-character lines. -->
|
|
456
|
+
<div v-for="s in outline.sections" :key="s.id" class="mb-2 max-w-3xl">
|
|
450
457
|
<button
|
|
451
458
|
v-if="s.title"
|
|
452
459
|
class="group flex w-full items-center gap-2 text-start"
|
|
@@ -310,7 +310,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
310
310
|
:title="t('clarity.title')"
|
|
311
311
|
:subtitle="block?.title"
|
|
312
312
|
variant="centered"
|
|
313
|
-
width="
|
|
313
|
+
width="full"
|
|
314
314
|
@close="close"
|
|
315
315
|
>
|
|
316
316
|
<template v-if="review" #header-extras>
|
|
@@ -418,7 +418,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
418
418
|
{{ t(STATUS_LABELS[item.status]) }}
|
|
419
419
|
</UBadge>
|
|
420
420
|
</div>
|
|
421
|
-
|
|
421
|
+
<!-- The reviewer's question is prose, so it takes the measure even though the
|
|
422
|
+
card around it takes the span (see the shell's `width` prop: the unit is the
|
|
423
|
+
paragraph, not the section). The badge row above and the answer control
|
|
424
|
+
below are what the full width is actually for. -->
|
|
425
|
+
<p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-400">
|
|
422
426
|
{{ item.detail }}
|
|
423
427
|
</p>
|
|
424
428
|
|
|
@@ -426,7 +430,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
426
430
|
the answer lives in the textarea below, seeded from the reply) -->
|
|
427
431
|
<div
|
|
428
432
|
v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
|
|
429
|
-
class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
|
|
433
|
+
class="mt-2 max-w-3xl rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
|
|
430
434
|
>
|
|
431
435
|
<span class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
432
436
|
{{ t('clarity.answerLabel') }}
|
|
@@ -490,7 +494,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
490
494
|
{{ incorporated ? t('clarity.docHeading') : t('clarity.docHeadingDraft') }}
|
|
491
495
|
</span>
|
|
492
496
|
</div>
|
|
493
|
-
|
|
497
|
+
<!-- The same reading measure the findings' own prose takes above (see the shell's
|
|
498
|
+
`width` prop): the window is `full`-width now, and this is continuous prose that
|
|
499
|
+
would otherwise run to 200-character lines. -->
|
|
500
|
+
<div v-for="s in outline.sections" :key="s.id" class="mb-2 max-w-3xl">
|
|
494
501
|
<button
|
|
495
502
|
v-if="s.title"
|
|
496
503
|
class="group flex w-full items-center gap-2 text-start"
|
|
@@ -224,45 +224,52 @@ async function copyPlan() {
|
|
|
224
224
|
class="min-h-0 min-w-0 flex-1 overflow-y-auto px-5 py-4"
|
|
225
225
|
@scroll="onScroll"
|
|
226
226
|
>
|
|
227
|
-
<!--
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
:
|
|
241
|
-
|
|
227
|
+
<!-- The reading measure, which the shell's `full` width made load-bearing: this column used
|
|
228
|
+
to be ~490px at every size that rendered it (a fixed outline and a fixed rail out of a
|
|
229
|
+
`5xl` shell), so a cap was dead markup and the comment here said so. On a window that now
|
|
230
|
+
spans the viewport it is the only thing between the plan and 200-character lines, and it
|
|
231
|
+
is the step reader's own measure (`AgentStepDetail`, `mx-auto max-w-3xl` over the same
|
|
232
|
+
13px `.reader-prose`) rather than a second opinion about how wide prose should be. The
|
|
233
|
+
leftover width is the document's margins; the LAYOUT is what the extra space bought —
|
|
234
|
+
outline and rail no longer competing with the plan for one 5xl card. -->
|
|
235
|
+
<div class="mx-auto w-full max-w-3xl">
|
|
236
|
+
<section
|
|
237
|
+
v-for="s in outline.sections"
|
|
238
|
+
:id="s.id"
|
|
239
|
+
:key="s.id"
|
|
240
|
+
:ref="(el) => (sectionEls[s.id] = el as HTMLElement | null)"
|
|
241
|
+
class="scroll-mt-2"
|
|
242
242
|
>
|
|
243
|
-
<
|
|
244
|
-
|
|
245
|
-
class="
|
|
246
|
-
:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
243
|
+
<button
|
|
244
|
+
v-if="s.depth > 0"
|
|
245
|
+
class="group flex w-full items-center gap-1.5 rounded py-0.5 text-start transition hover:text-white"
|
|
246
|
+
:aria-expanded="!collapsed[s.id]"
|
|
247
|
+
@click="toggle(s.id)"
|
|
248
|
+
>
|
|
249
|
+
<UIcon
|
|
250
|
+
name="i-lucide-chevron-right"
|
|
251
|
+
class="h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform group-hover:text-slate-300"
|
|
252
|
+
:class="collapsed[s.id] ? '' : 'rotate-90'"
|
|
253
|
+
/>
|
|
254
|
+
<span
|
|
255
|
+
class="font-semibold text-slate-100"
|
|
256
|
+
:class="s.depth <= 1 ? 'text-base' : s.depth === 2 ? 'text-sm' : 'text-[13px]'"
|
|
257
|
+
v-html="s.titleHtml"
|
|
258
|
+
/>
|
|
259
|
+
</button>
|
|
260
|
+
<!-- `review-mode` carries the click-to-comment affordance, so it tracks the same RBAC
|
|
261
|
+
gate the composer does — a viewer gets the document, not hover targets that lead
|
|
262
|
+
nowhere. -->
|
|
263
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
264
|
+
<div
|
|
265
|
+
v-show="!collapsed[s.id]"
|
|
266
|
+
class="reader-prose mt-0.5 text-[13px] leading-relaxed text-slate-300"
|
|
267
|
+
:class="[s.depth > 0 ? 'ps-5' : '', canExecute ? 'review-mode' : '']"
|
|
268
|
+
@click="onProseClick"
|
|
269
|
+
v-html="s.bodyHtml"
|
|
252
270
|
/>
|
|
253
|
-
</
|
|
254
|
-
|
|
255
|
-
gate the composer does — a viewer gets the document, not hover targets that lead
|
|
256
|
-
nowhere. -->
|
|
257
|
-
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
258
|
-
<div
|
|
259
|
-
v-show="!collapsed[s.id]"
|
|
260
|
-
class="reader-prose mt-0.5 text-[13px] leading-relaxed text-slate-300"
|
|
261
|
-
:class="[s.depth > 0 ? 'ps-5' : '', canExecute ? 'review-mode' : '']"
|
|
262
|
-
@click="onProseClick"
|
|
263
|
-
v-html="s.bodyHtml"
|
|
264
|
-
/>
|
|
265
|
-
</section>
|
|
271
|
+
</section>
|
|
272
|
+
</div>
|
|
266
273
|
</div>
|
|
267
274
|
|
|
268
275
|
<!-- Review rail: what the human is being asked, the anchored comments so far, and the two
|
|
@@ -245,7 +245,7 @@ async function savePolicy() {
|
|
|
245
245
|
icon-class="bg-indigo-500/15 text-indigo-300"
|
|
246
246
|
:title="initiative?.title ?? block?.title ?? t('initiative.tracker.title')"
|
|
247
247
|
:subtitle="t('initiative.tracker.subtitle')"
|
|
248
|
-
width="
|
|
248
|
+
width="full"
|
|
249
249
|
testid="initiative-tracker-window"
|
|
250
250
|
@close="close"
|
|
251
251
|
>
|
|
@@ -353,12 +353,15 @@ async function savePolicy() {
|
|
|
353
353
|
</div>
|
|
354
354
|
</section>
|
|
355
355
|
|
|
356
|
-
<!-- Goal & constraints
|
|
356
|
+
<!-- Goal & constraints. The planner's own prose, so it takes the reading measure the
|
|
357
|
+
shell's `full` width obliges (see the `width` prop): the phase/item rows below are
|
|
358
|
+
structure that reads better at the full span, but these are paragraphs and bullets
|
|
359
|
+
of agent-written text that would otherwise run the width of the display. -->
|
|
357
360
|
<section v-if="initiative.goal" class="mb-4">
|
|
358
361
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
359
362
|
{{ t('initiative.tracker.goal') }}
|
|
360
363
|
</h3>
|
|
361
|
-
<p class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
|
|
364
|
+
<p class="max-w-3xl whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
|
|
362
365
|
{{ initiative.goal }}
|
|
363
366
|
</p>
|
|
364
367
|
</section>
|
|
@@ -366,7 +369,7 @@ async function savePolicy() {
|
|
|
366
369
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
367
370
|
{{ t('initiative.tracker.constraints') }}
|
|
368
371
|
</h3>
|
|
369
|
-
<ul class="list-inside list-disc text-[13px] text-slate-300">
|
|
372
|
+
<ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
|
|
370
373
|
<li v-for="(c, i) in initiative.constraints" :key="i">{{ c }}</li>
|
|
371
374
|
</ul>
|
|
372
375
|
</section>
|
|
@@ -374,7 +377,7 @@ async function savePolicy() {
|
|
|
374
377
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
375
378
|
{{ t('initiative.tracker.nonGoals') }}
|
|
376
379
|
</h3>
|
|
377
|
-
<ul class="list-inside list-disc text-[13px] text-slate-300">
|
|
380
|
+
<ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
|
|
378
381
|
<li v-for="(g, i) in initiative.nonGoals" :key="i">{{ g }}</li>
|
|
379
382
|
</ul>
|
|
380
383
|
</section>
|
|
@@ -382,7 +385,7 @@ async function savePolicy() {
|
|
|
382
385
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
383
386
|
{{ t('initiative.tracker.analysis') }}
|
|
384
387
|
</h3>
|
|
385
|
-
<p class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
|
|
388
|
+
<p class="max-w-3xl whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
|
|
386
389
|
{{ initiative.analysisSummary }}
|
|
387
390
|
</p>
|
|
388
391
|
</section>
|
|
@@ -450,7 +453,7 @@ async function savePolicy() {
|
|
|
450
453
|
})
|
|
451
454
|
}}
|
|
452
455
|
</div>
|
|
453
|
-
<div v-if="item.note" class="mt-0.5 text-[10px] text-amber-300/80">
|
|
456
|
+
<div v-if="item.note" class="mt-0.5 max-w-3xl text-[10px] text-amber-300/80">
|
|
454
457
|
{{ item.note }}
|
|
455
458
|
</div>
|
|
456
459
|
<div
|
|
@@ -592,7 +595,7 @@ async function savePolicy() {
|
|
|
592
595
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
593
596
|
{{ t('initiative.tracker.deviations') }}
|
|
594
597
|
</h3>
|
|
595
|
-
<ul class="list-inside list-disc text-[13px] text-slate-300">
|
|
598
|
+
<ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
|
|
596
599
|
<li v-for="d in initiative.deviations" :key="d.id">
|
|
597
600
|
<code v-if="d.itemId" class="text-slate-400">{{ d.itemId }}</code>
|
|
598
601
|
{{ d.description }}
|
|
@@ -688,7 +691,7 @@ async function savePolicy() {
|
|
|
688
691
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
689
692
|
{{ t('initiative.tracker.caveats') }}
|
|
690
693
|
</h3>
|
|
691
|
-
<ul class="list-inside list-disc text-[13px] text-slate-300">
|
|
694
|
+
<ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
|
|
692
695
|
<li v-for="(c, i) in initiative.caveats" :key="i">{{ c }}</li>
|
|
693
696
|
</ul>
|
|
694
697
|
</section>
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// Two orthogonal ways this panel shrinks. WHICH destinations exist is the interface
|
|
10
10
|
// TIER (basic hides the `advanced` contributions, filtered upstream in `navSlotFilter`);
|
|
11
11
|
// how much room they take is the COLLAPSE state (the icon-only rail). Basic mode starts
|
|
12
|
-
// railed, but either can be changed independently from the
|
|
12
|
+
// railed, but either can be changed independently from the tier switcher at the top /
|
|
13
|
+
// the rail toggle.
|
|
13
14
|
import { useEventListener, useScrollLock } from '@vueuse/core'
|
|
14
15
|
import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
|
|
15
16
|
import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
|
|
@@ -179,6 +180,17 @@ watch(
|
|
|
179
180
|
|
|
180
181
|
<BoardSwitcher :collapsed="railed" />
|
|
181
182
|
|
|
183
|
+
<!-- The interface tier sits ABOVE the destinations it gates, not in the footer: basic is the
|
|
184
|
+
shipped default, so this row is most users' only sight of the tier, and below the fold in
|
|
185
|
+
a scrolled navbar it is a thin thread to hang the advanced half of the product on. The
|
|
186
|
+
wrapper is what keeps the control and its hint together — the aside's own `gap-4` would
|
|
187
|
+
otherwise push them apart. Kept OUT of the `onNavAction` group deliberately: switching
|
|
188
|
+
tiers opens nothing, and closing the compact drawer would hide the destinations the
|
|
189
|
+
switch just revealed. -->
|
|
190
|
+
<div class="space-y-1">
|
|
191
|
+
<UiModeSwitcher :collapsed="railed" />
|
|
192
|
+
</div>
|
|
193
|
+
|
|
182
194
|
<div class="contents" @click="onNavAction">
|
|
183
195
|
<!-- Command bar launcher (⌘K) — the primary way to create blocks / pipelines
|
|
184
196
|
and reach every action below. -->
|
|
@@ -236,7 +248,6 @@ watch(
|
|
|
236
248
|
</div>
|
|
237
249
|
|
|
238
250
|
<div class="mt-auto space-y-2">
|
|
239
|
-
<UiModeSwitcher :collapsed="railed" />
|
|
240
251
|
<LanguageSwitcher :collapsed="railed" />
|
|
241
252
|
<UserMenu :collapsed="railed" />
|
|
242
253
|
</div>
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
3
2
|
import { computed } from 'vue'
|
|
4
3
|
import { UI_MODES, type UiMode } from '~/utils/uiMode'
|
|
5
4
|
|
|
6
|
-
// Interface-tier picker, shown at the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
5
|
+
// Interface-tier picker, shown at the TOP of the sidebar under the board switcher. Basic mode
|
|
6
|
+
// hides the power-user destinations and the less-used run options; advanced shows everything.
|
|
7
|
+
//
|
|
8
|
+
// It is a SEGMENTED control rather than a dropdown, and it sits above the fold rather than in the
|
|
9
|
+
// footer, because basic is the shipped default and this row is most users' only sight of the tier:
|
|
10
|
+
// a dropdown states the current mode but not that another one exists, so the half of the product
|
|
11
|
+
// it gates is discoverable only to someone who already opens menus to see what is in them. Showing
|
|
12
|
+
// both segments makes the choice — and the fact that there IS a choice — legible at rest.
|
|
13
|
+
//
|
|
14
|
+
// The user's pick is persisted client-side — unless the deployment pinned the tier via
|
|
15
|
+
// NUXT_PUBLIC_UI_MODE, in which case the row is a read-only indicator, since writing a preference
|
|
16
|
+
// the resolver ignores would be a lie (see `stores/uiMode.ts`).
|
|
17
|
+
//
|
|
18
|
+
// `collapsed` is the icon-only rail (basic mode's own default), where two segments do not fit at
|
|
19
|
+
// 3.5rem. It degrades to ONE button that flips the tier directly — with only two modes a toggle is
|
|
20
|
+
// unambiguous — keeping the current tier's NAME under the glyph rather than an icon alone.
|
|
12
21
|
withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
|
|
13
22
|
|
|
14
23
|
const uiMode = useUiModeStore()
|
|
@@ -27,28 +36,20 @@ const MODE_HINTS: Record<UiMode, string> = {
|
|
|
27
36
|
|
|
28
37
|
const currentLabel = computed(() => t(MODE_LABELS[uiMode.mode]))
|
|
29
38
|
const icon = computed(() => (uiMode.isAdvanced ? 'i-lucide-toggle-right' : 'i-lucide-toggle-left'))
|
|
30
|
-
/**
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
/** The tier the rail button would switch TO, so its tooltip names the act and not the state. */
|
|
40
|
+
const otherMode = computed<UiMode>(() => (uiMode.isAdvanced ? 'basic' : 'advanced'))
|
|
41
|
+
/** Tooltip for the pinned row (both variants): mode + why it can't be changed. */
|
|
42
|
+
const pinnedTitle = computed(
|
|
43
|
+
() => `${t('uiMode.switcher')}: ${currentLabel.value} (${t('uiMode.pinned')})`,
|
|
35
44
|
)
|
|
36
|
-
|
|
37
|
-
const items = computed<DropdownMenuItem[][]>(() => [
|
|
38
|
-
UI_MODES.map((mode) => ({
|
|
39
|
-
label: t(MODE_LABELS[mode]),
|
|
40
|
-
icon: mode === uiMode.mode ? 'i-lucide-check' : undefined,
|
|
41
|
-
onSelect: () => uiMode.setMode(mode),
|
|
42
|
-
})),
|
|
43
|
-
])
|
|
44
45
|
</script>
|
|
45
46
|
|
|
46
47
|
<template>
|
|
47
|
-
<!-- Pinned by the deployment: no
|
|
48
|
+
<!-- Pinned by the deployment: no control, just the current tier. -->
|
|
48
49
|
<div
|
|
49
50
|
v-if="uiMode.envPinned"
|
|
50
51
|
data-testid="ui-mode-pinned"
|
|
51
|
-
:title="
|
|
52
|
+
:title="pinnedTitle"
|
|
52
53
|
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2 text-start"
|
|
53
54
|
:class="collapsed ? 'justify-center' : ''"
|
|
54
55
|
>
|
|
@@ -62,25 +63,51 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
|
|
62
63
|
<UIcon v-if="!collapsed" name="i-lucide-lock" class="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
|
63
64
|
</div>
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
<!-- Rail: one button, flips the tier. The label rides under the glyph so the rail still says
|
|
67
|
+
which tier is on — the audience that needs to find the advanced half is exactly the one
|
|
68
|
+
sitting in basic mode's collapsed default. -->
|
|
69
|
+
<button
|
|
70
|
+
v-else-if="collapsed"
|
|
71
|
+
type="button"
|
|
72
|
+
data-testid="ui-mode-toggle"
|
|
73
|
+
:aria-label="t('uiMode.switchTo', { mode: t(MODE_LABELS[otherMode]) })"
|
|
74
|
+
:title="t('uiMode.switchTo', { mode: t(MODE_LABELS[otherMode]) })"
|
|
75
|
+
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"
|
|
76
|
+
@click="uiMode.toggleMode()"
|
|
77
|
+
>
|
|
78
|
+
<UIcon :name="icon" class="h-4 w-4 shrink-0 text-indigo-400" />
|
|
79
|
+
<span class="w-full truncate text-center text-[9px] font-medium uppercase text-slate-300">
|
|
80
|
+
{{ currentLabel }}
|
|
81
|
+
</span>
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
<div v-else data-testid="ui-mode-switcher" class="w-full">
|
|
85
|
+
<div class="mb-1 px-1 text-[10px] uppercase tracking-wide text-slate-500">
|
|
86
|
+
{{ t('uiMode.switcher') }}
|
|
87
|
+
</div>
|
|
88
|
+
<div
|
|
89
|
+
role="group"
|
|
69
90
|
:aria-label="t('uiMode.switcher')"
|
|
70
|
-
|
|
71
|
-
class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-2 text-start transition hover:bg-slate-800/60"
|
|
72
|
-
:class="collapsed ? 'justify-center' : ''"
|
|
91
|
+
class="flex w-full gap-1 rounded-lg border border-slate-700 bg-slate-900/60 p-1"
|
|
73
92
|
>
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
<button
|
|
94
|
+
v-for="mode in UI_MODES"
|
|
95
|
+
:key="mode"
|
|
96
|
+
type="button"
|
|
97
|
+
:data-testid="`ui-mode-option-${mode}`"
|
|
98
|
+
:aria-pressed="mode === uiMode.mode"
|
|
99
|
+
class="flex-1 truncate rounded-md px-2 py-1 text-xs font-medium transition"
|
|
100
|
+
:class="
|
|
101
|
+
mode === uiMode.mode
|
|
102
|
+
? 'bg-indigo-500/20 text-indigo-200 ring-1 ring-indigo-500/50'
|
|
103
|
+
: 'text-slate-400 hover:bg-slate-800/70 hover:text-slate-200'
|
|
104
|
+
"
|
|
105
|
+
@click="uiMode.setMode(mode)"
|
|
106
|
+
>
|
|
107
|
+
{{ t(MODE_LABELS[mode]) }}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
84
111
|
|
|
85
112
|
<!-- The one-line "what this tier gives you", so the choice is self-explanatory. Dropped in
|
|
86
113
|
the collapsed rail, where the tooltip above carries the mode instead. -->
|