@sphynxoid/stepwise 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -59,15 +59,30 @@ function useSteps(items, opts) {
59
59
 
60
60
  // src/use-step-player.ts
61
61
  import { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo2, useRef as useRef2, useState as useState2 } from "react";
62
- function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syncHash, onEnd }) {
62
+ function useStepPlayer({
63
+ total,
64
+ baseDelayMs = 900,
65
+ autoPlay = false,
66
+ engine,
67
+ syncHash,
68
+ onEnd,
69
+ respectReducedMotion = true,
70
+ holdAt,
71
+ follow = false
72
+ }) {
63
73
  const core = useSteps(total);
74
+ const { go, next: coreNext, prev: corePrev, first } = core;
64
75
  const [started, setStarted] = useState2(autoPlay && total > 0);
65
- const [playing, setPlaying] = useState2(autoPlay && total > 0);
76
+ const [playing, setPlaying] = useState2(autoPlay && (total > 0 || follow));
66
77
  const [speed, setSpeed] = useState2(1);
67
78
  const [ended, setEnded] = useState2(false);
68
79
  const endedRef = useRef2(false);
69
80
  const onEndRef = useRef2(onEnd);
70
81
  onEndRef.current = onEnd;
82
+ const holdAtRef = useRef2(holdAt);
83
+ holdAtRef.current = holdAt;
84
+ const [held, setHeld] = useState2(null);
85
+ const releasedRef = useRef2(/* @__PURE__ */ new Set());
71
86
  const markEnded = useCallback2(() => {
72
87
  if (endedRef.current) return;
73
88
  endedRef.current = true;
@@ -81,10 +96,21 @@ function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syn
81
96
  }, []);
82
97
  const index = started && core.index >= 0 ? core.index : -1;
83
98
  const atEnd = total > 0 && index >= 0 && index >= total - 1;
84
- const engineRef = useRef2(engine);
85
- engineRef.current = engine;
99
+ const live = follow && held === null && (started ? playing || atEnd : playing);
86
100
  const totalRef = useRef2(total);
87
101
  totalRef.current = total;
102
+ const cursorRef = useRef2(core.index);
103
+ cursorRef.current = core.index;
104
+ const playingRef = useRef2(playing);
105
+ playingRef.current = playing;
106
+ const atEndRef = useRef2(atEnd);
107
+ atEndRef.current = atEnd;
108
+ const heldRef = useRef2(held);
109
+ heldRef.current = held;
110
+ const startedRef = useRef2(started);
111
+ startedRef.current = started;
112
+ const engineRef = useRef2(engine);
113
+ engineRef.current = engine;
88
114
  const engineApiRef = useRef2({});
89
115
  useEffect2(() => {
90
116
  if (!engine) return;
@@ -92,7 +118,7 @@ function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syn
92
118
  get total() {
93
119
  return totalRef.current;
94
120
  },
95
- onFrame: (f) => core.go(f),
121
+ onFrame: (f) => go(f),
96
122
  onEnd: () => {
97
123
  markEnded();
98
124
  setPlaying(false);
@@ -108,90 +134,135 @@ function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syn
108
134
  engineApiRef.current = {};
109
135
  handle.disconnect();
110
136
  };
111
- }, [engine]);
137
+ }, [engine, go, markEnded]);
138
+ useEffect2(() => {
139
+ if (!respectReducedMotion || !autoPlay) return;
140
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
141
+ if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
142
+ setPlaying(false);
143
+ engineApiRef.current.pause?.();
144
+ }, []);
112
145
  const start = useCallback2(() => {
113
146
  setStarted(true);
114
147
  setPlaying(true);
115
148
  clearEnded();
116
- core.first();
149
+ setHeld(null);
150
+ releasedRef.current = /* @__PURE__ */ new Set();
151
+ first();
117
152
  if (engineRef.current) {
118
153
  engineApiRef.current.seek?.(0);
119
154
  engineApiRef.current.play?.();
120
155
  }
121
- }, [core]);
156
+ }, [first, clearEnded]);
122
157
  const reset = useCallback2(() => {
123
158
  setStarted(false);
124
159
  setPlaying(false);
125
160
  clearEnded();
161
+ setHeld(null);
162
+ releasedRef.current = /* @__PURE__ */ new Set();
126
163
  if (engineRef.current) engineApiRef.current.pause?.();
127
164
  }, [clearEnded]);
128
165
  const seek = useCallback2(
129
166
  (f) => {
130
- if (total === 0) return;
167
+ const t = totalRef.current;
168
+ if (t === 0) return;
131
169
  setStarted(true);
132
170
  setPlaying(false);
133
171
  clearEnded();
134
- core.go(Math.max(0, Math.min(f, total - 1)));
172
+ setHeld(null);
173
+ go(Math.max(0, Math.min(f, t - 1)));
135
174
  if (engineRef.current) {
136
175
  engineApiRef.current.pause?.();
137
176
  engineApiRef.current.seek?.(f);
138
177
  }
139
178
  },
140
- [total, core, clearEnded]
179
+ [go, clearEnded]
141
180
  );
142
181
  const next = useCallback2(
143
182
  (onEmpty) => {
144
183
  setPlaying(false);
145
- if (total === 0) {
184
+ const t = totalRef.current;
185
+ if (t === 0) {
146
186
  onEmpty?.();
147
187
  return;
148
188
  }
149
189
  setStarted(true);
150
190
  clearEnded();
151
- core.next();
191
+ setHeld(null);
192
+ const target = Math.min(cursorRef.current + 1, t - 1);
193
+ coreNext();
152
194
  if (engineRef.current) {
153
195
  engineApiRef.current.pause?.();
154
- engineApiRef.current.seek?.(Math.min(core.index + 1, total - 1));
196
+ engineApiRef.current.seek?.(target);
155
197
  }
156
198
  },
157
- [total, core, clearEnded]
199
+ [coreNext, clearEnded]
158
200
  );
159
201
  const prev = useCallback2(() => {
160
202
  setPlaying(false);
161
203
  clearEnded();
162
- core.prev();
204
+ setHeld(null);
205
+ const target = Math.max(0, cursorRef.current - 1);
206
+ corePrev();
163
207
  if (engineRef.current) {
164
208
  engineApiRef.current.pause?.();
165
- engineApiRef.current.seek?.(Math.max(0, core.index - 1));
209
+ engineApiRef.current.seek?.(target);
166
210
  }
167
- }, [core, clearEnded]);
211
+ }, [corePrev, clearEnded]);
168
212
  const toggle = useCallback2(
169
213
  (onEmpty) => {
170
- if (total === 0) {
214
+ if (totalRef.current === 0) {
171
215
  onEmpty?.();
172
216
  return;
173
217
  }
174
- if (!playing && atEnd) {
175
- setStarted(true);
176
- setPlaying(true);
177
- clearEnded();
178
- core.first();
179
- if (engineRef.current) {
180
- engineApiRef.current.seek?.(0);
181
- engineApiRef.current.play?.();
182
- }
218
+ if (!playingRef.current && atEndRef.current) {
219
+ start();
183
220
  return;
184
221
  }
185
- const nextPlaying = !playing;
222
+ const nextPlaying = !playingRef.current;
186
223
  setPlaying(nextPlaying);
187
224
  setStarted(true);
225
+ if (nextPlaying) {
226
+ releasedRef.current.add(cursorRef.current);
227
+ setHeld(null);
228
+ }
188
229
  if (engineRef.current) {
189
230
  if (nextPlaying) engineApiRef.current.play?.();
190
231
  else engineApiRef.current.pause?.();
191
232
  }
192
233
  },
193
- [total, playing, atEnd, core, clearEnded]
234
+ [start]
194
235
  );
236
+ const release = useCallback2(() => {
237
+ const h = heldRef.current;
238
+ if (h === null) return;
239
+ releasedRef.current.add(h);
240
+ setHeld(null);
241
+ setPlaying(true);
242
+ }, []);
243
+ const jump = useCallback2(
244
+ (f) => {
245
+ const t = totalRef.current;
246
+ if (t === 0) return;
247
+ setStarted(true);
248
+ go(Math.max(0, Math.min(f, t - 1)));
249
+ if (engineRef.current) engineApiRef.current.seek?.(f);
250
+ },
251
+ [go]
252
+ );
253
+ const goLive = useCallback2(() => {
254
+ const t = totalRef.current;
255
+ if (t === 0) return;
256
+ setStarted(true);
257
+ setPlaying(false);
258
+ clearEnded();
259
+ setHeld(null);
260
+ go(t - 1);
261
+ if (engineRef.current) {
262
+ engineApiRef.current.pause?.();
263
+ engineApiRef.current.seek?.(t - 1);
264
+ }
265
+ }, [go, clearEnded]);
195
266
  const changeSpeed = useCallback2((s) => {
196
267
  setSpeed(s);
197
268
  if (engineRef.current) engineApiRef.current.rate?.(s);
@@ -199,6 +270,21 @@ function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syn
199
270
  useEffect2(() => {
200
271
  clearEnded();
201
272
  }, [total, clearEnded]);
273
+ const prevTotal = useRef2(total);
274
+ useEffect2(() => {
275
+ const before = prevTotal.current;
276
+ prevTotal.current = total;
277
+ if (!follow || engineRef.current || total <= before) return;
278
+ if (heldRef.current !== null) return;
279
+ if (!startedRef.current) {
280
+ if (playingRef.current) setStarted(true);
281
+ return;
282
+ }
283
+ if (playingRef.current || cursorRef.current >= before - 1) setPlaying(true);
284
+ }, [total, follow]);
285
+ useEffect2(() => {
286
+ if (held !== null && held !== core.index) setHeld(null);
287
+ }, [held, core.index]);
202
288
  useEffect2(() => {
203
289
  if (engineRef.current) return;
204
290
  if (!playing || !started || total === 0) return;
@@ -207,34 +293,49 @@ function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syn
207
293
  setPlaying(false);
208
294
  return;
209
295
  }
210
- const id = setTimeout(() => core.next(), baseDelayMs / speed);
296
+ if (holdAtRef.current?.(core.index) && !releasedRef.current.has(core.index)) {
297
+ setHeld(core.index);
298
+ setPlaying(false);
299
+ return;
300
+ }
301
+ const id = setTimeout(() => coreNext(), baseDelayMs / speed);
211
302
  return () => clearTimeout(id);
212
- }, [playing, started, core, total, speed, baseDelayMs, markEnded]);
213
- const hashKey = syncHash;
214
- const coreGoRef = useRef2(core.go);
215
- coreGoRef.current = core.go;
303
+ }, [playing, started, core.index, coreNext, total, speed, baseDelayMs, markEnded]);
216
304
  const lastWrittenHash = useRef2("");
305
+ const pendingRead = useRef2(null);
217
306
  const readHash = useCallback2(() => {
218
- if (!hashKey || typeof window === "undefined" || total === 0) return;
307
+ const t = totalRef.current;
308
+ if (!syncHash || typeof window === "undefined" || t === 0) return;
219
309
  const hash = window.location.hash;
220
310
  if (hash === lastWrittenHash.current) return;
221
- const match = hash.match(new RegExp(`[#&]${hashKey}=(\\d+)`));
222
- if (!match) return;
223
- const f = Number(match[1]);
224
- if (Number.isFinite(f) && f >= 0 && f < total) {
311
+ const value = readHashParam(hash, syncHash);
312
+ if (value === null || !/^\d+$/.test(value)) return;
313
+ const f = Number(value);
314
+ if (f < t) {
315
+ pendingRead.current = f;
225
316
  setStarted(true);
226
- coreGoRef.current(f);
317
+ go(f);
227
318
  }
228
- }, [hashKey, total]);
319
+ }, [syncHash, go]);
320
+ const linkedRead = useRef2(false);
229
321
  useEffect2(() => {
230
322
  readHash();
323
+ linkedRead.current = totalRef.current > 0;
231
324
  const onHashChange = () => readHash();
232
325
  window.addEventListener("hashchange", onHashChange);
233
326
  return () => window.removeEventListener("hashchange", onHashChange);
234
327
  }, [readHash]);
328
+ useEffect2(() => {
329
+ if (linkedRead.current || total === 0) return;
330
+ linkedRead.current = true;
331
+ readHash();
332
+ }, [total, readHash]);
235
333
  useEffect2(() => {
236
334
  if (!syncHash || index < 0 || typeof window === "undefined") return;
237
- const next2 = `#${syncHash}=${index}`;
335
+ const pending2 = pendingRead.current;
336
+ pendingRead.current = null;
337
+ if (pending2 !== null && pending2 !== index) return;
338
+ const next2 = writeHashParam(window.location.hash, syncHash, String(index));
238
339
  if (window.location.hash !== next2) {
239
340
  lastWrittenHash.current = next2;
240
341
  window.history.replaceState(null, "", next2);
@@ -249,6 +350,11 @@ function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syn
249
350
  speed,
250
351
  atEnd,
251
352
  ended,
353
+ held,
354
+ release,
355
+ jump,
356
+ live,
357
+ goLive,
252
358
  start,
253
359
  reset,
254
360
  seek,
@@ -258,28 +364,598 @@ function useStepPlayer({ total, baseDelayMs = 900, autoPlay = false, engine, syn
258
364
  setPlaying,
259
365
  setSpeed: changeSpeed
260
366
  }),
261
- [total, index, playing, speed, atEnd, ended, start, reset, seek, next, prev, toggle, changeSpeed]
367
+ [total, index, playing, speed, atEnd, ended, held, release, jump, live, goLive, start, reset, seek, next, prev, toggle, changeSpeed]
368
+ );
369
+ }
370
+ function hashParts(hash) {
371
+ const body = hash.startsWith("#") ? hash.slice(1) : hash;
372
+ return body ? body.split("&") : [];
373
+ }
374
+ function readHashParam(hash, key) {
375
+ for (const part of hashParts(hash)) {
376
+ const eq = part.indexOf("=");
377
+ if (eq > 0 && part.slice(0, eq) === key) return part.slice(eq + 1);
378
+ }
379
+ return null;
380
+ }
381
+ function writeHashParam(hash, key, value) {
382
+ const parts = hashParts(hash);
383
+ const pair = `${key}=${value}`;
384
+ const i = parts.findIndex((p) => p.startsWith(`${key}=`));
385
+ if (i >= 0) parts[i] = pair;
386
+ else parts.push(pair);
387
+ return `#${parts.join("&")}`;
388
+ }
389
+
390
+ // src/use-trace.ts
391
+ import { useCallback as useCallback3, useEffect as useEffect3, useLayoutEffect, useMemo as useMemo3, useReducer, useRef as useRef3 } from "react";
392
+
393
+ // src/reanchor.ts
394
+ function reanchor(oldSteps, oldIndex, newSteps, keyOf) {
395
+ if (newSteps.length === 0) return -1;
396
+ if (oldIndex < 0 || oldSteps.length === 0) return 0;
397
+ const positions = /* @__PURE__ */ new Map();
398
+ newSteps.forEach((s, i) => {
399
+ const k = keyOf(s, i);
400
+ if (k !== void 0 && !positions.has(k)) positions.set(k, i);
401
+ });
402
+ for (let i = Math.min(oldIndex, oldSteps.length - 1); i >= 0; i--) {
403
+ const k = keyOf(oldSteps[i], i);
404
+ if (k === void 0) continue;
405
+ const j = positions.get(k);
406
+ if (j !== void 0) return j;
407
+ }
408
+ return 0;
409
+ }
410
+ var stepKey = (step) => step !== null && typeof step === "object" && typeof step.key === "string" ? step.key : void 0;
411
+
412
+ // src/use-trace.ts
413
+ var useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect3;
414
+ function useTrace(steps, opts) {
415
+ const { keyOf, holdAt, ...playerOpts } = opts ?? {};
416
+ const stepsRef = useRef3(steps);
417
+ const player = useStepPlayer({
418
+ ...playerOpts,
419
+ total: steps.length,
420
+ holdAt: holdAt ? (f) => holdAt(f, stepsRef.current[f]) : void 0
421
+ });
422
+ stepsRef.current = steps;
423
+ const keyFn = keyOf ?? stepKey;
424
+ const shownRef = useRef3({ steps, index: player.index });
425
+ const shown = shownRef.current;
426
+ let anchored = null;
427
+ if (shown.steps !== steps && shown.index >= 0) {
428
+ const i = shown.index;
429
+ const k = i < shown.steps.length ? keyFn(shown.steps[i], i) : void 0;
430
+ if (k !== void 0 && i < steps.length && keyFn(steps[i], i) === k) anchored = i;
431
+ else if (steps.some((s, j) => keyFn(s, j) !== void 0)) anchored = reanchor(shown.steps, i, steps, keyFn);
432
+ }
433
+ const index = anchored ?? player.index;
434
+ const { jump } = player;
435
+ const [, rerender] = useReducer((n) => n + 1, 0);
436
+ useIsomorphicLayoutEffect(() => {
437
+ shownRef.current = { steps, index };
438
+ if (anchored === null) return;
439
+ if (anchored >= 0 && anchored !== player.index) jump(anchored);
440
+ rerender();
441
+ });
442
+ const frame = steps.length === 0 ? -1 : Math.max(0, Math.min(index, steps.length - 1));
443
+ const step = frame >= 0 ? steps[frame] : void 0;
444
+ const toneAt = useCallback3((i) => stepsRef.current[i]?.tone ?? "neutral", []);
445
+ const titleAt = useCallback3((i) => stepsRef.current[i]?.label ?? `step ${i + 1}`, []);
446
+ return useMemo3(() => ({ player, step, frame, toneAt, titleAt }), [player, step, frame, toneAt, titleAt]);
447
+ }
448
+
449
+ // src/use-simulation.ts
450
+ import { useMemo as useMemo4 } from "react";
451
+
452
+ // src/simulation.ts
453
+ function hashSeed(seed) {
454
+ let h = 2166136261;
455
+ for (let i = 0; i < seed.length; i++) {
456
+ h ^= seed.charCodeAt(i);
457
+ h = Math.imul(h, 16777619);
458
+ }
459
+ return h >>> 0;
460
+ }
461
+ function createRng(seed = 0) {
462
+ const base = typeof seed === "string" ? hashSeed(seed) : seed >>> 0;
463
+ let state = base;
464
+ const next = () => {
465
+ state = state + 1831565813 >>> 0;
466
+ let t = state;
467
+ t = Math.imul(t ^ t >>> 15, t | 1);
468
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
469
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
470
+ };
471
+ const int = (min, max) => min + Math.floor(next() * (max - min + 1));
472
+ return {
473
+ seed: base,
474
+ next,
475
+ int,
476
+ pick(items) {
477
+ if (items.length === 0) throw new RangeError("rng.pick: empty array");
478
+ return items[int(0, items.length - 1)];
479
+ },
480
+ shuffle(items) {
481
+ const out = items.slice();
482
+ for (let i = out.length - 1; i > 0; i--) {
483
+ const j = int(0, i);
484
+ [out[i], out[j]] = [out[j], out[i]];
485
+ }
486
+ return out;
487
+ }
488
+ };
489
+ }
490
+ function runSimulation(simulation, input, opts) {
491
+ const maxSteps = opts?.maxSteps ?? 1e4;
492
+ const rng = createRng(opts?.seed ?? 0);
493
+ const steps = [];
494
+ let truncated = false;
495
+ let error = null;
496
+ try {
497
+ for (const step of simulation(input, { rng })) {
498
+ if (steps.length >= maxSteps) {
499
+ truncated = true;
500
+ break;
501
+ }
502
+ steps.push(step);
503
+ }
504
+ } catch (e) {
505
+ error = e;
506
+ }
507
+ return { steps, truncated, error, seed: rng.seed };
508
+ }
509
+
510
+ // src/use-simulation.ts
511
+ function useSimulation(simulation, input, opts) {
512
+ const { seed, maxSteps, ...traceOpts } = opts ?? {};
513
+ const result = useMemo4(
514
+ () => runSimulation(simulation, input, { seed, maxSteps }),
515
+ [simulation, input, seed, maxSteps]
516
+ );
517
+ const trace = useTrace(result.steps, traceOpts);
518
+ return useMemo4(
519
+ () => ({ ...trace, steps: result.steps, truncated: result.truncated, error: result.error, seed: result.seed }),
520
+ [trace, result]
521
+ );
522
+ }
523
+
524
+ // src/create-trace.ts
525
+ function createTrace() {
526
+ const frames = [];
527
+ const builder = {
528
+ step(step) {
529
+ frames.push(step);
530
+ return builder;
531
+ },
532
+ get length() {
533
+ return frames.length;
534
+ },
535
+ last() {
536
+ return frames[frames.length - 1];
537
+ },
538
+ build() {
539
+ return frames.slice();
540
+ }
541
+ };
542
+ return builder;
543
+ }
544
+
545
+ // src/use-synced-tracks.ts
546
+ import { useCallback as useCallback4, useMemo as useMemo5, useRef as useRef4 } from "react";
547
+ function alignTracks(tracks, key) {
548
+ const names = Object.keys(tracks);
549
+ const lens = names.map((n) => tracks[n].length);
550
+ const rows = [];
551
+ const holdCell = (len, at) => ({ index: len === 0 ? -1 : Math.min(at, len - 1), held: true });
552
+ if (!key) {
553
+ const length = Math.max(0, ...lens);
554
+ for (let i = 0; i < length; i++) {
555
+ const row = {};
556
+ names.forEach((n, t) => {
557
+ row[n] = i < lens[t] ? { index: i, held: false } : holdCell(lens[t], i);
558
+ });
559
+ rows.push(row);
560
+ }
561
+ return rows;
562
+ }
563
+ const keyed = names.map(
564
+ (n) => tracks[n].map((step, i) => {
565
+ const k = key(step, i, n);
566
+ return k == null ? null : k;
567
+ })
568
+ );
569
+ const qualified = keyed.map((ks) => {
570
+ const seen = /* @__PURE__ */ new Map();
571
+ return ks.map((k) => {
572
+ if (k === null) return null;
573
+ const n = seen.get(k) ?? 0;
574
+ seen.set(k, n + 1);
575
+ return `${k}\0${n}`;
576
+ });
577
+ });
578
+ const where = /* @__PURE__ */ new Map();
579
+ qualified.forEach(
580
+ (ks, t) => ks.forEach((k, i) => {
581
+ if (k === null) return;
582
+ let m = where.get(k);
583
+ if (!m) where.set(k, m = /* @__PURE__ */ new Map());
584
+ m.set(t, i);
585
+ })
586
+ );
587
+ for (const [k, m] of where) if (m.size < 2) where.delete(k);
588
+ const pos = names.map(() => 0);
589
+ while (pos.some((p, t) => p < lens[t])) {
590
+ const live = names.map((_, t) => pos[t] < lens[t]);
591
+ const blocked = names.map((_, t) => {
592
+ if (!live[t]) return false;
593
+ const k = qualified[t][pos[t]];
594
+ const m = k === null ? void 0 : where.get(k);
595
+ if (!m) return false;
596
+ for (const [u, at] of m) if (u !== t && at > pos[u]) return true;
597
+ return false;
598
+ });
599
+ const anyFree = live.some((l, t) => l && !blocked[t]);
600
+ const row = {};
601
+ names.forEach((n, t) => {
602
+ if (live[t] && (!blocked[t] || !anyFree)) {
603
+ row[n] = { index: pos[t], held: false };
604
+ pos[t]++;
605
+ } else {
606
+ row[n] = holdCell(lens[t], Math.max(0, pos[t] - 1));
607
+ }
608
+ });
609
+ rows.push(row);
610
+ }
611
+ return rows;
612
+ }
613
+ var TONE_RANK = ["bad", "good", "write", "accent", "neutral", "idle"];
614
+ function useSyncedTracks(tracks, opts) {
615
+ const { key, ...playerOpts } = opts ?? {};
616
+ const keyRef = useRef4(key);
617
+ keyRef.current = key;
618
+ const cache = useRef4(null);
619
+ const names = Object.keys(tracks);
620
+ const arrays = names.map((n) => tracks[n]);
621
+ const c = cache.current;
622
+ if (!c || c.keyed !== !!key || c.names.length !== names.length || names.some((n, i) => c.names[i] !== n || c.arrays[i] !== arrays[i])) {
623
+ cache.current = { names, arrays, keyed: !!key, rows: alignTracks(tracks, keyRef.current) };
624
+ }
625
+ const rows = cache.current.rows;
626
+ const player = useStepPlayer({ ...playerOpts, total: rows.length });
627
+ const tracksRef = useRef4(tracks);
628
+ tracksRef.current = tracks;
629
+ const rowsRef = useRef4(rows);
630
+ rowsRef.current = rows;
631
+ const frame = rows.length === 0 ? -1 : Math.max(0, Math.min(player.index, rows.length - 1));
632
+ const row = frame >= 0 ? rows[frame] : void 0;
633
+ const aligned = cache.current;
634
+ const view = useMemo5(() => {
635
+ const out = {};
636
+ aligned.names.forEach((n, t) => {
637
+ const list = aligned.arrays[t];
638
+ const cell = row?.[n];
639
+ const index = cell ? cell.index : list.length === 0 ? -1 : 0;
640
+ out[n] = {
641
+ step: index >= 0 ? list[index] : void 0,
642
+ index,
643
+ held: cell?.held ?? false,
644
+ done: list.length > 0 && index === list.length - 1,
645
+ length: list.length
646
+ };
647
+ });
648
+ return out;
649
+ }, [aligned, row]);
650
+ const moved = useCallback4((i) => {
651
+ const r = rowsRef.current[i];
652
+ if (!r) return [];
653
+ return Object.keys(r).filter((n) => !r[n].held).map((n) => ({ name: n, step: tracksRef.current[n]?.[r[n].index] })).filter((m) => !!m.step);
654
+ }, []);
655
+ const toneAt = useCallback4(
656
+ (i) => {
657
+ const tones = moved(i).map((m) => m.step.tone);
658
+ return TONE_RANK.find((t) => tones.includes(t)) ?? "neutral";
659
+ },
660
+ [moved]
661
+ );
662
+ const titleAt = useCallback4(
663
+ (i) => {
664
+ const parts = moved(i).map((m) => `${m.name}: ${m.step.label}`);
665
+ return parts.length ? parts.join(" \xB7 ") : `frame ${i + 1}`;
666
+ },
667
+ [moved]
668
+ );
669
+ return useMemo5(
670
+ () => ({ player, frame, total: rows.length, tracks: view, rows, toneAt, titleAt }),
671
+ [player, frame, rows, view, toneAt, titleAt]
672
+ );
673
+ }
674
+
675
+ // src/use-lesson-stream.ts
676
+ import { useCallback as useCallback5, useEffect as useEffect5, useLayoutEffect as useLayoutEffect2, useMemo as useMemo6, useRef as useRef5, useState as useState4 } from "react";
677
+
678
+ // src/use-prefers-reduced-motion.ts
679
+ import { useEffect as useEffect4, useState as useState3 } from "react";
680
+ function usePrefersReducedMotion() {
681
+ const [reduced, setReduced] = useState3(false);
682
+ useEffect4(() => {
683
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
684
+ setReduced(mq.matches);
685
+ const onChange = (e) => setReduced(e.matches);
686
+ mq.addEventListener("change", onChange);
687
+ return () => mq.removeEventListener("change", onChange);
688
+ }, []);
689
+ return reduced;
690
+ }
691
+
692
+ // src/use-lesson-stream.ts
693
+ var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect2 : useEffect5;
694
+ function useLessonStream(count, opts = {}) {
695
+ const { radius = 1, approachBelow = 600, approachAbove = 300, scrollToSeed = true } = opts;
696
+ const last = count - 1;
697
+ const seed = count === 0 ? -1 : Math.max(0, Math.min(opts.seed ?? 0, last));
698
+ const reducedMotion = usePrefersReducedMotion();
699
+ const core = useSteps(count, { initialIndex: Math.max(0, seed) });
700
+ const coreGoRef = useRef5(core.go);
701
+ coreGoRef.current = core.go;
702
+ const activeRef = useRef5(core.index);
703
+ activeRef.current = core.index;
704
+ const [range, setRange] = useState4(() => ({
705
+ first: Math.max(0, seed - radius),
706
+ last: Math.min(last, seed + radius)
707
+ }));
708
+ const first = Math.max(0, Math.min(range.first, last));
709
+ const lastMounted = Math.min(range.last, last);
710
+ const rangeRef = useRef5({ first, last: lastMounted });
711
+ rangeRef.current = { first, last: lastMounted };
712
+ const countRef = useRef5(count);
713
+ countRef.current = count;
714
+ const reducedRef = useRef5(reducedMotion);
715
+ reducedRef.current = reducedMotion;
716
+ const onActiveRef = useRef5(opts.onActiveChange);
717
+ onActiveRef.current = opts.onActiveChange;
718
+ const optsRef = useRef5({ approachBelow, approachAbove });
719
+ optsRef.current = { approachBelow, approachAbove };
720
+ const blocks = useRef5(/* @__PURE__ */ new Map());
721
+ const refCache = useRef5(/* @__PURE__ */ new Map());
722
+ const refFor = useCallback5((i) => {
723
+ let fn = refCache.current.get(i);
724
+ if (!fn) {
725
+ fn = (el) => {
726
+ if (el) blocks.current.set(i, el);
727
+ else blocks.current.delete(i);
728
+ };
729
+ refCache.current.set(i, fn);
730
+ }
731
+ return fn;
732
+ }, []);
733
+ const causeRef = useRef5("scroll");
734
+ const expandTo = useCallback5((i, cause) => {
735
+ const r = rangeRef.current;
736
+ if (i >= r.first && i <= r.last) return;
737
+ causeRef.current = cause;
738
+ const grown = { first: Math.min(r.first, i), last: Math.max(r.last, i) };
739
+ rangeRef.current = grown;
740
+ setRange(grown);
741
+ }, []);
742
+ const scheduleRef = useRef5(null);
743
+ useEffect5(() => {
744
+ if (count === 0) return;
745
+ let raf = 0;
746
+ const update = () => {
747
+ raf = 0;
748
+ if (blocks.current.size === 0) return;
749
+ const mid = window.innerHeight / 2;
750
+ let best = -1;
751
+ for (const [i, el] of blocks.current) {
752
+ const rect = el.getBoundingClientRect();
753
+ if (rect.top <= mid && rect.bottom >= mid) best = Math.max(best, i);
754
+ }
755
+ if (best === -1) best = Math.min(...blocks.current.keys());
756
+ if (best !== activeRef.current) {
757
+ activeRef.current = best;
758
+ coreGoRef.current(best);
759
+ onActiveRef.current?.(best);
760
+ }
761
+ const { first: f, last: l } = rangeRef.current;
762
+ const { approachBelow: below, approachAbove: above } = optsRef.current;
763
+ const lastEl = blocks.current.get(l);
764
+ if (lastEl && l < countRef.current - 1 && lastEl.getBoundingClientRect().bottom - window.innerHeight < below) {
765
+ expandTo(l + 1, "scroll");
766
+ }
767
+ const firstEl = blocks.current.get(f);
768
+ if (firstEl && f > 0 && firstEl.getBoundingClientRect().top > -above) {
769
+ expandTo(f - 1, "scroll");
770
+ }
771
+ };
772
+ const onScroll = () => {
773
+ if (!raf) raf = requestAnimationFrame(update);
774
+ };
775
+ scheduleRef.current = onScroll;
776
+ update();
777
+ window.addEventListener("scroll", onScroll, { passive: true });
778
+ window.addEventListener("resize", onScroll);
779
+ return () => {
780
+ scheduleRef.current = null;
781
+ window.removeEventListener("scroll", onScroll);
782
+ window.removeEventListener("resize", onScroll);
783
+ if (raf) cancelAnimationFrame(raf);
784
+ };
785
+ }, [count, expandTo]);
786
+ useEffect5(() => {
787
+ scheduleRef.current?.();
788
+ }, [first, lastMounted]);
789
+ const firstRef = useRef5(first);
790
+ useIsoLayoutEffect(() => {
791
+ if (first < firstRef.current && causeRef.current === "scroll") {
792
+ let height = 0;
793
+ for (let i = first; i < firstRef.current; i++) {
794
+ height += blocks.current.get(i)?.getBoundingClientRect().height ?? 0;
795
+ }
796
+ if (height) window.scrollBy({ top: height, behavior: "auto" });
797
+ }
798
+ causeRef.current = "scroll";
799
+ firstRef.current = first;
800
+ }, [first]);
801
+ useIsoLayoutEffect(() => {
802
+ if (!scrollToSeed || seed <= 0) return;
803
+ blocks.current.get(seed)?.scrollIntoView({ block: "start", behavior: "auto" });
804
+ }, []);
805
+ const [nav, setNav] = useState4(null);
806
+ useEffect5(() => {
807
+ if (!nav) return;
808
+ const raf = requestAnimationFrame(
809
+ () => blocks.current.get(nav.target)?.scrollIntoView({ behavior: reducedRef.current ? "auto" : "smooth", block: "start" })
810
+ );
811
+ return () => cancelAnimationFrame(raf);
812
+ }, [nav]);
813
+ const goTo = useCallback5(
814
+ (i) => {
815
+ const c = countRef.current;
816
+ if (c === 0) return;
817
+ const target = Math.max(0, Math.min(i, c - 1));
818
+ expandTo(target, "nav");
819
+ setNav({ target });
820
+ },
821
+ [expandTo]
822
+ );
823
+ const next = useCallback5(() => goTo(activeRef.current + 1), [goTo]);
824
+ const prev = useCallback5(() => goTo(activeRef.current - 1), [goTo]);
825
+ const mounted = useMemo6(() => {
826
+ const out = [];
827
+ for (let i = first; i <= lastMounted; i++) out.push(i);
828
+ return out;
829
+ }, [first, lastMounted]);
830
+ return useMemo6(
831
+ () => ({
832
+ active: core.index,
833
+ seed,
834
+ mounted,
835
+ complete: count > 0 && lastMounted === last,
836
+ refFor,
837
+ goTo,
838
+ next,
839
+ prev,
840
+ reducedMotion
841
+ }),
842
+ [core.index, seed, mounted, count, lastMounted, last, refFor, goTo, next, prev, reducedMotion]
843
+ );
844
+ }
845
+ function useFadeIn(initiallyVisible = false) {
846
+ const reduced = usePrefersReducedMotion();
847
+ const [shown, setShown] = useState4(initiallyVisible);
848
+ useEffect5(() => {
849
+ if (shown) return;
850
+ if (reduced) {
851
+ setShown(true);
852
+ return;
853
+ }
854
+ const raf = requestAnimationFrame(() => setShown(true));
855
+ return () => cancelAnimationFrame(raf);
856
+ }, [shown, reduced]);
857
+ return shown;
858
+ }
859
+
860
+ // src/use-evaluator.ts
861
+ import { useCallback as useCallback6, useEffect as useEffect6, useMemo as useMemo7, useRef as useRef6, useState as useState5 } from "react";
862
+
863
+ // src/evaluate.ts
864
+ function toVerdict(result) {
865
+ return typeof result === "boolean" ? { status: result ? "pass" : "fail" } : result;
866
+ }
867
+ var pass = (feedback) => ({ status: "pass", feedback });
868
+ var fail = (feedback) => ({ status: "fail", feedback });
869
+ var pending = (feedback) => ({ status: "pending", feedback });
870
+
871
+ // src/use-evaluator.ts
872
+ var IDLE = { status: "idle", feedback: void 0, error: null };
873
+ function useEvaluator(evaluator, ctx, opts) {
874
+ const mode = opts?.mode ?? "auto";
875
+ const [state, setState] = useState5(IDLE);
876
+ const [attempts, setAttempts] = useState5(0);
877
+ const evaluatorRef = useRef6(evaluator);
878
+ evaluatorRef.current = evaluator;
879
+ const ctxRef = useRef6(ctx);
880
+ ctxRef.current = ctx;
881
+ const runRef = useRef6(null);
882
+ useEffect6(() => () => runRef.current?.abort(), []);
883
+ const run = useCallback6(async () => {
884
+ runRef.current?.abort();
885
+ const controller = new AbortController();
886
+ runRef.current = controller;
887
+ let result;
888
+ try {
889
+ result = evaluatorRef.current(ctxRef.current, controller.signal);
890
+ } catch (error) {
891
+ setState({ status: "fail", feedback: void 0, error });
892
+ return { status: "fail" };
893
+ }
894
+ if (!(result instanceof Promise)) {
895
+ const verdict = toVerdict(result);
896
+ setState({ status: verdict.status, feedback: verdict.feedback, error: null });
897
+ return verdict;
898
+ }
899
+ setState((s) => ({ status: "pending", feedback: s.feedback, error: null }));
900
+ try {
901
+ const verdict = toVerdict(await result);
902
+ if (controller.signal.aborted) return null;
903
+ setState({ status: verdict.status, feedback: verdict.feedback, error: null });
904
+ return verdict;
905
+ } catch (error) {
906
+ if (controller.signal.aborted) return null;
907
+ setState({ status: "fail", feedback: void 0, error });
908
+ return { status: "fail" };
909
+ }
910
+ }, []);
911
+ const check = useCallback6(() => {
912
+ setAttempts((n) => n + 1);
913
+ return run();
914
+ }, [run]);
915
+ const reset = useCallback6(() => {
916
+ runRef.current?.abort();
917
+ runRef.current = null;
918
+ setState(IDLE);
919
+ setAttempts(0);
920
+ }, []);
921
+ useEffect6(() => {
922
+ if (mode === "auto") {
923
+ void run();
924
+ } else {
925
+ runRef.current?.abort();
926
+ setState(IDLE);
927
+ }
928
+ }, [ctx, mode, run]);
929
+ return useMemo7(
930
+ () => ({ ...state, passed: state.status === "pass", attempts, check, reset }),
931
+ [state, attempts, check, reset]
262
932
  );
263
933
  }
264
934
 
265
935
  // src/use-guided-tour.ts
266
- import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
936
+ import { useCallback as useCallback7, useEffect as useEffect7, useMemo as useMemo8, useRef as useRef7, useState as useState6 } from "react";
937
+ function gateVerdict(step) {
938
+ const r = step?.gate?.();
939
+ return r === void 0 ? { status: "pass", feedback: void 0 } : toVerdict(r);
940
+ }
267
941
  function useGuidedTour(steps, opts) {
268
- const [running, setRunning] = useState3(false);
269
- const runningRef = useRef3(running);
942
+ const [running, setRunning] = useState6(false);
943
+ const runningRef = useRef7(running);
270
944
  runningRef.current = running;
271
945
  const core = useSteps(steps.length);
272
- const stepsRef = useRef3(steps);
946
+ const stepsRef = useRef7(steps);
273
947
  stepsRef.current = steps;
274
- const driverRef = useRef3(opts?.driver);
948
+ const driverRef = useRef7(opts?.driver);
275
949
  driverRef.current = opts?.driver;
276
- const coreGoRef = useRef3(core.go);
950
+ const coreGoRef = useRef7(core.go);
277
951
  coreGoRef.current = core.go;
278
- const isSkipped = useCallback3(
952
+ const cursorRef = useRef7(core.index);
953
+ cursorRef.current = core.index;
954
+ const isSkipped = useCallback7(
279
955
  (i) => stepsRef.current[i]?.when?.() === false,
280
956
  []
281
957
  );
282
- const settle = useCallback3(
958
+ const settle = useCallback7(
283
959
  (start2, dir) => {
284
960
  const len = stepsRef.current.length;
285
961
  let i = start2;
@@ -291,7 +967,7 @@ function useGuidedTour(steps, opts) {
291
967
  },
292
968
  [isSkipped]
293
969
  );
294
- const arrive = useCallback3(
970
+ const arrive = useCallback7(
295
971
  (i, dir) => {
296
972
  const target = settle(i, dir);
297
973
  if (target === null) {
@@ -303,14 +979,14 @@ function useGuidedTour(steps, opts) {
303
979
  },
304
980
  [settle]
305
981
  );
306
- const goTo = useCallback3(
982
+ const goTo = useCallback7(
307
983
  (i) => {
308
984
  if (stepsRef.current.length === 0) return;
309
985
  arrive(Math.max(0, Math.min(i, stepsRef.current.length - 1)), 1);
310
986
  },
311
987
  [arrive]
312
988
  );
313
- const goToId = useCallback3(
989
+ const goToId = useCallback7(
314
990
  (id) => {
315
991
  const i = stepsRef.current.findIndex((s) => s.id === id);
316
992
  if (i >= 0) goTo(i);
@@ -318,11 +994,11 @@ function useGuidedTour(steps, opts) {
318
994
  [goTo]
319
995
  );
320
996
  const index = running ? core.index : null;
321
- useEffect3(() => {
997
+ useEffect7(() => {
322
998
  if (!running) return;
323
999
  stepsRef.current[core.index]?.onEnter?.();
324
1000
  }, [running, core.index]);
325
- useEffect3(() => {
1001
+ useEffect7(() => {
326
1002
  const driver = driverRef.current;
327
1003
  if (!driver) return;
328
1004
  if (!running) {
@@ -341,14 +1017,14 @@ function useGuidedTour(steps, opts) {
341
1017
  back: () => goTo(core.index - 1),
342
1018
  end: () => setRunning(false)
343
1019
  });
344
- }, [running, core.index, goTo, core]);
345
- useEffect3(() => () => driverRef.current?.hide(), []);
346
- const start = useCallback3(() => {
1020
+ }, [running, core.index, goTo]);
1021
+ useEffect7(() => () => driverRef.current?.hide(), []);
1022
+ const start = useCallback7(() => {
347
1023
  if (stepsRef.current.length === 0) return;
348
1024
  arrive(0, 1);
349
1025
  }, [arrive]);
350
- const end = useCallback3(() => setRunning(false), []);
351
- const resolveNext = useCallback3((i) => {
1026
+ const end = useCallback7(() => setRunning(false), []);
1027
+ const resolveNext = useCallback7((i) => {
352
1028
  const list = stepsRef.current;
353
1029
  const edge = list[i]?.next?.();
354
1030
  if (edge != null) {
@@ -357,30 +1033,35 @@ function useGuidedTour(steps, opts) {
357
1033
  }
358
1034
  return i + 1;
359
1035
  }, []);
360
- const next = useCallback3(() => {
1036
+ const next = useCallback7(() => {
361
1037
  if (!runningRef.current) return;
362
- const i = core.index;
363
- if (stepsRef.current[i]?.gate?.() === false) return;
1038
+ const i = cursorRef.current;
1039
+ if (gateVerdict(stepsRef.current[i]).status !== "pass") return;
364
1040
  const target = resolveNext(i);
365
1041
  if (target > stepsRef.current.length - 1) {
366
1042
  setRunning(false);
367
1043
  return;
368
1044
  }
369
1045
  arrive(target, 1);
370
- }, [core, arrive, resolveNext]);
371
- const back = useCallback3(() => {
1046
+ }, [arrive, resolveNext]);
1047
+ const back = useCallback7(() => {
372
1048
  if (!runningRef.current) return;
373
- const i = core.index;
1049
+ const i = cursorRef.current;
374
1050
  if (i <= 0) return;
375
1051
  arrive(i - 1, -1);
376
- }, [core, arrive]);
1052
+ }, [arrive]);
377
1053
  const current = running ? stepsRef.current[core.index] ?? null : null;
378
- const blocked = current?.gate?.() === false;
379
- return { index, current, active: running, blocked, start, end, next, back, goto: goTo, gotoId: goToId };
1054
+ const verdict = current ? gateVerdict(current) : null;
1055
+ const blocked = verdict !== null && verdict.status !== "pass";
1056
+ const feedback = verdict?.feedback;
1057
+ return useMemo8(
1058
+ () => ({ index, current, active: running, blocked, feedback, start, end, next, back, goto: goTo, gotoId: goToId }),
1059
+ [index, current, running, blocked, feedback, start, end, next, back, goTo, goToId]
1060
+ );
380
1061
  }
381
1062
 
382
1063
  // src/use-chapters.ts
383
- import { useCallback as useCallback4, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4, useState as useState4 } from "react";
1064
+ import { useCallback as useCallback8, useEffect as useEffect8, useMemo as useMemo9, useRef as useRef8, useState as useState7 } from "react";
384
1065
 
385
1066
  // src/flatten.ts
386
1067
  function flattenChapters(chapters, depth = 0) {
@@ -399,17 +1080,17 @@ function flattenChapters(chapters, depth = 0) {
399
1080
 
400
1081
  // src/use-chapters.ts
401
1082
  function useChapters(chapters) {
402
- const [mounted, setMounted] = useState4(false);
403
- const flat = useMemo3(() => flattenChapters(chapters), [chapters]);
404
- const flatRef = useRef4(flat);
1083
+ const [mounted, setMounted] = useState7(false);
1084
+ const flat = useMemo9(() => flattenChapters(chapters), [chapters]);
1085
+ const flatRef = useRef8(flat);
405
1086
  flatRef.current = flat;
406
- const [active, setActive] = useState4(0);
407
- const activeRef = useRef4(0);
1087
+ const [active, setActive] = useState7(0);
1088
+ const activeRef = useRef8(0);
408
1089
  activeRef.current = active;
409
1090
  const core = useSteps(flat.length, { initialIndex: 0 });
410
- const coreGoRef = useRef4(core.go);
1091
+ const coreGoRef = useRef8(core.go);
411
1092
  coreGoRef.current = core.go;
412
- useEffect4(() => {
1093
+ useEffect8(() => {
413
1094
  if (!mounted) return;
414
1095
  let raf = 0;
415
1096
  const update = () => {
@@ -447,8 +1128,8 @@ function useChapters(chapters) {
447
1128
  if (raf) cancelAnimationFrame(raf);
448
1129
  };
449
1130
  }, [mounted, flat.length]);
450
- useEffect4(() => setMounted(true), []);
451
- const goTo = useCallback4((i) => {
1131
+ useEffect8(() => setMounted(true), []);
1132
+ const goTo = useCallback8((i) => {
452
1133
  const entry = flatRef.current[i];
453
1134
  if (!entry) return;
454
1135
  setActive(i);
@@ -457,25 +1138,25 @@ function useChapters(chapters) {
457
1138
  if (!el) return;
458
1139
  el.scrollIntoView({ behavior: "smooth", block: "start" });
459
1140
  }, []);
460
- const next = useCallback4(() => {
1141
+ const next = useCallback8(() => {
461
1142
  if (activeRef.current < flatRef.current.length - 1) goTo(activeRef.current + 1);
462
1143
  }, [goTo]);
463
- const prev = useCallback4(() => {
1144
+ const prev = useCallback8(() => {
464
1145
  if (activeRef.current > 0) goTo(activeRef.current - 1);
465
1146
  }, [goTo]);
466
- return { active: core.index, goTo, next, prev };
1147
+ return useMemo9(() => ({ active: core.index, goTo, next, prev }), [core.index, goTo, next, prev]);
467
1148
  }
468
1149
 
469
1150
  // src/use-global-keys.ts
470
- import { useEffect as useEffect5, useRef as useRef5 } from "react";
1151
+ import { useEffect as useEffect9, useRef as useRef9 } from "react";
471
1152
  var DEFAULT_IGNORED_TAGS = ["INPUT", "TEXTAREA", "SELECT", "BUTTON"];
472
1153
  function useGlobalKeys(map, opts) {
473
- const mapRef = useRef5(map);
1154
+ const mapRef = useRef9(map);
474
1155
  mapRef.current = map;
475
- const ignoreRef = useRef5(new Set(opts?.ignoreTags ?? DEFAULT_IGNORED_TAGS));
476
- const enabledRef = useRef5(opts?.enabled ?? true);
1156
+ const ignoreRef = useRef9(new Set(opts?.ignoreTags ?? DEFAULT_IGNORED_TAGS));
1157
+ const enabledRef = useRef9(opts?.enabled ?? true);
477
1158
  enabledRef.current = opts?.enabled ?? true;
478
- useEffect5(() => {
1159
+ useEffect9(() => {
479
1160
  const onKey = (e) => {
480
1161
  const gate = enabledRef.current;
481
1162
  const enabled = typeof gate === "function" ? gate() : gate;
@@ -493,7 +1174,7 @@ function useGlobalKeys(map, opts) {
493
1174
  }
494
1175
 
495
1176
  // src/use-discoveries.ts
496
- import { useCallback as useCallback5, useEffect as useEffect6, useMemo as useMemo4, useRef as useRef6, useState as useState5 } from "react";
1177
+ import { useCallback as useCallback9, useEffect as useEffect10, useMemo as useMemo10, useRef as useRef10, useState as useState8 } from "react";
497
1178
  function localStorageStore(key = "stepwise.discoveries") {
498
1179
  return {
499
1180
  read() {
@@ -544,23 +1225,23 @@ function memoryStore(seed = []) {
544
1225
  };
545
1226
  }
546
1227
  function useDiscoveries(opts) {
547
- const defaultStoreRef = useRef6(null);
548
- const storeRef = useRef6(null);
1228
+ const defaultStoreRef = useRef10(null);
1229
+ const storeRef = useRef10(null);
549
1230
  if (opts?.store) {
550
1231
  storeRef.current = opts.store;
551
1232
  } else {
552
1233
  if (!defaultStoreRef.current) defaultStoreRef.current = localStorageStore(opts?.key);
553
1234
  storeRef.current = defaultStoreRef.current;
554
1235
  }
555
- const [ids, setIds] = useState5(() => /* @__PURE__ */ new Set());
556
- const [ready, setReady] = useState5(false);
557
- const idsRef = useRef6(ids);
1236
+ const [ids, setIds] = useState8(() => /* @__PURE__ */ new Set());
1237
+ const [ready, setReady] = useState8(false);
1238
+ const idsRef = useRef10(ids);
558
1239
  idsRef.current = ids;
559
- useEffect6(() => {
1240
+ useEffect10(() => {
560
1241
  setIds(new Set(storeRef.current.read()));
561
1242
  setReady(true);
562
1243
  }, []);
563
- const markDiscovered = useCallback5((id) => {
1244
+ const markDiscovered = useCallback9((id) => {
564
1245
  const additions = Array.isArray(id) ? id : [id];
565
1246
  const merged = new Set(idsRef.current);
566
1247
  let changed = false;
@@ -575,24 +1256,24 @@ function useDiscoveries(opts) {
575
1256
  setIds(merged);
576
1257
  storeRef.current.write([...merged]);
577
1258
  }, []);
578
- const reset = useCallback5(() => {
1259
+ const reset = useCallback9(() => {
579
1260
  const empty = /* @__PURE__ */ new Set();
580
1261
  idsRef.current = empty;
581
1262
  setIds(empty);
582
1263
  storeRef.current.write([]);
583
1264
  }, []);
584
- const isDiscovered = useCallback5((id) => idsRef.current.has(id), []);
585
- return useMemo4(
1265
+ const isDiscovered = useCallback9((id) => idsRef.current.has(id), []);
1266
+ return useMemo10(
586
1267
  () => ({ discovered: ids, ready, isDiscovered, markDiscovered, reset }),
587
1268
  [ids, ready, isDiscovered, markDiscovered, reset]
588
1269
  );
589
1270
  }
590
1271
 
591
1272
  // src/use-focus-trap.ts
592
- import { useEffect as useEffect7, useRef as useRef7 } from "react";
1273
+ import { useEffect as useEffect11, useRef as useRef11 } from "react";
593
1274
  function useFocusTrap(active) {
594
- const ref = useRef7(null);
595
- useEffect7(() => {
1275
+ const ref = useRef11(null);
1276
+ useEffect11(() => {
596
1277
  if (!active) return;
597
1278
  const container = ref.current;
598
1279
  if (!container) return;
@@ -626,31 +1307,17 @@ function useFocusTrap(active) {
626
1307
  return ref;
627
1308
  }
628
1309
 
629
- // src/use-prefers-reduced-motion.ts
630
- import { useEffect as useEffect8, useState as useState6 } from "react";
631
- function usePrefersReducedMotion() {
632
- const [reduced, setReduced] = useState6(false);
633
- useEffect8(() => {
634
- const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
635
- setReduced(mq.matches);
636
- const onChange = (e) => setReduced(e.matches);
637
- mq.addEventListener("change", onChange);
638
- return () => mq.removeEventListener("change", onChange);
639
- }, []);
640
- return reduced;
641
- }
642
-
643
1310
  // src/glossary.tsx
644
1311
  import {
645
1312
  createContext,
646
- useCallback as useCallback6,
1313
+ useCallback as useCallback10,
647
1314
  useContext,
648
- useEffect as useEffect9,
1315
+ useEffect as useEffect12,
649
1316
  useId,
650
- useLayoutEffect,
651
- useMemo as useMemo5,
652
- useRef as useRef8,
653
- useState as useState7
1317
+ useLayoutEffect as useLayoutEffect3,
1318
+ useMemo as useMemo11,
1319
+ useRef as useRef12,
1320
+ useState as useState9
654
1321
  } from "react";
655
1322
  import { createPortal } from "react-dom";
656
1323
 
@@ -708,7 +1375,7 @@ function GlossaryProvider({
708
1375
  theme = defaultTheme,
709
1376
  children
710
1377
  }) {
711
- const value = useMemo5(
1378
+ const value = useMemo11(
712
1379
  () => ({ byId: new Map(entries.map((e) => [e.id, e])), entries, theme }),
713
1380
  [entries, theme]
714
1381
  );
@@ -718,23 +1385,23 @@ function Term({ id, children, className }) {
718
1385
  const ctx = useContext(GlossaryContext);
719
1386
  const depth = useContext(DepthContext);
720
1387
  const entry = ctx?.byId.get(id);
721
- const [open, setOpen] = useState7(false);
722
- const [pos, setPos] = useState7(null);
723
- const openRef = useRef8(false);
724
- const wrapperRef = useRef8(null);
725
- const triggerRef = useRef8(null);
726
- const cardRef = useRef8(null);
727
- const openTimer = useRef8(0);
728
- const closeTimer = useRef8(0);
1388
+ const [open, setOpen] = useState9(false);
1389
+ const [pos, setPos] = useState9(null);
1390
+ const openRef = useRef12(false);
1391
+ const wrapperRef = useRef12(null);
1392
+ const triggerRef = useRef12(null);
1393
+ const cardRef = useRef12(null);
1394
+ const openTimer = useRef12(0);
1395
+ const closeTimer = useRef12(0);
729
1396
  const tipId = useId();
730
1397
  const display = children ?? entry?.term ?? id;
731
- const closeNow = useCallback6(() => {
1398
+ const closeNow = useCallback10(() => {
732
1399
  window.clearTimeout(openTimer.current);
733
1400
  window.clearTimeout(closeTimer.current);
734
1401
  openRef.current = false;
735
1402
  setOpen(false);
736
1403
  }, []);
737
- const openNow = useCallback6((focusCard = false) => {
1404
+ const openNow = useCallback10((focusCard = false) => {
738
1405
  window.clearTimeout(closeTimer.current);
739
1406
  window.clearTimeout(openTimer.current);
740
1407
  openRef.current = true;
@@ -743,18 +1410,18 @@ function Term({ id, children, className }) {
743
1410
  requestAnimationFrame(() => cardRef.current?.focus({ preventScroll: true }));
744
1411
  }
745
1412
  }, []);
746
- const scheduleOpen = useCallback6(() => {
1413
+ const scheduleOpen = useCallback10(() => {
747
1414
  window.clearTimeout(closeTimer.current);
748
1415
  if (openRef.current) return;
749
1416
  window.clearTimeout(openTimer.current);
750
1417
  openTimer.current = window.setTimeout(() => openNow(), OPEN_DELAY_MS);
751
1418
  }, [openNow]);
752
- const scheduleClose = useCallback6(() => {
1419
+ const scheduleClose = useCallback10(() => {
753
1420
  window.clearTimeout(openTimer.current);
754
1421
  window.clearTimeout(closeTimer.current);
755
1422
  closeTimer.current = window.setTimeout(closeNow, CLOSE_GRACE_MS);
756
1423
  }, [closeNow]);
757
- useLayoutEffect(() => {
1424
+ useLayoutEffect3(() => {
758
1425
  if (!open) {
759
1426
  setPos(null);
760
1427
  return;
@@ -791,7 +1458,7 @@ function Term({ id, children, className }) {
791
1458
  if (raf) cancelAnimationFrame(raf);
792
1459
  };
793
1460
  }, [open]);
794
- useEffect9(() => {
1461
+ useEffect12(() => {
795
1462
  if (!open) return;
796
1463
  const onPointerDown = (e) => {
797
1464
  const target = e.target;
@@ -802,7 +1469,7 @@ function Term({ id, children, className }) {
802
1469
  document.addEventListener("pointerdown", onPointerDown);
803
1470
  return () => document.removeEventListener("pointerdown", onPointerDown);
804
1471
  }, [open, closeNow]);
805
- useEffect9(() => () => {
1472
+ useEffect12(() => () => {
806
1473
  window.clearTimeout(openTimer.current);
807
1474
  window.clearTimeout(closeTimer.current);
808
1475
  }, []);
@@ -854,6 +1521,7 @@ function Term({ id, children, className }) {
854
1521
  "span",
855
1522
  {
856
1523
  ref: wrapperRef,
1524
+ "data-stepwise": "",
857
1525
  className: "relative inline",
858
1526
  onMouseEnter: interactive ? scheduleOpen : void 0,
859
1527
  onMouseLeave: interactive ? scheduleClose : void 0,
@@ -876,6 +1544,7 @@ function Term({ id, children, className }) {
876
1544
  "span",
877
1545
  {
878
1546
  ref: cardRef,
1547
+ "data-stepwise": "",
879
1548
  role: "tooltip",
880
1549
  id: tipId,
881
1550
  tabIndex: -1,
@@ -946,14 +1615,14 @@ function GlossaryAppendix({
946
1615
  if (bare) {
947
1616
  return /* @__PURE__ */ jsx(AppendixContext.Provider, { value: true, children: body });
948
1617
  }
949
- return /* @__PURE__ */ jsx(AppendixContext.Provider, { value: true, children: /* @__PURE__ */ jsxs("section", { "aria-label": title, className: cn("mt-16 border-t border-white/10 pt-10", className), children: [
1618
+ return /* @__PURE__ */ jsx(AppendixContext.Provider, { value: true, children: /* @__PURE__ */ jsxs("section", { "data-stepwise": "", "aria-label": title, className: cn("mt-16 border-t border-white/10 pt-10", className), children: [
950
1619
  /* @__PURE__ */ jsx("p", { className: cn("text-[11px] font-mono uppercase tracking-widest mb-6", theme.muted), children: title }),
951
1620
  body
952
1621
  ] }) });
953
1622
  }
954
1623
 
955
1624
  // src/components.tsx
956
- import { useEffect as useEffect10, useState as useState8 } from "react";
1625
+ import { useEffect as useEffect13, useState as useState10 } from "react";
957
1626
  import { createPortal as createPortal2 } from "react-dom";
958
1627
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
959
1628
  var TONE_SEGMENT_CLASS = defaultTheme.toneSegment;
@@ -975,7 +1644,7 @@ function CodeBlock({
975
1644
  theme = defaultTheme,
976
1645
  className
977
1646
  }) {
978
- return /* @__PURE__ */ jsx2("div", { className: cn(theme.codeBlock, className), children: lines.map((line, i) => {
1647
+ return /* @__PURE__ */ jsx2("div", { "data-stepwise": "", className: cn(theme.codeBlock, className), children: lines.map((line, i) => {
979
1648
  const commentStart = line.indexOf("//");
980
1649
  if (commentStart === 0) {
981
1650
  return /* @__PURE__ */ jsx2("div", { className: cn("whitespace-pre", theme.codeMuted), children: line }, i);
@@ -1022,7 +1691,7 @@ function StepScrubber({
1022
1691
  }
1023
1692
  };
1024
1693
  if (count === 0) {
1025
- return /* @__PURE__ */ jsx2("div", { className: cn("flex-1 flex items-center min-w-0", className), children: emptyHint && /* @__PURE__ */ jsx2("span", { className: cn("text-xs font-mono px-2", theme.muted), children: emptyHint }) });
1694
+ return /* @__PURE__ */ jsx2("div", { "data-stepwise": "", className: cn("flex-1 flex items-center min-w-0", className), children: emptyHint && /* @__PURE__ */ jsx2("span", { className: cn("text-xs font-mono px-2", theme.muted), children: emptyHint }) });
1026
1695
  }
1027
1696
  const valueText = labels?.frame ? labels.frame(index) : titleAt?.(index) ?? `frame ${index + 1} of ${count}`;
1028
1697
  const segment = (i) => /* @__PURE__ */ jsx2(
@@ -1064,6 +1733,7 @@ function StepScrubber({
1064
1733
  return /* @__PURE__ */ jsx2(
1065
1734
  "div",
1066
1735
  {
1736
+ "data-stepwise": "",
1067
1737
  role: "slider",
1068
1738
  tabIndex: 0,
1069
1739
  "aria-label": labels?.scrubber ?? "Frames",
@@ -1091,17 +1761,21 @@ function PlayerBar({
1091
1761
  className,
1092
1762
  theme = defaultTheme,
1093
1763
  groupOf,
1094
- labels
1764
+ labels,
1765
+ live
1095
1766
  }) {
1096
1767
  const L = {
1097
1768
  previous: labels?.previous ?? "Previous step",
1098
1769
  play: labels?.play ?? "Play",
1099
1770
  pause: labels?.pause ?? "Pause",
1100
1771
  replay: labels?.replay ?? "Replay",
1101
- next: labels?.next ?? "Next step"
1772
+ next: labels?.next ?? "Next step",
1773
+ live: labels?.live ?? "Live",
1774
+ goLive: labels?.goLive ?? "Go live"
1102
1775
  };
1776
+ const behind = Math.max(0, player.total - 1 - Math.max(player.index, 0));
1103
1777
  const playLabel = player.playing ? L.pause : player.total > 0 && player.atEnd ? L.replay : L.play;
1104
- return /* @__PURE__ */ jsxs2("div", { className: cn("flex items-center gap-2 p-2", className), children: [
1778
+ return /* @__PURE__ */ jsxs2("div", { "data-stepwise": "", className: cn("flex items-center gap-2 p-2", className), children: [
1105
1779
  leading,
1106
1780
  /* @__PURE__ */ jsx2(StepButton, { variant: "ghost", onClick: () => player.prev(), className: "h-7 px-2 text-xs", "aria-label": L.previous, children: "\u2190" }),
1107
1781
  /* @__PURE__ */ jsx2(StepButton, { variant: "outline", onClick: () => player.toggle(onEmpty), className: "h-7 px-3.5 text-[11px]", children: playLabel }),
@@ -1119,7 +1793,15 @@ function PlayerBar({
1119
1793
  groupOf
1120
1794
  }
1121
1795
  ),
1122
- /* @__PURE__ */ jsx2("span", { className: cn("text-[10px] font-mono w-11 text-right shrink-0", theme.muted), children: player.total > 0 ? `${player.index + 1}/${player.total}` : "\u2014" })
1796
+ /* @__PURE__ */ jsx2("span", { className: cn("text-[10px] font-mono w-11 text-right shrink-0", theme.muted), children: player.total > 0 ? `${player.index + 1}/${player.total}` : "\u2014" }),
1797
+ live && (player.live || behind === 0 ? /* @__PURE__ */ jsxs2("span", { role: "status", className: "flex items-center gap-1 text-[10px] font-mono uppercase text-primary shrink-0", children: [
1798
+ /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", className: cn("h-1.5 w-1.5 rounded-full bg-primary", player.playing && "motion-safe:animate-pulse") }),
1799
+ L.live
1800
+ ] }) : /* @__PURE__ */ jsxs2(StepButton, { variant: "ghost", onClick: () => player.goLive?.(), className: "h-7 px-2 text-[10px] font-mono shrink-0", children: [
1801
+ L.goLive,
1802
+ " +",
1803
+ behind
1804
+ ] }))
1123
1805
  ] });
1124
1806
  }
1125
1807
  function ChapterNav({
@@ -1163,6 +1845,7 @@ function ChapterNav({
1163
1845
  return /* @__PURE__ */ jsx2(
1164
1846
  "nav",
1165
1847
  {
1848
+ "data-stepwise": "",
1166
1849
  "aria-label": "Chapters",
1167
1850
  className: cn(
1168
1851
  "sticky top-20 md:top-24 z-30 rounded-sm shadow-[0_8px_24px_rgba(0,0,0,0.35)]",
@@ -1186,7 +1869,7 @@ function LessonPager({
1186
1869
  className,
1187
1870
  labels
1188
1871
  }) {
1189
- return /* @__PURE__ */ jsxs2("div", { className: cn("flex items-center gap-2", className), children: [
1872
+ return /* @__PURE__ */ jsxs2("div", { "data-stepwise": "", className: cn("flex items-center gap-2", className), children: [
1190
1873
  /* @__PURE__ */ jsxs2(
1191
1874
  StepButton,
1192
1875
  {
@@ -1223,8 +1906,8 @@ function TourSpotlight({
1223
1906
  target,
1224
1907
  theme = defaultTheme
1225
1908
  }) {
1226
- const [rect, setRect] = useState8(null);
1227
- useEffect10(() => {
1909
+ const [rect, setRect] = useState10(null);
1910
+ useEffect13(() => {
1228
1911
  if (!target) {
1229
1912
  setRect(null);
1230
1913
  return;
@@ -1256,7 +1939,7 @@ function TourSpotlight({
1256
1939
  }, [target]);
1257
1940
  if (!rect) return null;
1258
1941
  const pad = 6;
1259
- return /* @__PURE__ */ jsx2("div", { className: "fixed inset-0 z-40 pointer-events-none", children: /* @__PURE__ */ jsx2(
1942
+ return /* @__PURE__ */ jsx2("div", { "data-stepwise": "", className: "fixed inset-0 z-40 pointer-events-none", children: /* @__PURE__ */ jsx2(
1260
1943
  "div",
1261
1944
  {
1262
1945
  className: cn("absolute", theme.spotlight),
@@ -1280,7 +1963,8 @@ function TourCard({
1280
1963
  labels,
1281
1964
  className,
1282
1965
  theme = defaultTheme,
1283
- nextDisabled = false
1966
+ nextDisabled = false,
1967
+ feedback
1284
1968
  }) {
1285
1969
  const L = {
1286
1970
  title: labels?.title ?? "Guided tour",
@@ -1294,6 +1978,7 @@ function TourCard({
1294
1978
  "div",
1295
1979
  {
1296
1980
  ref: trapRef,
1981
+ "data-stepwise": "",
1297
1982
  role: "dialog",
1298
1983
  "aria-modal": "true",
1299
1984
  "aria-label": `${L.title} \xB7 ${index + 1}/${total}`,
@@ -1327,6 +2012,7 @@ function TourCard({
1327
2012
  /* @__PURE__ */ jsx2("p", { className: "text-sm font-bold text-foreground/95 mb-1.5", children: step.title }),
1328
2013
  /* @__PURE__ */ jsx2("div", { className: "text-[12px] font-mono text-foreground/70 leading-relaxed", children: step.body }),
1329
2014
  step.waitHint && /* @__PURE__ */ jsx2("p", { className: "text-[11px] font-mono text-accent mt-2 animate-pulse", children: step.waitHint }),
2015
+ /* @__PURE__ */ jsx2("div", { "aria-live": "polite", children: feedback != null && feedback !== false && /* @__PURE__ */ jsx2("p", { className: "text-[11px] font-mono text-foreground/70 mt-2", children: feedback }) }),
1330
2016
  /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2 mt-3 pt-3 border-t border-white/10", children: [
1331
2017
  /* @__PURE__ */ jsx2(StepButton, { variant: "outline", onClick: onBack, disabled: index === 0, className: "h-7 px-3 text-[11px]", children: L.back }),
1332
2018
  /* @__PURE__ */ jsx2(
@@ -1352,10 +2038,10 @@ function ShortcutsDialog({
1352
2038
  className,
1353
2039
  theme = defaultTheme
1354
2040
  }) {
1355
- const [mounted, setMounted] = useState8(false);
2041
+ const [mounted, setMounted] = useState10(false);
1356
2042
  const trapRef = useFocusTrap(open);
1357
- useEffect10(() => setMounted(true), []);
1358
- useEffect10(() => {
2043
+ useEffect13(() => setMounted(true), []);
2044
+ useEffect13(() => {
1359
2045
  if (!open) return;
1360
2046
  const onKey = (e) => {
1361
2047
  if (e.key === "Escape") onClose();
@@ -1368,6 +2054,7 @@ function ShortcutsDialog({
1368
2054
  /* @__PURE__ */ jsx2(
1369
2055
  "div",
1370
2056
  {
2057
+ "data-stepwise": "",
1371
2058
  className: cn("fixed inset-0 z-50 flex items-center justify-center p-4", theme.overlay, className),
1372
2059
  onClick: onClose,
1373
2060
  children: /* @__PURE__ */ jsxs2(
@@ -1424,18 +2111,35 @@ export {
1424
2111
  Term,
1425
2112
  TourCard,
1426
2113
  TourSpotlight,
2114
+ alignTracks,
1427
2115
  cookieStore,
2116
+ createRng,
2117
+ createTrace,
1428
2118
  defaultTheme,
2119
+ fail,
1429
2120
  flattenChapters,
1430
2121
  localStorageStore,
1431
2122
  memoryStore,
2123
+ pass,
2124
+ pending,
2125
+ readHashParam,
2126
+ reanchor,
2127
+ runSimulation,
2128
+ toVerdict,
1432
2129
  useChapters,
1433
2130
  useDiscoveries,
2131
+ useEvaluator,
2132
+ useFadeIn,
1434
2133
  useFocusTrap,
1435
2134
  useGlobalKeys,
1436
2135
  useGuidedTour,
2136
+ useLessonStream,
1437
2137
  usePrefersReducedMotion,
2138
+ useSimulation,
1438
2139
  useStepPlayer,
1439
- useSteps
2140
+ useSteps,
2141
+ useSyncedTracks,
2142
+ useTrace,
2143
+ writeHashParam
1440
2144
  };
1441
2145
  //# sourceMappingURL=index.js.map