@cat-factory/app 0.196.1 → 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.
Files changed (45) hide show
  1. package/README.md +105 -0
  2. package/app/components/github/AddServiceFromRepoModal.vue +6 -1
  3. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +164 -0
  4. package/app/components/tutorial/TutorialOverlay.logic.ts +125 -0
  5. package/app/components/tutorial/TutorialOverlay.vue +307 -0
  6. package/app/components/tutorial/TutorialPrompt.vue +102 -0
  7. package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
  8. package/app/composables/useNavContributions.ts +1 -0
  9. package/app/composables/usePipelineErrorToast.spec.ts +4 -1
  10. package/app/composables/usePipelineErrorToast.ts +24 -165
  11. package/app/composables/useTutorialTours.ts +18 -0
  12. package/app/modular/nav-contributions.spec.ts +14 -0
  13. package/app/modular/nav-contributions.ts +70 -0
  14. package/app/modular/nav-gates.logic.spec.ts +41 -0
  15. package/app/modular/nav-gates.logic.ts +36 -0
  16. package/app/modular/nav-gates.ts +54 -0
  17. package/app/modular/registry.spec.ts +6 -0
  18. package/app/modular/registry.ts +3 -1
  19. package/app/modular/slots.ts +7 -0
  20. package/app/modular/tutorial-tours.spec.ts +222 -0
  21. package/app/modular/tutorial-tours.ts +421 -0
  22. package/app/pages/index.vue +61 -0
  23. package/app/stores/board/dependencies.ts +52 -0
  24. package/app/stores/board/placement.ts +4 -37
  25. package/app/stores/execution/pendingGates.ts +109 -0
  26. package/app/stores/execution.ts +7 -94
  27. package/app/stores/requirements/recommendations.ts +77 -0
  28. package/app/stores/requirements.ts +17 -43
  29. package/app/stores/tutorial.spec.ts +135 -0
  30. package/app/stores/tutorial.ts +145 -0
  31. package/app/stores/workspace/commands.ts +77 -0
  32. package/app/stores/workspace.ts +11 -50
  33. package/app/utils/tutorial.spec.ts +115 -0
  34. package/app/utils/tutorial.ts +246 -0
  35. package/i18n/locales/de.json +209 -2
  36. package/i18n/locales/en.json +215 -2
  37. package/i18n/locales/es.json +209 -2
  38. package/i18n/locales/fr.json +209 -2
  39. package/i18n/locales/he.json +209 -2
  40. package/i18n/locales/it.json +209 -2
  41. package/i18n/locales/ja.json +209 -2
  42. package/i18n/locales/pl.json +209 -2
  43. package/i18n/locales/tr.json +209 -2
  44. package/i18n/locales/uk.json +209 -2
  45. 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
 
@@ -54,6 +55,27 @@ over the WebSocket. How that sync works is written up in
54
55
  | `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
55
56
  | `utils/` | Small pure helpers. |
56
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
+
57
79
  ### Always import a layer component explicitly
58
80
 
59
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.
@@ -159,6 +181,89 @@ picking what each of them runs on are halves of the same job.
159
181
  whatever the tier — the same rule `showOverrideField` states for a single field: a row the
160
182
  user can neither read nor clear is worse than a longer list.
161
183
 
184
+ ## In-app tutorial tours
185
+
186
+ On first launch (once the board is up and no other startup advisory is open) the app asks
187
+ whether the user wants a guided tour. The answer is SAVED per browser (`stores/tutorial.ts`,
188
+ persisted like the interface tier): "no thanks" stops the prompt for good, closing without
189
+ answering defers it to the next launch, and the command palette's "Take a tour" entry is the
190
+ way back either way.
191
+
192
+ A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
193
+ control by its `data-testid` (the e2e anchor vocabulary — cover a control that has none by
194
+ adding the test id first) and carrying i18n keys for its copy. One shared runtime
195
+ (`components/tutorial/TutorialOverlay.vue`) renders every tour: it highlights the current
196
+ step's control, places the tooltip (`utils/tutorial.ts` owns the pure geometry + types),
197
+ advances on Next or — for `advanceOn: 'target-click'` steps — on the user really clicking
198
+ the control, so the app's real response (the actual modal, the actual task) is what the next
199
+ step anchors to. `target-click` is for BUTTONS, where the click is the completed action; a
200
+ text field keeps Next, or the tooltip would leave the instruction the moment the user clicked
201
+ in to type. A step whose anchor never appears within its wait is SKIPPED, because controls
202
+ come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
203
+ fixed script. Reaching the end having skipped steps is reported on the final card rather than
204
+ congratulating the user on a walkthrough they did not see — and a tour that could only ever
205
+ be abridged should not be offered at all, which is what each tour's `when(gates)` is for (the
206
+ task-creation tour requires board write AND a service frame to add a task to).
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
+
249
+ Two runtime constraints worth knowing before changing the overlay: it must keep
250
+ `pointer-events-auto` and swallow `pointerdown`, because Nuxt UI modals are reka-ui
251
+ dismissable layers that set `body { pointer-events: none }` and dismiss on an outside
252
+ pointerdown — without both, the tooltip's own buttons go inert and pressing one closes the
253
+ user's half-filled form. And everything that DECIDES (skip direction, wait budget,
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.
260
+
261
+ The catalog is the `tutorialTours` slot: first-party tours live in
262
+ `modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
263
+ `registerAppModule` — they appear in the launch prompt beside the built-ins, gated per tour
264
+ by its `when(gates)` predicate (the same reactive gates service the nav uses, filtered in
265
+ `navSlotFilter`). Completion is persisted per tour id, so renaming an id resets its state.
266
+
162
267
  ## Extending the layer (consumer modules)
163
268
 
164
269
  A deployment can contribute its own components — result windows, nav entries, inspector
@@ -380,7 +380,11 @@ function done() {
380
380
  :description="t('github.addService.repositoryHint')"
381
381
  required
382
382
  >
383
- <div class="space-y-1.5">
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') }}
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ isSafeTargetId,
4
+ isTargetClickAdvance,
5
+ resolveSkip,
6
+ stepTargetIds,
7
+ stepTargetSelectors,
8
+ unexpectedlySkippedSteps,
9
+ waitBudgetMs,
10
+ } from '~/components/tutorial/TutorialOverlay.logic'
11
+ import { DEFAULT_TARGET_WAIT_MS } from '~/utils/tutorial'
12
+ import type { TutorialStep } from '~/utils/tutorial'
13
+
14
+ const step = (over: Partial<TutorialStep> = {}): TutorialStep => ({
15
+ id: 'a-step',
16
+ titleKey: 'tutorial.tours.x.steps.a.title',
17
+ bodyKey: 'tutorial.tours.x.steps.a.body',
18
+ ...over,
19
+ })
20
+
21
+ describe('stepTargetIds', () => {
22
+ it('lists the target then its fallbacks, in order', () => {
23
+ expect(
24
+ stepTargetIds(step({ target: 'frame-add-task', altTargets: ['frame-add-task-empty'] })),
25
+ ).toEqual(['frame-add-task', 'frame-add-task-empty'])
26
+ })
27
+
28
+ it('is empty for an untargeted (centered) step', () => {
29
+ expect(stepTargetIds(step())).toEqual([])
30
+ })
31
+ })
32
+
33
+ describe('isSafeTargetId', () => {
34
+ it('accepts the kebab-case ids the repo actually uses', () => {
35
+ for (const id of ['task-card', 'add-task-submit', 'board-fit-view', 'nav-tutorial', 'h2'])
36
+ expect(isSafeTargetId(id), id).toBe(true)
37
+ })
38
+
39
+ it('rejects anything that could end up inside a selector as syntax', () => {
40
+ for (const id of ['od"d', "od'd", 'back\\slash', 'a b', 'a]b', 'Upper', ''])
41
+ expect(isSafeTargetId(id), id).toBe(false)
42
+ })
43
+ })
44
+
45
+ describe('stepTargetSelectors', () => {
46
+ it('builds the data-testid selectors, target first then fallbacks', () => {
47
+ const s = step({ target: 'frame-add-task', altTargets: ['frame-add-task-empty'] })
48
+ expect(stepTargetSelectors(s)).toEqual([
49
+ '[data-testid="frame-add-task"]',
50
+ '[data-testid="frame-add-task-empty"]',
51
+ ])
52
+ })
53
+
54
+ it('drops a malformed id rather than building a selector that could throw', () => {
55
+ // A tour is DATA a consumer deployment authors, so an id arrives from outside this
56
+ // package. A bad one must degrade to "anchor not found" (a skipped step) — NOT to a
57
+ // SyntaxError raised out of the 150ms tracking interval, several times a second.
58
+ const s = step({ target: 'od"d', altTargets: ['task-card'] })
59
+ expect(stepTargetSelectors(s)).toEqual(['[data-testid="task-card"]'])
60
+ for (const selector of stepTargetSelectors(s))
61
+ expect(() => document.querySelector(selector)).not.toThrow()
62
+ })
63
+
64
+ it('is empty for an untargeted step', () => {
65
+ expect(stepTargetSelectors(step())).toEqual([])
66
+ })
67
+ })
68
+
69
+ describe('waitBudgetMs', () => {
70
+ it('defaults, and honours a step that waits on a just-opened modal', () => {
71
+ expect(waitBudgetMs(step())).toBe(DEFAULT_TARGET_WAIT_MS)
72
+ expect(waitBudgetMs(step({ waitForTargetMs: 8000 }))).toBe(8000)
73
+ })
74
+ })
75
+
76
+ describe('resolveSkip', () => {
77
+ it('continues forward past a missing anchor', () => {
78
+ expect(resolveSkip(1, 'forward', 4)).toEqual({ kind: 'move', index: 2 })
79
+ })
80
+
81
+ it('completes the tour when the last step is the one that went missing', () => {
82
+ expect(resolveSkip(3, 'forward', 4)).toEqual({ kind: 'complete' })
83
+ })
84
+
85
+ it('keeps travelling BACK when the user is stepping backwards', () => {
86
+ // Otherwise Back onto a step this deployment does not render bounces the user
87
+ // straight forward again, making the button unusable exactly where it is needed.
88
+ expect(resolveSkip(2, 'back', 4)).toEqual({ kind: 'move', index: 1 })
89
+ })
90
+
91
+ it('reverses to forward rather than pinning on the first step', () => {
92
+ expect(resolveSkip(0, 'back', 4)).toEqual({ kind: 'move', index: 1 })
93
+ })
94
+
95
+ it('completes a single-step tour whose only anchor never appeared', () => {
96
+ expect(resolveSkip(0, 'back', 1)).toEqual({ kind: 'complete' })
97
+ expect(resolveSkip(0, 'forward', 1)).toEqual({ kind: 'complete' })
98
+ })
99
+ })
100
+
101
+ describe('isTargetClickAdvance', () => {
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')
113
+ const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
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)
134
+ })
135
+
136
+ it('ignores clicks elsewhere, on a Next-advanced step, or on a non-element', () => {
137
+ const s = step({ target: 'add-task-submit', advanceOn: 'target-click' })
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)
143
+ })
144
+ })
145
+
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([])
163
+ })
164
+ })
@@ -0,0 +1,125 @@
1
+ import { DEFAULT_TARGET_WAIT_MS } from '~/utils/tutorial'
2
+ import type { TutorialStep } from '~/utils/tutorial'
3
+
4
+ /**
5
+ * The tour runtime's decision logic, extracted from `TutorialOverlay.vue` so it is
6
+ * unit-testable (the vitest setup has no SFC transform — same split as
7
+ * `AppOverlayHost.logic.ts`). The component keeps the DOM work (querying, measuring,
8
+ * listeners); everything that DECIDES something lives here.
9
+ */
10
+
11
+ /**
12
+ * Which way the step cursor is travelling. It matters only for a step whose anchor
13
+ * never appears: a skip must continue in the direction the user was already going,
14
+ * or pressing Back onto a step whose control this deployment doesn't render would
15
+ * bounce them straight forward again — an unusable Back button.
16
+ */
17
+ export type TutorialDirection = 'forward' | 'back'
18
+
19
+ /** What the overlay does with a step whose anchor never appeared. */
20
+ export type SkipOutcome = { kind: 'move'; index: number } | { kind: 'complete' }
21
+
22
+ /**
23
+ * What a `data-testid` may look like. Every one of the ~470 test ids in this layer is
24
+ * lowercase kebab-case, and the e2e suite's convention keeps it that way, so this rejects
25
+ * nothing real — which is what makes it usable as a GUARD rather than as escaping.
26
+ *
27
+ * A tour is DATA and a consumer deployment authors its own, so an id reaches
28
+ * `querySelector` from outside this package. Escaping it is the obvious move and the wrong
29
+ * one: `[data-testid="a\"b"]` is valid CSS that real selector engines disagree about (it
30
+ * throws in happy-dom), so an id with a quote could still take down the tracking interval —
31
+ * several times a second, for as long as the tour runs. Validating the shape instead means
32
+ * no selector is ever built from a string that could break one, and an id that fails simply
33
+ * finds no anchor, which the runtime already handles as a skipped step.
34
+ */
35
+ export const TARGET_ID_PATTERN = /^[a-z0-9-]+$/
36
+
37
+ /** Is this a well-formed anchor id, i.e. safe to put in a selector? */
38
+ export function isSafeTargetId(id: string): boolean {
39
+ return TARGET_ID_PATTERN.test(id)
40
+ }
41
+
42
+ /** The `data-testid`s a step declares, in priority order (target first, then fallbacks). */
43
+ export function stepTargetIds(step: TutorialStep): string[] {
44
+ return [step.target, ...(step.altTargets ?? [])].filter((id): id is string => id !== undefined)
45
+ }
46
+
47
+ /** The selectors to try for a step: its declared ids, minus any that are malformed. */
48
+ export function stepTargetSelectors(step: TutorialStep): string[] {
49
+ return stepTargetIds(step)
50
+ .filter(isSafeTargetId)
51
+ .map((id) => `[data-testid="${id}"]`)
52
+ }
53
+
54
+ /** How long this step may spend looking for its anchor before it is skipped. */
55
+ export function waitBudgetMs(step: TutorialStep): number {
56
+ return step.waitForTargetMs ?? DEFAULT_TARGET_WAIT_MS
57
+ }
58
+
59
+ /**
60
+ * Where the cursor goes when the current step's anchor never appeared.
61
+ *
62
+ * Travelling BACK off the first step has nowhere further back to go, so it reverses to
63
+ * forward rather than pinning the tour on an anchor that is never coming.
64
+ */
65
+ export function resolveSkip(
66
+ index: number,
67
+ direction: TutorialDirection,
68
+ total: number,
69
+ ): SkipOutcome {
70
+ if (direction === 'back' && index > 0) return { kind: 'move', index: index - 1 }
71
+ return index + 1 < total ? { kind: 'move', index: index + 1 } : { kind: 'complete' }
72
+ }
73
+
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
+ */
97
+ export function isTargetClickAdvance(
98
+ step: TutorialStep | null,
99
+ eventTarget: EventTarget | null,
100
+ ): boolean {
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)
105
+ }
106
+
107
+ /**
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.
119
+ */
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)
125
+ }