@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.
- package/README.md +105 -0
- package/app/components/github/AddServiceFromRepoModal.vue +6 -1
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +164 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +125 -0
- package/app/components/tutorial/TutorialOverlay.vue +307 -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.spec.ts +4 -1
- package/app/composables/usePipelineErrorToast.ts +24 -165
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +14 -0
- package/app/modular/nav-contributions.ts +70 -0
- 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 +54 -0
- package/app/modular/registry.spec.ts +6 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +222 -0
- package/app/modular/tutorial-tours.ts +421 -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 +115 -0
- package/app/utils/tutorial.ts +246 -0
- package/i18n/locales/de.json +209 -2
- package/i18n/locales/en.json +215 -2
- package/i18n/locales/es.json +209 -2
- package/i18n/locales/fr.json +209 -2
- package/i18n/locales/he.json +209 -2
- package/i18n/locales/it.json +209 -2
- package/i18n/locales/ja.json +209 -2
- package/i18n/locales/pl.json +209 -2
- package/i18n/locales/tr.json +209 -2
- package/i18n/locales/uk.json +209 -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
|
|
|
@@ -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
|
-
|
|
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
|
+
}
|