@cat-factory/app 0.201.1 → 0.202.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 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
- package/app/components/tutorial/TutorialOverlay.vue +287 -38
- package/app/components/tutorial/TutorialPrompt.vue +19 -7
- package/app/modular/tutorial-tours.spec.ts +97 -10
- package/app/stores/tutorial.spec.ts +92 -0
- package/app/stores/tutorial.ts +74 -3
- package/app/utils/tutorial.spec.ts +59 -1
- package/app/utils/tutorial.ts +67 -1
- package/i18n/locales/de.json +2 -0
- package/i18n/locales/en.json +8 -0
- package/i18n/locales/es.json +2 -0
- package/i18n/locales/fr.json +2 -0
- package/i18n/locales/he.json +2 -0
- package/i18n/locales/it.json +2 -0
- package/i18n/locales/ja.json +2 -0
- package/i18n/locales/pl.json +2 -0
- package/i18n/locales/tr.json +2 -0
- package/i18n/locales/uk.json +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -258,12 +258,80 @@ several anchors (`task-card`, `task-resolve`, `run-step`) render once per board
|
|
|
258
258
|
ring can only sit on one of them, so requiring the click to land on that one left a user who
|
|
259
259
|
clicked the card the copy asked for with no way forward — such a step renders no Next.
|
|
260
260
|
|
|
261
|
+
**An anchor that is on the page but off SCREEN is revealed before it is pointed at.** An
|
|
262
|
+
element scrolled out of a panel or panned off the board still HAS layout boxes, so it passes
|
|
263
|
+
the visibility check — and the ring was drawn at off-screen coordinates while the tooltip
|
|
264
|
+
clamped to a viewport edge, leaving the user reading "click this" beside nothing. The two
|
|
265
|
+
`task-card` steps hit this hardest, since they anchor whichever card is first in the DOM.
|
|
266
|
+
`needsReveal` (in `utils/tutorial.ts`, unit-tested) decides, measuring against
|
|
267
|
+
`min(anchorArea, viewportArea)` so a control bigger than the viewport — `board-canvas`,
|
|
268
|
+
`sidebar` — is judged on how much of the SCREEN it fills rather than on a fraction of its own
|
|
269
|
+
area it could never clear. The mechanism then depends on the container: the board is a
|
|
270
|
+
transform-panned Vue Flow canvas, where `scrollIntoView` does nothing and the camera has to
|
|
271
|
+
move instead (clamped to the current zoom, or fitting one button would throw away the user's
|
|
272
|
+
view of their board), and everything else is an ordinary scroll. `boardNodeIdFor` asks the
|
|
273
|
+
DOM which it is, rather than keying off the target id — the same id is a canvas node on the
|
|
274
|
+
board and a plain row in a panel. A reveal is attempted at most once per step, because both
|
|
275
|
+
mechanisms are animations longer than a tracking tick.
|
|
276
|
+
|
|
277
|
+
**Tracking is event-driven once an anchor is held**: only the hunt for a not-yet-mounted
|
|
278
|
+
anchor polls fast, and it is bounded by the step's wait budget. Movement arrives from scroll
|
|
279
|
+
(capture phase, so every scroll container counts), window resize, a `ResizeObserver` on the
|
|
280
|
+
anchor, and the board camera — with a slow backstop tick that also RE-RESOLVES the selector,
|
|
281
|
+
which is what lets a step re-anchor when its control is replaced underneath it. Every one of
|
|
282
|
+
those re-measures is coalesced into one per animation frame, because `measure()` reads layout
|
|
283
|
+
and then writes it, and capture-phase scroll fires for every container many times a frame.
|
|
284
|
+
|
|
285
|
+
**Accessibility.** The card is a non-modal `dialog` and deliberately not a focus trap: half
|
|
286
|
+
the catalog asks the user to operate the real control behind it. Focus moves onto the card
|
|
287
|
+
when the tour starts and on every Next/Back — without that a keyboard user has to tab the
|
|
288
|
+
whole page, since the overlay is teleported to the end of `body` — but never on a
|
|
289
|
+
`target-click` advance, where the app is opening a modal that rightly autofocuses its own
|
|
290
|
+
first field and the NEXT step is usually the one telling the user to type in it. That is a
|
|
291
|
+
decision, so it is `shouldFocusCard` in the logic module with a test on it, not an `if` at
|
|
292
|
+
the call site — it was an inline one, and a call site that forgot it is exactly how the card
|
|
293
|
+
came to steal focus from the modal it had just opened.
|
|
294
|
+
|
|
295
|
+
Step changes are announced through a separate `role="status"` region rather than `aria-live`
|
|
296
|
+
on the card, because the card's entire contents are replaced per step and a wholesale subtree
|
|
297
|
+
swap inside a dialog is not reliably announced. Two things about that region are load-bearing
|
|
298
|
+
and easy to undo by accident: it lives OUTSIDE the overlay's `v-if` and its text lands a tick
|
|
299
|
+
after the node does, because assistive tech announces a CHANGE to a live region and routinely
|
|
300
|
+
says nothing about one that was inserted already populated — which would silently cost the
|
|
301
|
+
first step of every tour. And it is the SOLE announcement: the card carries no
|
|
302
|
+
`aria-describedby`, or the body would be read a second time on every focus move.
|
|
303
|
+
|
|
304
|
+
Motion is honoured on both sides: `motion-safe:` on the ring transition and the searching
|
|
305
|
+
spinner, and an instant scroll and camera move under `prefers-reduced-motion`.
|
|
306
|
+
|
|
307
|
+
**Breaking off a tour leaves a resume point.** Esc and Skip are both easy to reach — one by
|
|
308
|
+
accident, one to get the overlay out of the way for a moment — and what they discarded was
|
|
309
|
+
the whole walkthrough. `stopTour()` records where it stopped and the prompt offers Resume
|
|
310
|
+
instead of only Start. Session-only, like the cursor itself: within a session the board is
|
|
311
|
+
still in the state the tour left it in, which is exactly what a DOM-anchored position needs.
|
|
312
|
+
The store validates no index (it knows nothing about which tours exist), so the overlay
|
|
313
|
+
clamps a resume that lands past the end of a script the gates have thinned since — and the
|
|
314
|
+
runtime's own bail-out on an unresolvable tour passes `resumable: false`, or resuming would
|
|
315
|
+
put the user straight back into the same dead overlay. There is ONE slot, and starting a tour
|
|
316
|
+
clears only that tour's own entry: another tour's position is not this action's to discard,
|
|
317
|
+
and it loses the slot soon enough — when this one is broken off past step 0.
|
|
318
|
+
|
|
261
319
|
The catalog is the `tutorialTours` slot: first-party tours live in
|
|
262
320
|
`modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
|
|
263
321
|
`registerAppModule` — they appear in the launch prompt beside the built-ins, gated per tour
|
|
264
322
|
by its `when(gates)` predicate (the same reactive gates service the nav uses, filtered in
|
|
265
323
|
`navSlotFilter`). Completion is persisted per tour id, so renaming an id resets its state.
|
|
266
324
|
|
|
325
|
+
**A built-in tour's anchors are drift-guarded** (`tutorial-tours.spec.ts`), because they are
|
|
326
|
+
the one thing about a tour that nothing else in the build checks: a renamed `data-testid`
|
|
327
|
+
passes typecheck, lint and the whole e2e suite, and several anchors have no other consumer at
|
|
328
|
+
all. The failure it prevents is worse than a dead step — those steps carry no `when`, so the
|
|
329
|
+
miss counts as an unexpected skip and every user lands on a permanent "you missed N steps"
|
|
330
|
+
notice, a false claim the tour goes on making in production with nothing red anywhere. The
|
|
331
|
+
guard scans the layer for both ways an id is named: written onto an element, or declared as a
|
|
332
|
+
`testId` field on a data contribution (the whole `nav-*` family reaches the DOM that way). It
|
|
333
|
+
is scoped to the built-in catalog, since a consumer's tours anchor on its own layer.
|
|
334
|
+
|
|
267
335
|
## Extending the layer (consumer modules)
|
|
268
336
|
|
|
269
337
|
A deployment can contribute its own components — result windows, nav entries, inspector
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
+
boardNodeIdFor,
|
|
3
4
|
isSafeTargetId,
|
|
4
5
|
isTargetClickAdvance,
|
|
5
6
|
resolveSkip,
|
|
7
|
+
shouldFocusCard,
|
|
6
8
|
stepTargetIds,
|
|
7
9
|
stepTargetSelectors,
|
|
8
10
|
unexpectedlySkippedSteps,
|
|
@@ -162,3 +164,47 @@ describe('unexpectedlySkippedSteps', () => {
|
|
|
162
164
|
expect(unexpectedlySkippedSteps(new Set(), [plain, branch])).toEqual([])
|
|
163
165
|
})
|
|
164
166
|
})
|
|
167
|
+
|
|
168
|
+
describe('boardNodeIdFor', () => {
|
|
169
|
+
/** A stand-in for the DOM ancestry lookup: `closest` hits when the selector is the one
|
|
170
|
+
* Vue Flow wraps its nodes in, and the hit carries whatever `data-id` we hand it. */
|
|
171
|
+
const el = (nodeId: string | null) => ({
|
|
172
|
+
closest: (selector: string) =>
|
|
173
|
+
selector === '.vue-flow__node' && nodeId !== null
|
|
174
|
+
? { getAttribute: (name: string) => (name === 'data-id' ? nodeId : null) }
|
|
175
|
+
: null,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('reports the node id for an anchor on the board canvas', () => {
|
|
179
|
+
expect(boardNodeIdFor(el('block-42'))).toBe('block-42')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('reports none for an anchor outside the canvas, so the caller scrolls it instead', () => {
|
|
183
|
+
// A panel row, a modal button, the sidebar: an ordinary scroll container, where a camera
|
|
184
|
+
// move would do nothing and `scrollIntoView` is the right mechanism.
|
|
185
|
+
expect(boardNodeIdFor(el(null))).toBeNull()
|
|
186
|
+
expect(boardNodeIdFor(null)).toBeNull()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('treats an empty data-id as absent', () => {
|
|
190
|
+
// `fitView` over an unknown id silently does nothing, which would look exactly like a
|
|
191
|
+
// reveal that ran — and the step would sit pointing off screen with its budget ticking.
|
|
192
|
+
expect(boardNodeIdFor(el(''))).toBeNull()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('shouldFocusCard', () => {
|
|
197
|
+
it('takes focus when the tour starts and when the user drives it', () => {
|
|
198
|
+
// The overlay is teleported to the end of `body`, so without this a keyboard user has to
|
|
199
|
+
// tab the whole page to reach Next.
|
|
200
|
+
expect(shouldFocusCard('tour-start')).toBe(true)
|
|
201
|
+
expect(shouldFocusCard('nav-control')).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('leaves focus alone when the step advanced because the user clicked the real control', () => {
|
|
205
|
+
// Such a click routinely opens a modal that autofocuses its own first field, and the next
|
|
206
|
+
// step is typically the one telling the user to type in it. Pulling focus back onto the
|
|
207
|
+
// coach mark puts their caret on a tooltip instead of the form the tour just pointed at.
|
|
208
|
+
expect(shouldFocusCard('target-click')).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -19,6 +19,33 @@ export type TutorialDirection = 'forward' | 'back'
|
|
|
19
19
|
/** What the overlay does with a step whose anchor never appeared. */
|
|
20
20
|
export type SkipOutcome = { kind: 'move'; index: number } | { kind: 'complete' }
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Why the step cursor moved. The overlay's only reason for tracking this is the focus
|
|
24
|
+
* decision below, which is why it is a CAUSE rather than a boolean the call sites pass: the
|
|
25
|
+
* question "who moved the cursor" has an obvious answer at every call site, where "should
|
|
26
|
+
* this one steal focus" has to be re-derived — and getting it wrong is silent.
|
|
27
|
+
*/
|
|
28
|
+
export type TutorialAdvanceCause = 'tour-start' | 'nav-control' | 'target-click'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Does the coach mark take focus now that the cursor has moved?
|
|
32
|
+
*
|
|
33
|
+
* Yes for the tour's own controls: the overlay is teleported to the end of `body`, so without
|
|
34
|
+
* this a keyboard user has to tab the whole page to reach Next.
|
|
35
|
+
*
|
|
36
|
+
* No for a `target-click` advance, and that is the whole reason this is a function. Such a
|
|
37
|
+
* step advances because the user operated a REAL control, which routinely opens a modal that
|
|
38
|
+
* rightly autofocuses its own first field — and the next step is typically the one telling
|
|
39
|
+
* them to type in it. Pulling focus back onto our card leaves the caret on a tooltip instead
|
|
40
|
+
* of the form, so the tour breaks the very interaction it just asked for.
|
|
41
|
+
*
|
|
42
|
+
* Not a focus trap either way: half the catalog asks the user to operate the control behind
|
|
43
|
+
* the card, which a trap would put out of reach.
|
|
44
|
+
*/
|
|
45
|
+
export function shouldFocusCard(cause: TutorialAdvanceCause): boolean {
|
|
46
|
+
return cause !== 'target-click'
|
|
47
|
+
}
|
|
48
|
+
|
|
22
49
|
/**
|
|
23
50
|
* What a `data-testid` may look like. Every one of the ~470 test ids in this layer is
|
|
24
51
|
* lowercase kebab-case, and the e2e suite's convention keeps it that way, so this rejects
|
|
@@ -71,6 +98,32 @@ export function resolveSkip(
|
|
|
71
98
|
return index + 1 < total ? { kind: 'move', index: index + 1 } : { kind: 'complete' }
|
|
72
99
|
}
|
|
73
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Vue Flow's per-node wrapper, which carries the node id in `data-id`. The board is a
|
|
103
|
+
* TRANSFORM-panned canvas rather than a scroll container, so an anchor inside one of these is
|
|
104
|
+
* revealed by moving the camera (`fitView({ nodes })`), where every other anchor is revealed
|
|
105
|
+
* by `scrollIntoView`. Asking the DOM which of the two an element is beats keying off the
|
|
106
|
+
* step's target id, since the same id (`task-card`) is a canvas node on the board and a plain
|
|
107
|
+
* list row in a panel.
|
|
108
|
+
*/
|
|
109
|
+
export const BOARD_NODE_SELECTOR = '.vue-flow__node'
|
|
110
|
+
|
|
111
|
+
/** The part of an element the reveal path needs: ancestry, and that ancestor's node id. */
|
|
112
|
+
interface RevealNode {
|
|
113
|
+
closest(selector: string): { getAttribute(name: string): string | null } | null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The board-canvas node id owning this element, or null when the element is not on the canvas
|
|
118
|
+
* (so the caller scrolls it into view instead). An empty `data-id` counts as absent: Vue Flow
|
|
119
|
+
* would match nothing, and a `fitView` over an unknown id silently does nothing at all — which
|
|
120
|
+
* would look exactly like a reveal that ran.
|
|
121
|
+
*/
|
|
122
|
+
export function boardNodeIdFor(el: RevealNode | null): string | null {
|
|
123
|
+
const id = el?.closest(BOARD_NODE_SELECTOR)?.getAttribute('data-id') ?? null
|
|
124
|
+
return id !== null && id.length > 0 ? id : null
|
|
125
|
+
}
|
|
126
|
+
|
|
74
127
|
/** The part of a clicked node this check needs: CSS-selector ancestry. */
|
|
75
128
|
interface ClickedNode {
|
|
76
129
|
closest(selector: string): unknown
|