@cat-factory/app 0.197.0 → 0.198.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 +68 -2
- package/app/components/github/AddServiceFromRepoModal.vue +6 -1
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +55 -17
- package/app/components/tutorial/TutorialOverlay.logic.ts +43 -10
- package/app/components/tutorial/TutorialOverlay.vue +47 -13
- package/app/composables/usePipelineErrorToast.spec.ts +4 -1
- package/app/composables/usePipelineErrorToast.ts +18 -1
- package/app/modular/nav-contributions.spec.ts +10 -0
- package/app/modular/nav-contributions.ts +40 -5
- package/app/modular/nav-gates.logic.spec.ts +41 -0
- package/app/modular/nav-gates.logic.ts +36 -0
- package/app/modular/nav-gates.ts +47 -0
- package/app/modular/registry.spec.ts +5 -0
- package/app/modular/tutorial-tours.spec.ts +117 -2
- package/app/modular/tutorial-tours.ts +275 -1
- package/app/utils/tutorial.spec.ts +49 -2
- package/app/utils/tutorial.ts +54 -0
- package/i18n/locales/de.json +120 -0
- package/i18n/locales/en.json +123 -0
- package/i18n/locales/es.json +120 -0
- package/i18n/locales/fr.json +120 -0
- package/i18n/locales/he.json +120 -0
- package/i18n/locales/it.json +120 -0
- package/i18n/locales/ja.json +120 -0
- package/i18n/locales/pl.json +120 -0
- package/i18n/locales/tr.json +120 -0
- package/i18n/locales/uk.json +120 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,6 +55,27 @@ over the WebSocket. How that sync works is written up in
|
|
|
55
55
|
| `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
|
|
56
56
|
| `utils/` | Small pure helpers. |
|
|
57
57
|
|
|
58
|
+
### A store must be instantiable outside a component `setup`
|
|
59
|
+
|
|
60
|
+
A Pinia setup store runs its body on the FIRST `useStore()` anywhere in the app, and that
|
|
61
|
+
caller is not always a component: `plugins/modular.client.ts` builds the nav gates
|
|
62
|
+
(`createNavGates`) during plugin setup, which instantiates a handful of stores before any
|
|
63
|
+
component exists. So nothing a store reaches for at setup time may require an active
|
|
64
|
+
component instance.
|
|
65
|
+
|
|
66
|
+
The one that bites is **`useI18n()`, which throws `MUST_BE_CALL_SETUP_TOP` outside a
|
|
67
|
+
component** — and because it happens inside a plugin, Nuxt's error boundary replaces the
|
|
68
|
+
whole app with its 500 page rather than surfacing a broken feature. Resolve translations
|
|
69
|
+
through the Nuxt app's global i18n instance instead (`useNuxtApp().$i18n`, typed as
|
|
70
|
+
`ReturnType<typeof useI18n>`), as `stores/board.ts`, `stores/recurringPipelines.ts` and
|
|
71
|
+
`composables/usePipelineErrorToast.ts` do. This costs no typed-message-key coverage: tier 1
|
|
72
|
+
only sees literal keys written in a `<script setup>`, never in a `.ts` store or composable.
|
|
73
|
+
|
|
74
|
+
The blast radius is why this is a rule rather than a preference — a store reached one call
|
|
75
|
+
earlier than before takes the entire SPA down at boot, and the unit suite cannot see it
|
|
76
|
+
(nothing there installs the plugin). Every e2e spec does, because every one of them boots
|
|
77
|
+
the app.
|
|
78
|
+
|
|
58
79
|
### Always import a layer component explicitly
|
|
59
80
|
|
|
60
81
|
**Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
|
|
@@ -184,13 +205,58 @@ congratulating the user on a walkthrough they did not see — and a tour that co
|
|
|
184
205
|
be abridged should not be offered at all, which is what each tour's `when(gates)` is for (the
|
|
185
206
|
task-creation tour requires board write AND a service frame to add a task to).
|
|
186
207
|
|
|
208
|
+
**A step carries its own `when(gates)` when its BRANCH, not its control, is the thing that
|
|
209
|
+
may not apply.** The two are different facts and only one of them is a defect: a skip means
|
|
210
|
+
the control should be here and isn't, while a `when` means this board is not on that branch
|
|
211
|
+
of the flow (a run parked on a decision has no approval gate, and the reverse). Reporting the
|
|
212
|
+
second as an abridged tour would tell a user who saw exactly the right walkthrough that they
|
|
213
|
+
missed half of it, every time. `resolveTours` (in `utils/tutorial.ts`, applied by
|
|
214
|
+
`navSlotFilter`) drops the rejected steps and then drops a tour left with none, so a tour
|
|
215
|
+
whose every step is branch-specific can never open on an empty cursor. With no gates service
|
|
216
|
+
wired at all (a bare install withholds nothing) every branch survives instead, and only one
|
|
217
|
+
of them can anchor — so the abridged notice ignores any skipped step that carries a `when`,
|
|
218
|
+
which has already declared that not applying is legitimate.
|
|
219
|
+
|
|
220
|
+
**Gates decide what is OFFERED; the running tour's script is resolved once and HELD.** The
|
|
221
|
+
overlay snapshots its tour when it starts rather than re-reading the gated slot on every
|
|
222
|
+
flip. This is not an optimisation: gates over live run state flip as a direct result of
|
|
223
|
+
following the tour — `answer-park` is offered while something waits for a human, so the
|
|
224
|
+
moment the user answers, its `when` goes false. A re-reading overlay tore itself down there,
|
|
225
|
+
one step short of its own finish card and with nothing recorded as completed, at exactly the
|
|
226
|
+
moment the user succeeded. Holding the script also freezes the branch `resolveTours` chose,
|
|
227
|
+
so a step can't be swapped underneath a stationary cursor.
|
|
228
|
+
|
|
229
|
+
**Gates must mean what the board RENDERS, not what the store holds.** `boardHasOpenDecision`
|
|
230
|
+
/ `boardHasPendingApproval` are not the store's raw pending counts: a park on a frame block
|
|
231
|
+
has no task card, and a reviewer gate mid-cycle is deliberately suppressed by the card
|
|
232
|
+
(`useReviewStage().isBackground`), so either would offer a tour onto a control that isn't
|
|
233
|
+
there. `hasActionablePark` (`modular/nav-gates.logic.ts`) is the shared rule; the run gates
|
|
234
|
+
are task-scoped for the same reason.
|
|
235
|
+
|
|
236
|
+
**Fixed proper nouns ride `bodyParams`, not the catalogs.** A step naming the sample
|
|
237
|
+
repository slug (`SAMPLE_REPO` in `modular/tutorial-tours.ts`) passes it as a `{repo}`
|
|
238
|
+
interpolation, so it is written once in code rather than translated into ten catalogs that
|
|
239
|
+
each drift on their own — the same split components make for inline placeholders.
|
|
240
|
+
|
|
241
|
+
The built-ins walk the delivery loop end to end, each gated on the state the previous one
|
|
242
|
+
leaves behind, so the launch prompt only ever offers what this board can demonstrate: board
|
|
243
|
+
basics, add a repository (`add-service`), create a task (`first-task`), run it (`run-task`),
|
|
244
|
+
answer it when it parks (`answer-park`), review and merge the result (`review-merge`). One
|
|
245
|
+
deliberate asymmetry: `run-task` points at Start without click-to-advance, because starting a
|
|
246
|
+
run spends real model budget and nobody should discover they agreed to that by following a
|
|
247
|
+
tutorial.
|
|
248
|
+
|
|
187
249
|
Two runtime constraints worth knowing before changing the overlay: it must keep
|
|
188
250
|
`pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
|
|
189
251
|
dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
|
|
190
252
|
pointerdown — without both, the tooltip's own buttons go inert and pressing one closes the
|
|
191
253
|
user's half-filled form. And everything that DECIDES (skip direction, wait budget,
|
|
192
|
-
target-click matching
|
|
193
|
-
unit-tested; the SFC keeps only the
|
|
254
|
+
target-click matching, which skips count as abridged) lives in
|
|
255
|
+
`components/tutorial/TutorialOverlay.logic.ts` so it is unit-tested; the SFC keeps only the
|
|
256
|
+
DOM work. Note that target-click matching is by SELECTOR, not by the highlighted element:
|
|
257
|
+
several anchors (`task-card`, `task-resolve`, `run-step`) render once per board item and the
|
|
258
|
+
ring can only sit on one of them, so requiring the click to land on that one left a user who
|
|
259
|
+
clicked the card the copy asked for with no way forward — such a step renders no Next.
|
|
194
260
|
|
|
195
261
|
The catalog is the `tutorialTours` slot: first-party tours live in
|
|
196
262
|
`modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
|
|
@@ -380,7 +380,11 @@ function done() {
|
|
|
380
380
|
:description="t('github.addService.repositoryHint')"
|
|
381
381
|
required
|
|
382
382
|
>
|
|
383
|
-
|
|
383
|
+
<!-- The wrapper, not the UInputMenu itself, carries the anchor: a tutorial tour
|
|
384
|
+
stop needs an element that is present the moment the modal mounts, and it
|
|
385
|
+
highlights the whole field rather than whichever inner node Nuxt UI happens
|
|
386
|
+
to forward the attribute to. -->
|
|
387
|
+
<div class="space-y-1.5" data-testid="add-service-repo-search">
|
|
384
388
|
<UInputMenu
|
|
385
389
|
v-model="selectedRepoId"
|
|
386
390
|
v-model:search-term="repoSearch"
|
|
@@ -553,6 +557,7 @@ function done() {
|
|
|
553
557
|
icon="i-lucide-plus"
|
|
554
558
|
:loading="adding"
|
|
555
559
|
:disabled="!canAdd"
|
|
560
|
+
data-testid="add-service-submit"
|
|
556
561
|
@click="add"
|
|
557
562
|
>
|
|
558
563
|
{{ t('github.addService.add') }}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
resolveSkip,
|
|
6
6
|
stepTargetIds,
|
|
7
7
|
stepTargetSelectors,
|
|
8
|
-
|
|
8
|
+
unexpectedlySkippedSteps,
|
|
9
9
|
waitBudgetMs,
|
|
10
10
|
} from '~/components/tutorial/TutorialOverlay.logic'
|
|
11
11
|
import { DEFAULT_TARGET_WAIT_MS } from '~/utils/tutorial'
|
|
@@ -99,28 +99,66 @@ describe('resolveSkip', () => {
|
|
|
99
99
|
})
|
|
100
100
|
|
|
101
101
|
describe('isTargetClickAdvance', () => {
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
/** A control carrying `id`, with a nested span, mounted so `closest` can walk to it. */
|
|
103
|
+
const control = (id: string) => {
|
|
104
|
+
const el = document.createElement('button')
|
|
105
|
+
el.setAttribute('data-testid', id)
|
|
106
|
+
el.appendChild(document.createElement('span'))
|
|
107
|
+
document.body.appendChild(el)
|
|
108
|
+
return el
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it('advances on a real click on the control, or inside it', () => {
|
|
112
|
+
const el = control('add-task-submit')
|
|
107
113
|
const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
|
|
108
|
-
expect(isTargetClickAdvance(s, el
|
|
109
|
-
expect(isTargetClickAdvance(s, el
|
|
114
|
+
expect(isTargetClickAdvance(s, el)).toBe(true)
|
|
115
|
+
expect(isTargetClickAdvance(s, el.firstChild)).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('advances on ANY instance of a control the board renders per item', () => {
|
|
119
|
+
// `task-card` / `task-resolve` / `run-step` exist once per board item, and the ring can
|
|
120
|
+
// only sit on one of them. Requiring the click to land on THAT one left a user who
|
|
121
|
+
// clicked the card the step's copy asked for with no way forward — a click-to-advance
|
|
122
|
+
// step renders no Next button.
|
|
123
|
+
control('task-card')
|
|
124
|
+
const second = control('task-card')
|
|
125
|
+
const s = step({ target: 'task-card', advanceOn: 'target-click' })
|
|
126
|
+
expect(isTargetClickAdvance(s, second)).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('advances on a fallback anchor too, since either is the control the step named', () => {
|
|
130
|
+
const s = step({ target: 'ui-mode-switcher', altTargets: ['ui-mode-toggle'] })
|
|
131
|
+
expect(
|
|
132
|
+
isTargetClickAdvance({ ...s, advanceOn: 'target-click' }, control('ui-mode-toggle')),
|
|
133
|
+
).toBe(true)
|
|
110
134
|
})
|
|
111
135
|
|
|
112
|
-
it('ignores clicks elsewhere, on a Next-advanced step, or
|
|
136
|
+
it('ignores clicks elsewhere, on a Next-advanced step, or on a non-element', () => {
|
|
113
137
|
const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
|
|
114
|
-
expect(isTargetClickAdvance(s,
|
|
115
|
-
expect(isTargetClickAdvance(s, null
|
|
116
|
-
expect(isTargetClickAdvance(
|
|
117
|
-
expect(isTargetClickAdvance(
|
|
138
|
+
expect(isTargetClickAdvance(s, control('run-start'))).toBe(false)
|
|
139
|
+
expect(isTargetClickAdvance(s, null)).toBe(false)
|
|
140
|
+
expect(isTargetClickAdvance(s, document.createTextNode('stray'))).toBe(false)
|
|
141
|
+
expect(isTargetClickAdvance(step({ target: 'add-task-submit' }), control('x'))).toBe(false)
|
|
142
|
+
expect(isTargetClickAdvance(null, control('y'))).toBe(false)
|
|
118
143
|
})
|
|
119
144
|
})
|
|
120
145
|
|
|
121
|
-
describe('
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
146
|
+
describe('unexpectedlySkippedSteps', () => {
|
|
147
|
+
const plain = step({ id: 'addTask', target: 'frame-add-task' })
|
|
148
|
+
const branch = step({ id: 'approve', target: 'step-approve', when: () => true })
|
|
149
|
+
|
|
150
|
+
it('counts a skipped step whose control simply was not there', () => {
|
|
151
|
+
expect(unexpectedlySkippedSteps(new Set(['addTask']), [plain, branch])).toEqual([plain])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does not count a branch-gated step, whose absence it already declared legitimate', () => {
|
|
155
|
+
// The gates-absent case (a bare install withholds nothing, so BOTH branches of a tour
|
|
156
|
+
// are kept and only one can ever anchor). Reporting that as abridged would put a
|
|
157
|
+
// permanent "you missed some of this" on a tour that showed exactly the right branch.
|
|
158
|
+
expect(unexpectedlySkippedSteps(new Set(['approve']), [plain, branch])).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('is empty for a tour that skipped nothing', () => {
|
|
162
|
+
expect(unexpectedlySkippedSteps(new Set(), [plain, branch])).toEqual([])
|
|
125
163
|
})
|
|
126
164
|
})
|
|
@@ -71,22 +71,55 @@ export function resolveSkip(
|
|
|
71
71
|
return index + 1 < total ? { kind: 'move', index: index + 1 } : { kind: 'complete' }
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/** The part of a clicked node this check needs: CSS-selector ancestry. */
|
|
75
|
+
interface ClickedNode {
|
|
76
|
+
closest(selector: string): unknown
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The clicked node as something we can ask about ancestry, or null (a text node, `document`). */
|
|
80
|
+
function asClickedNode(eventTarget: EventTarget | null): ClickedNode | null {
|
|
81
|
+
const node = eventTarget as ClickedNode | null
|
|
82
|
+
return node && typeof node.closest === 'function' ? node : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Does this real click count as the "now click this" step's advance?
|
|
87
|
+
*
|
|
88
|
+
* Matched against the step's SELECTORS rather than against the one element the tracker
|
|
89
|
+
* happened to highlight, because several of the controls a tour points at are rendered once
|
|
90
|
+
* PER BOARD ITEM: `task-card`, `task-resolve`, `run-step`. The tracker anchors its ring to
|
|
91
|
+
* the first match in the DOM, which is not necessarily the card the step's own copy is
|
|
92
|
+
* asking for ("open a task whose run has finished"). Requiring the click to land inside THAT
|
|
93
|
+
* element meant a user who clicked the right card got no advance at all — and a
|
|
94
|
+
* click-to-advance step renders no Next button, so the tour had no way forward but Skip.
|
|
95
|
+
* Any instance of the control the step names is the action the step asked for.
|
|
96
|
+
*/
|
|
75
97
|
export function isTargetClickAdvance(
|
|
76
98
|
step: TutorialStep | null,
|
|
77
|
-
targetEl: { contains: (node: Node) => boolean } | null,
|
|
78
99
|
eventTarget: EventTarget | null,
|
|
79
100
|
): boolean {
|
|
80
|
-
if (!step || step.advanceOn !== 'target-click'
|
|
81
|
-
|
|
101
|
+
if (!step || step.advanceOn !== 'target-click') return false
|
|
102
|
+
const node = asClickedNode(eventTarget)
|
|
103
|
+
if (!node) return false
|
|
104
|
+
return stepTargetSelectors(step).some((selector) => node.closest(selector) != null)
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
/**
|
|
85
|
-
*
|
|
86
|
-
* steps point at aren't part of this board/role/
|
|
87
|
-
*
|
|
88
|
-
*
|
|
108
|
+
* The skipped steps whose absence is a DEFECT — which is what the final card's "abridged"
|
|
109
|
+
* notice is about: the controls those steps point at aren't part of this board/role/
|
|
110
|
+
* deployment, so saying nothing would congratulate the user on a walkthrough they never saw.
|
|
111
|
+
*
|
|
112
|
+
* A step carrying a `when` is excluded, because it has already declared that not applying is
|
|
113
|
+
* a legitimate state rather than a missing control. Normally such a step is DROPPED before
|
|
114
|
+
* the tour runs (see `resolveTours`), so it never reaches the skip path at all — but with no
|
|
115
|
+
* gates service wired (a bare install, where nothing is withheld) every branch of a tour is
|
|
116
|
+
* kept, and exactly one of them can ever anchor. Counting the other as abridged would put a
|
|
117
|
+
* permanent "you missed some of this" on a tour that showed the user precisely the branch
|
|
118
|
+
* their board is on.
|
|
89
119
|
*/
|
|
90
|
-
export function
|
|
91
|
-
|
|
120
|
+
export function unexpectedlySkippedSteps(
|
|
121
|
+
skippedStepIds: ReadonlySet<string>,
|
|
122
|
+
steps: readonly TutorialStep[],
|
|
123
|
+
): TutorialStep[] {
|
|
124
|
+
return steps.filter((s) => skippedStepIds.has(s.id) && s.when === undefined)
|
|
92
125
|
}
|
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
DEFAULT_TARGET_WAIT_MS,
|
|
5
5
|
TARGET_TRACK_INTERVAL_MS,
|
|
6
6
|
} from '~/utils/tutorial'
|
|
7
|
-
import type { CoachMarkLayout, TutorialRect, TutorialStep } from '~/utils/tutorial'
|
|
7
|
+
import type { CoachMarkLayout, TutorialRect, TutorialStep, TutorialTour } from '~/utils/tutorial'
|
|
8
8
|
import {
|
|
9
9
|
isTargetClickAdvance,
|
|
10
10
|
resolveSkip,
|
|
11
11
|
stepTargetSelectors,
|
|
12
|
-
|
|
12
|
+
unexpectedlySkippedSteps,
|
|
13
13
|
waitBudgetMs,
|
|
14
14
|
} from './TutorialOverlay.logic'
|
|
15
15
|
import type { TutorialDirection } from './TutorialOverlay.logic'
|
|
@@ -29,13 +29,38 @@ const { t } = useI18n()
|
|
|
29
29
|
const tutorial = useTutorialStore()
|
|
30
30
|
const { tours } = useTutorialTours()
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The running tour's script, resolved ONCE from the slot when the tour starts and then HELD
|
|
34
|
+
* for its duration. Gates decide what is OFFERED; they do not get to rewrite a walkthrough
|
|
35
|
+
* that is already under way.
|
|
36
|
+
*
|
|
37
|
+
* Re-reading the gated slot on every flip was fine while the gates were slow-moving facts
|
|
38
|
+
* (a permission, a connection). Gates over live RUN state flip as a DIRECT RESULT of
|
|
39
|
+
* following the tour: `answer-park` is offered while something is waiting for a human, so
|
|
40
|
+
* the moment the user answered — the very thing the tour teaches — its `when` went false,
|
|
41
|
+
* the slot dropped the tour, and the watch below tore the overlay down one step short of its
|
|
42
|
+
* own finish card, with no completion recorded. Holding the script also freezes the branch
|
|
43
|
+
* `resolveTours` picked, so a step can't be swapped underneath a stationary cursor when a
|
|
44
|
+
* board that had both a decision and an approval loses one of them mid-tour.
|
|
45
|
+
*/
|
|
46
|
+
const tour = shallowRef<TutorialTour | null>(null)
|
|
47
|
+
watch(
|
|
48
|
+
() => tutorial.activeTourId,
|
|
49
|
+
(id) => {
|
|
50
|
+
// Read untracked (a watch callback registers no dependencies), which is what pins the
|
|
51
|
+
// script: only starting a DIFFERENT tour re-resolves it.
|
|
52
|
+
tour.value = id ? (tours.value.find((x) => x.id === id) ?? null) : null
|
|
53
|
+
},
|
|
54
|
+
{ immediate: true },
|
|
55
|
+
)
|
|
56
|
+
|
|
33
57
|
const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.stepIndex] ?? null)
|
|
34
58
|
const total = computed(() => tour.value?.steps.length ?? 0)
|
|
35
59
|
const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
|
|
36
60
|
|
|
37
|
-
// The
|
|
38
|
-
// or the cursor ran past the end: end
|
|
61
|
+
// The tour could not be resolved when it started (a stale persisted id, or a tour this board
|
|
62
|
+
// is not offered at all) or the cursor ran past the end: end it instead of rendering a dead
|
|
63
|
+
// overlay. Since the script is held, this can no longer fire because a gate flipped mid-tour.
|
|
39
64
|
watch(
|
|
40
65
|
() => [tour.value, step.value] as const,
|
|
41
66
|
([tr, st]) => {
|
|
@@ -44,7 +69,6 @@ watch(
|
|
|
44
69
|
{ immediate: true },
|
|
45
70
|
)
|
|
46
71
|
|
|
47
|
-
const targetEl = ref<HTMLElement | null>(null)
|
|
48
72
|
const targetRect = ref<TutorialRect | null>(null)
|
|
49
73
|
const cardEl = ref<HTMLElement | null>(null)
|
|
50
74
|
const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
|
|
@@ -62,7 +86,11 @@ const skippedStepIds = ref<Set<string>>(new Set())
|
|
|
62
86
|
|
|
63
87
|
/** A targeted step whose anchor hasn't been found yet (renders the waiting note). */
|
|
64
88
|
const searching = computed(() => step.value?.target !== undefined && targetRect.value === null)
|
|
65
|
-
|
|
89
|
+
/** The skips the final card must own up to — a branch-gated step's absence is not one. */
|
|
90
|
+
const unexpectedSkips = computed(() =>
|
|
91
|
+
unexpectedlySkippedSteps(skippedStepIds.value, tour.value?.steps ?? []),
|
|
92
|
+
)
|
|
93
|
+
const abridged = computed(() => unexpectedSkips.value.length > 0)
|
|
66
94
|
|
|
67
95
|
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
68
96
|
/** The tooltip's own size, or a sensible guess before it has rendered once. */
|
|
@@ -93,11 +121,9 @@ function measure() {
|
|
|
93
121
|
const s = step.value
|
|
94
122
|
if (!s) return
|
|
95
123
|
if (!s.target) {
|
|
96
|
-
targetEl.value = null
|
|
97
124
|
targetRect.value = null
|
|
98
125
|
} else {
|
|
99
126
|
const el = queryTarget(s)
|
|
100
|
-
targetEl.value = el
|
|
101
127
|
if (!el) {
|
|
102
128
|
targetRect.value = null
|
|
103
129
|
// Centered while searching: the card must not sit at the PREVIOUS step's anchor —
|
|
@@ -122,7 +148,6 @@ function measure() {
|
|
|
122
148
|
// the card has re-rendered its new copy (its size feeds the layout).
|
|
123
149
|
watch(step, async (s) => {
|
|
124
150
|
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
125
|
-
targetEl.value = null
|
|
126
151
|
targetRect.value = null
|
|
127
152
|
await nextTick()
|
|
128
153
|
measure()
|
|
@@ -143,7 +168,7 @@ function back() {
|
|
|
143
168
|
// control can't hide them) and follow along AFTER the app has reacted — the deferral lets
|
|
144
169
|
// the real handler open its modal/submit its form before the tour moves its anchor.
|
|
145
170
|
function onDocumentClick(event: MouseEvent) {
|
|
146
|
-
if (isTargetClickAdvance(step.value,
|
|
171
|
+
if (isTargetClickAdvance(step.value, event.target)) {
|
|
147
172
|
window.setTimeout(advance, 0)
|
|
148
173
|
}
|
|
149
174
|
}
|
|
@@ -208,7 +233,10 @@ onUnmounted(() => {
|
|
|
208
233
|
{{ t('tutorial.overlay.progress', { current: tutorial.stepIndex + 1, total }) }}
|
|
209
234
|
</span>
|
|
210
235
|
</div>
|
|
211
|
-
|
|
236
|
+
<!-- `bodyParams` carries the fixed proper nouns a step names (a repository slug),
|
|
237
|
+
which live in the catalog's `{named}` placeholders rather than in nine
|
|
238
|
+
translations of the same literal. Absent for most steps. -->
|
|
239
|
+
<p class="text-sm text-slate-300">{{ t(step.bodyKey, step.bodyParams ?? {}) }}</p>
|
|
212
240
|
<p
|
|
213
241
|
v-if="searching"
|
|
214
242
|
class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
|
|
@@ -231,7 +259,13 @@ onUnmounted(() => {
|
|
|
231
259
|
class="mt-2 text-xs text-amber-300/90"
|
|
232
260
|
data-testid="tutorial-abridged"
|
|
233
261
|
>
|
|
234
|
-
{{
|
|
262
|
+
{{
|
|
263
|
+
t(
|
|
264
|
+
'tutorial.overlay.abridged',
|
|
265
|
+
{ count: unexpectedSkips.length },
|
|
266
|
+
unexpectedSkips.length,
|
|
267
|
+
)
|
|
268
|
+
}}
|
|
235
269
|
</p>
|
|
236
270
|
<div class="mt-3 flex items-center justify-between gap-2">
|
|
237
271
|
<UButton
|
|
@@ -49,7 +49,10 @@ beforeEach(() => {
|
|
|
49
49
|
}
|
|
50
50
|
vi.stubGlobal('useToast', () => ({ add, update }))
|
|
51
51
|
vi.stubGlobal('useUiStore', () => ui)
|
|
52
|
-
|
|
52
|
+
// Stubbed on the Nuxt app's GLOBAL i18n instance, which is what this composable resolves
|
|
53
|
+
// (never `useI18n()`): it is called from store setup, where no component instance exists.
|
|
54
|
+
// See `frontend/app/README.md` — "A store must be instantiable outside a component setup".
|
|
55
|
+
vi.stubGlobal('useNuxtApp', () => ({ $i18n: { t, te: (key: string) => hasKey(key) } }))
|
|
53
56
|
})
|
|
54
57
|
|
|
55
58
|
function conflict(reason?: string, details: Record<string, unknown> = {}, message?: string) {
|
|
@@ -331,7 +331,24 @@ export function describeGenericFailure(error: unknown): GenericFailure {
|
|
|
331
331
|
export function usePipelineErrorToast() {
|
|
332
332
|
const toast = useToast()
|
|
333
333
|
const ui = useUiStore()
|
|
334
|
-
|
|
334
|
+
// Resolved through the Nuxt app's global i18n instance rather than `useI18n()`, which
|
|
335
|
+
// requires an active component instance — the same pattern (and the same reason) as the
|
|
336
|
+
// board / recurring-pipelines stores.
|
|
337
|
+
//
|
|
338
|
+
// This composable is called from STORE SETUP (`stores/execution.ts`, `stores/agentRuns.ts`),
|
|
339
|
+
// and a Pinia setup store runs its body on the FIRST `useStore()` anywhere. That used to be
|
|
340
|
+
// a component, so `useI18n()` happened to be legal; the moment anything instantiated one of
|
|
341
|
+
// those stores earlier — `createNavGates()` does, from the `enforce: 'post'` modular plugin —
|
|
342
|
+
// vue-i18n threw `MUST_BE_CALL_SETUP_TOP`, the plugin threw, and Nuxt's error boundary
|
|
343
|
+
// replaced the entire app with its 500 page. Every single e2e spec failed on a blank board.
|
|
344
|
+
// A store must be instantiable outside a component, so the i18n handle it reaches for has
|
|
345
|
+
// to be too.
|
|
346
|
+
//
|
|
347
|
+
// Typed as `useI18n`'s own return (`$i18n` IS that global Composer in composition mode), so
|
|
348
|
+
// `t`/`te` keep their real signatures. No typed-message-key coverage is lost by the switch:
|
|
349
|
+
// tier 1 only sees literal keys written in a `<script setup>`, never in a `.ts` composable —
|
|
350
|
+
// the drift guard here is the exhaustive `CONFLICT_INFO` / `ApiErrorCode` records above.
|
|
351
|
+
const { t, te } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
335
352
|
|
|
336
353
|
// The five bespoke conflict reasons (a runtime-interpolated body + a "configure X" jump each)
|
|
337
354
|
// live in a sibling factory over the same toast/ui/i18n handles, so this composable stays
|
|
@@ -26,6 +26,11 @@ const NO_GATES: NavGates = {
|
|
|
26
26
|
// so a dropped item is unambiguously an RBAC/availability drop, not a tier drop.
|
|
27
27
|
advancedMode: true,
|
|
28
28
|
boardHasService: false,
|
|
29
|
+
boardHasTask: false,
|
|
30
|
+
boardHasRun: false,
|
|
31
|
+
boardHasOpenDecision: false,
|
|
32
|
+
boardHasPendingApproval: false,
|
|
33
|
+
boardHasFinishedRun: false,
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
const ALL_GATES: NavGates = {
|
|
@@ -39,6 +44,11 @@ const ALL_GATES: NavGates = {
|
|
|
39
44
|
isAccountAdmin: true,
|
|
40
45
|
advancedMode: true,
|
|
41
46
|
boardHasService: true,
|
|
47
|
+
boardHasTask: true,
|
|
48
|
+
boardHasRun: true,
|
|
49
|
+
boardHasOpenDecision: true,
|
|
50
|
+
boardHasPendingApproval: true,
|
|
51
|
+
boardHasFinishedRun: true,
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
const slots = (): AppSlots => ({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineModule } from '@modular-vue/core'
|
|
2
|
+
import { resolveTours } from '~/utils/tutorial'
|
|
2
3
|
import type { AppSlots } from './slots'
|
|
3
4
|
|
|
4
5
|
// Re-exported for the slice-1 importers that reach `AppSlots` through this
|
|
@@ -89,6 +90,41 @@ export interface NavGates {
|
|
|
89
90
|
* service lands on the board.
|
|
90
91
|
*/
|
|
91
92
|
boardHasService: boolean
|
|
93
|
+
/**
|
|
94
|
+
* The open board has at least one task block. The service frame that {@link boardHasService}
|
|
95
|
+
* reports is where a task GOES; this is whether one is actually there to run, which is what
|
|
96
|
+
* a tour about the run controls needs — they live in the inspector of a task, and a board of
|
|
97
|
+
* empty frames offers nothing to open.
|
|
98
|
+
*/
|
|
99
|
+
boardHasTask: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Some run on a TASK block is cached for this board, in any state. Availability for the
|
|
102
|
+
* steps that explain a run's ANATOMY (the step list, its live progress), which have nothing
|
|
103
|
+
* to anchor to until a run has been started at least once.
|
|
104
|
+
*
|
|
105
|
+
* Task-scoped, like the three below: every surface these gates open is reached through a
|
|
106
|
+
* task card and its inspector, and a frame-level run (a blueprint pass, an initiative plan)
|
|
107
|
+
* renders none of them.
|
|
108
|
+
*/
|
|
109
|
+
boardHasRun: boolean
|
|
110
|
+
/**
|
|
111
|
+
* Some task card is offering a human an unanswered DECISION to resolve.
|
|
112
|
+
*
|
|
113
|
+
* "Offering" rather than "exists": these two report what the board actually RENDERS, which
|
|
114
|
+
* is what a tour anchoring on the card's action can point at — see `hasActionablePark` for
|
|
115
|
+
* the two ways a park can exist with no control to show for it.
|
|
116
|
+
*/
|
|
117
|
+
boardHasOpenDecision: boolean
|
|
118
|
+
/** Some task card is offering a human an unanswered APPROVAL gate. See above. */
|
|
119
|
+
boardHasPendingApproval: boolean
|
|
120
|
+
/**
|
|
121
|
+
* Some run on a task block has finished successfully. Availability for the review/merge
|
|
122
|
+
* tour: its subject is the OUTPUT of a run, so there has to be one that produced output. A
|
|
123
|
+
* FAILED run deliberately does not count — the failure banner is its own surface, and a
|
|
124
|
+
* tour about reading a result and merging it would spend its steps pointing at controls a
|
|
125
|
+
* failed run never renders.
|
|
126
|
+
*/
|
|
127
|
+
boardHasFinishedRun: boolean
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
/** Command-palette placement + copy for a contribution that appears in the palette. */
|
|
@@ -542,11 +578,10 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
542
578
|
)
|
|
543
579
|
: nav,
|
|
544
580
|
// Tutorial tours gate over the same reactive service, so a tour about a surface the
|
|
545
|
-
// caller can't reach (e.g. creating tasks without board write) never shows
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
: tutorialTours,
|
|
581
|
+
// caller can't reach (e.g. creating tasks without board write) never shows, and a step
|
|
582
|
+
// about a branch this board isn't on is dropped rather than skipped (see `resolveTours`).
|
|
583
|
+
// Same gates-absent pass-through as `nav`.
|
|
584
|
+
tutorialTours: gates ? resolveTours(tutorialTours, gates) : tutorialTours,
|
|
550
585
|
}
|
|
551
586
|
}
|
|
552
587
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { hasActionablePark } from '~/modular/nav-gates.logic'
|
|
3
|
+
import type { ParkedGateRef } from '~/modular/nav-gates.logic'
|
|
4
|
+
|
|
5
|
+
const onlyTasks = (id: string) => id.startsWith('task_')
|
|
6
|
+
const nothingIsBackground = () => false
|
|
7
|
+
|
|
8
|
+
describe('hasActionablePark', () => {
|
|
9
|
+
it('reports a park a task card really shows an action for', () => {
|
|
10
|
+
const parks: ParkedGateRef[] = [{ blockId: 'task_login', agentKind: 'architect' }]
|
|
11
|
+
expect(hasActionablePark(parks, onlyTasks, nothingIsBackground)).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('ignores a park on a block that renders no task card', () => {
|
|
15
|
+
// A frame/module run parks too, but the affordance the tour anchors on is TaskCard's.
|
|
16
|
+
const parks: ParkedGateRef[] = [{ blockId: 'frame_billing', agentKind: 'blueprints' }]
|
|
17
|
+
expect(hasActionablePark(parks, onlyTasks, nothingIsBackground)).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('ignores a reviewer gate the card suppresses as background work', () => {
|
|
21
|
+
// Mirrors `TaskCard.pendingApproval`: while the review is folding answers / re-reviewing
|
|
22
|
+
// it needs no human, so no Resolve button exists to point a tour at.
|
|
23
|
+
const parks: ParkedGateRef[] = [{ blockId: 'task_login', agentKind: 'requirements-review' }]
|
|
24
|
+
const isBackground = (kind: string | undefined) => kind === 'requirements-review'
|
|
25
|
+
expect(hasActionablePark(parks, onlyTasks, isBackground)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reports the actionable one when a board holds both kinds at once', () => {
|
|
29
|
+
const parks: ParkedGateRef[] = [
|
|
30
|
+
{ blockId: 'frame_billing', agentKind: 'architect' },
|
|
31
|
+
{ blockId: 'task_login', agentKind: 'requirements-review' },
|
|
32
|
+
{ blockId: 'task_signup', agentKind: 'coder' },
|
|
33
|
+
]
|
|
34
|
+
const isBackground = (kind: string | undefined) => kind === 'requirements-review'
|
|
35
|
+
expect(hasActionablePark(parks, onlyTasks, isBackground)).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('is false for a board with nothing parked at all', () => {
|
|
39
|
+
expect(hasActionablePark([], onlyTasks, nothingIsBackground)).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions the `NavGates` service makes that are worth testing without a Pinia runtime.
|
|
3
|
+
* `nav-gates.ts` itself is pure store wiring (getters over computeds); everything that
|
|
4
|
+
* DECIDES something lives here, the same split as `TutorialOverlay.logic.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One park (an open decision or a pending approval gate) as the execution store projects it. */
|
|
8
|
+
export interface ParkedGateRef {
|
|
9
|
+
blockId: string
|
|
10
|
+
agentKind?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Is some park actually SHOWING a human an action to take?
|
|
15
|
+
*
|
|
16
|
+
* The gates this answers (`boardHasOpenDecision` / `boardHasPendingApproval`) exist to offer
|
|
17
|
+
* a tour that anchors on a task card's attention affordance (`task-resolve`), so they have to
|
|
18
|
+
* mean what makes that affordance RENDER — not merely "a park exists somewhere in the cached
|
|
19
|
+
* runs". Two facts the raw store counts miss, both of which would offer the tour onto a board
|
|
20
|
+
* with no control to point at (the tour then anchor-skips and reports itself abridged, which
|
|
21
|
+
* is exactly the noise per-step `when` gating exists to avoid):
|
|
22
|
+
*
|
|
23
|
+
* - a park on a frame or module block has no task card, so nothing renders the action;
|
|
24
|
+
* - a reviewer gate mid-cycle is deliberately SUPPRESSED by the card (`TaskCard.pendingApproval`
|
|
25
|
+
* → `useReviewStage().isBackground`): while the driver is folding answers or re-reviewing,
|
|
26
|
+
* the gate needs no human and the card shows a working indicator instead.
|
|
27
|
+
*
|
|
28
|
+
* Both predicates are injected rather than reached for, so the rule is checkable on plain data.
|
|
29
|
+
*/
|
|
30
|
+
export function hasActionablePark(
|
|
31
|
+
parks: readonly ParkedGateRef[],
|
|
32
|
+
isTaskBlock: (blockId: string) => boolean,
|
|
33
|
+
isBackground: (agentKind: string | undefined, blockId: string) => boolean,
|
|
34
|
+
): boolean {
|
|
35
|
+
return parks.some((p) => isTaskBlock(p.blockId) && !isBackground(p.agentKind, p.blockId))
|
|
36
|
+
}
|