@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 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