@sphynxoid/stepwise 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,1441 @@
1
+ "use client";
2
+
3
+ // src/use-steps.ts
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ function useSteps(items, opts) {
6
+ const count = typeof items === "number" ? items : items.length;
7
+ const countRef = useRef(count);
8
+ countRef.current = count;
9
+ const onChangeRef = useRef(opts?.onChange);
10
+ onChangeRef.current = opts?.onChange;
11
+ const [cursor, setCursor] = useState(() => {
12
+ const initial = opts?.initialIndex ?? 0;
13
+ return count === 0 ? -1 : Math.max(0, Math.min(initial, count - 1));
14
+ });
15
+ const index = count === 0 ? -1 : Math.max(0, Math.min(cursor, count - 1));
16
+ const indexRef = useRef(index);
17
+ indexRef.current = index;
18
+ useEffect(() => {
19
+ if (count === 0) {
20
+ if (cursor !== -1) setCursor(-1);
21
+ } else if (cursor < 0 || cursor > count - 1) {
22
+ setCursor(Math.max(0, Math.min(cursor, count - 1)));
23
+ }
24
+ }, [count, cursor]);
25
+ const go = useCallback((i) => {
26
+ const c = countRef.current;
27
+ if (c === 0) return;
28
+ const clamped = Math.max(0, Math.min(i, c - 1));
29
+ if (clamped === indexRef.current) return;
30
+ indexRef.current = clamped;
31
+ setCursor(clamped);
32
+ onChangeRef.current?.(clamped);
33
+ }, []);
34
+ const next = useCallback(() => {
35
+ go(indexRef.current + 1);
36
+ }, [go]);
37
+ const prev = useCallback(() => {
38
+ go(indexRef.current - 1);
39
+ }, [go]);
40
+ const first = useCallback(() => {
41
+ go(0);
42
+ }, [go]);
43
+ return useMemo(
44
+ () => ({
45
+ count,
46
+ index,
47
+ atStart: index <= 0,
48
+ atEnd: count > 0 && index >= count - 1,
49
+ canNext: count > 0 && index < count - 1,
50
+ canPrev: count > 0 && index > 0,
51
+ go,
52
+ next,
53
+ prev,
54
+ first
55
+ }),
56
+ [count, index, go, next, prev, first]
57
+ );
58
+ }
59
+
60
+ // src/use-step-player.ts
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 }) {
63
+ const core = useSteps(total);
64
+ const [started, setStarted] = useState2(autoPlay && total > 0);
65
+ const [playing, setPlaying] = useState2(autoPlay && total > 0);
66
+ const [speed, setSpeed] = useState2(1);
67
+ const [ended, setEnded] = useState2(false);
68
+ const endedRef = useRef2(false);
69
+ const onEndRef = useRef2(onEnd);
70
+ onEndRef.current = onEnd;
71
+ const markEnded = useCallback2(() => {
72
+ if (endedRef.current) return;
73
+ endedRef.current = true;
74
+ setEnded(true);
75
+ onEndRef.current?.();
76
+ }, []);
77
+ const clearEnded = useCallback2(() => {
78
+ if (!endedRef.current) return;
79
+ endedRef.current = false;
80
+ setEnded(false);
81
+ }, []);
82
+ const index = started && core.index >= 0 ? core.index : -1;
83
+ const atEnd = total > 0 && index >= 0 && index >= total - 1;
84
+ const engineRef = useRef2(engine);
85
+ engineRef.current = engine;
86
+ const totalRef = useRef2(total);
87
+ totalRef.current = total;
88
+ const engineApiRef = useRef2({});
89
+ useEffect2(() => {
90
+ if (!engine) return;
91
+ const handle = engine.connect({
92
+ get total() {
93
+ return totalRef.current;
94
+ },
95
+ onFrame: (f) => core.go(f),
96
+ onEnd: () => {
97
+ markEnded();
98
+ setPlaying(false);
99
+ }
100
+ });
101
+ engineApiRef.current = {
102
+ play: () => handle.play(),
103
+ pause: () => handle.pause(),
104
+ seek: (f) => handle.seek(f),
105
+ rate: (r) => handle.setRate(r)
106
+ };
107
+ return () => {
108
+ engineApiRef.current = {};
109
+ handle.disconnect();
110
+ };
111
+ }, [engine]);
112
+ const start = useCallback2(() => {
113
+ setStarted(true);
114
+ setPlaying(true);
115
+ clearEnded();
116
+ core.first();
117
+ if (engineRef.current) {
118
+ engineApiRef.current.seek?.(0);
119
+ engineApiRef.current.play?.();
120
+ }
121
+ }, [core]);
122
+ const reset = useCallback2(() => {
123
+ setStarted(false);
124
+ setPlaying(false);
125
+ clearEnded();
126
+ if (engineRef.current) engineApiRef.current.pause?.();
127
+ }, [clearEnded]);
128
+ const seek = useCallback2(
129
+ (f) => {
130
+ if (total === 0) return;
131
+ setStarted(true);
132
+ setPlaying(false);
133
+ clearEnded();
134
+ core.go(Math.max(0, Math.min(f, total - 1)));
135
+ if (engineRef.current) {
136
+ engineApiRef.current.pause?.();
137
+ engineApiRef.current.seek?.(f);
138
+ }
139
+ },
140
+ [total, core, clearEnded]
141
+ );
142
+ const next = useCallback2(
143
+ (onEmpty) => {
144
+ setPlaying(false);
145
+ if (total === 0) {
146
+ onEmpty?.();
147
+ return;
148
+ }
149
+ setStarted(true);
150
+ clearEnded();
151
+ core.next();
152
+ if (engineRef.current) {
153
+ engineApiRef.current.pause?.();
154
+ engineApiRef.current.seek?.(Math.min(core.index + 1, total - 1));
155
+ }
156
+ },
157
+ [total, core, clearEnded]
158
+ );
159
+ const prev = useCallback2(() => {
160
+ setPlaying(false);
161
+ clearEnded();
162
+ core.prev();
163
+ if (engineRef.current) {
164
+ engineApiRef.current.pause?.();
165
+ engineApiRef.current.seek?.(Math.max(0, core.index - 1));
166
+ }
167
+ }, [core, clearEnded]);
168
+ const toggle = useCallback2(
169
+ (onEmpty) => {
170
+ if (total === 0) {
171
+ onEmpty?.();
172
+ return;
173
+ }
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
+ }
183
+ return;
184
+ }
185
+ const nextPlaying = !playing;
186
+ setPlaying(nextPlaying);
187
+ setStarted(true);
188
+ if (engineRef.current) {
189
+ if (nextPlaying) engineApiRef.current.play?.();
190
+ else engineApiRef.current.pause?.();
191
+ }
192
+ },
193
+ [total, playing, atEnd, core, clearEnded]
194
+ );
195
+ const changeSpeed = useCallback2((s) => {
196
+ setSpeed(s);
197
+ if (engineRef.current) engineApiRef.current.rate?.(s);
198
+ }, []);
199
+ useEffect2(() => {
200
+ clearEnded();
201
+ }, [total, clearEnded]);
202
+ useEffect2(() => {
203
+ if (engineRef.current) return;
204
+ if (!playing || !started || total === 0) return;
205
+ if (core.index >= total - 1) {
206
+ markEnded();
207
+ setPlaying(false);
208
+ return;
209
+ }
210
+ const id = setTimeout(() => core.next(), baseDelayMs / speed);
211
+ 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;
216
+ const lastWrittenHash = useRef2("");
217
+ const readHash = useCallback2(() => {
218
+ if (!hashKey || typeof window === "undefined" || total === 0) return;
219
+ const hash = window.location.hash;
220
+ 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) {
225
+ setStarted(true);
226
+ coreGoRef.current(f);
227
+ }
228
+ }, [hashKey, total]);
229
+ useEffect2(() => {
230
+ readHash();
231
+ const onHashChange = () => readHash();
232
+ window.addEventListener("hashchange", onHashChange);
233
+ return () => window.removeEventListener("hashchange", onHashChange);
234
+ }, [readHash]);
235
+ useEffect2(() => {
236
+ if (!syncHash || index < 0 || typeof window === "undefined") return;
237
+ const next2 = `#${syncHash}=${index}`;
238
+ if (window.location.hash !== next2) {
239
+ lastWrittenHash.current = next2;
240
+ window.history.replaceState(null, "", next2);
241
+ }
242
+ }, [syncHash, index]);
243
+ return useMemo2(
244
+ () => ({
245
+ total,
246
+ frame: index,
247
+ index,
248
+ playing,
249
+ speed,
250
+ atEnd,
251
+ ended,
252
+ start,
253
+ reset,
254
+ seek,
255
+ next,
256
+ prev,
257
+ toggle,
258
+ setPlaying,
259
+ setSpeed: changeSpeed
260
+ }),
261
+ [total, index, playing, speed, atEnd, ended, start, reset, seek, next, prev, toggle, changeSpeed]
262
+ );
263
+ }
264
+
265
+ // src/use-guided-tour.ts
266
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
267
+ function useGuidedTour(steps, opts) {
268
+ const [running, setRunning] = useState3(false);
269
+ const runningRef = useRef3(running);
270
+ runningRef.current = running;
271
+ const core = useSteps(steps.length);
272
+ const stepsRef = useRef3(steps);
273
+ stepsRef.current = steps;
274
+ const driverRef = useRef3(opts?.driver);
275
+ driverRef.current = opts?.driver;
276
+ const coreGoRef = useRef3(core.go);
277
+ coreGoRef.current = core.go;
278
+ const isSkipped = useCallback3(
279
+ (i) => stepsRef.current[i]?.when?.() === false,
280
+ []
281
+ );
282
+ const settle = useCallback3(
283
+ (start2, dir) => {
284
+ const len = stepsRef.current.length;
285
+ let i = start2;
286
+ while (i >= 0 && i < len && isSkipped(i)) i += dir;
287
+ if (i >= 0 && i < len) return i;
288
+ if (dir === 1) return null;
289
+ for (let j = 0; j < len; j++) if (!isSkipped(j)) return j;
290
+ return null;
291
+ },
292
+ [isSkipped]
293
+ );
294
+ const arrive = useCallback3(
295
+ (i, dir) => {
296
+ const target = settle(i, dir);
297
+ if (target === null) {
298
+ setRunning(false);
299
+ return;
300
+ }
301
+ setRunning(true);
302
+ coreGoRef.current(target);
303
+ },
304
+ [settle]
305
+ );
306
+ const goTo = useCallback3(
307
+ (i) => {
308
+ if (stepsRef.current.length === 0) return;
309
+ arrive(Math.max(0, Math.min(i, stepsRef.current.length - 1)), 1);
310
+ },
311
+ [arrive]
312
+ );
313
+ const goToId = useCallback3(
314
+ (id) => {
315
+ const i = stepsRef.current.findIndex((s) => s.id === id);
316
+ if (i >= 0) goTo(i);
317
+ },
318
+ [goTo]
319
+ );
320
+ const index = running ? core.index : null;
321
+ useEffect3(() => {
322
+ if (!running) return;
323
+ stepsRef.current[core.index]?.onEnter?.();
324
+ }, [running, core.index]);
325
+ useEffect3(() => {
326
+ const driver = driverRef.current;
327
+ if (!driver) return;
328
+ if (!running) {
329
+ driver.hide();
330
+ return;
331
+ }
332
+ const step = stepsRef.current[core.index];
333
+ if (!step) {
334
+ driver.hide();
335
+ return;
336
+ }
337
+ driver.show(step, {
338
+ index: core.index,
339
+ total: stepsRef.current.length,
340
+ next: () => goTo(core.index + 1),
341
+ back: () => goTo(core.index - 1),
342
+ end: () => setRunning(false)
343
+ });
344
+ }, [running, core.index, goTo, core]);
345
+ useEffect3(() => () => driverRef.current?.hide(), []);
346
+ const start = useCallback3(() => {
347
+ if (stepsRef.current.length === 0) return;
348
+ arrive(0, 1);
349
+ }, [arrive]);
350
+ const end = useCallback3(() => setRunning(false), []);
351
+ const resolveNext = useCallback3((i) => {
352
+ const list = stepsRef.current;
353
+ const edge = list[i]?.next?.();
354
+ if (edge != null) {
355
+ const t = list.findIndex((s) => s.id === edge);
356
+ if (t >= 0) return t;
357
+ }
358
+ return i + 1;
359
+ }, []);
360
+ const next = useCallback3(() => {
361
+ if (!runningRef.current) return;
362
+ const i = core.index;
363
+ if (stepsRef.current[i]?.gate?.() === false) return;
364
+ const target = resolveNext(i);
365
+ if (target > stepsRef.current.length - 1) {
366
+ setRunning(false);
367
+ return;
368
+ }
369
+ arrive(target, 1);
370
+ }, [core, arrive, resolveNext]);
371
+ const back = useCallback3(() => {
372
+ if (!runningRef.current) return;
373
+ const i = core.index;
374
+ if (i <= 0) return;
375
+ arrive(i - 1, -1);
376
+ }, [core, arrive]);
377
+ 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 };
380
+ }
381
+
382
+ // src/use-chapters.ts
383
+ import { useCallback as useCallback4, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4, useState as useState4 } from "react";
384
+
385
+ // src/flatten.ts
386
+ function flattenChapters(chapters, depth = 0) {
387
+ const out = [];
388
+ const walk = (list, depth2, parentIndex, rootIndex) => {
389
+ for (const chapter of list) {
390
+ const index = out.length;
391
+ const effectiveRoot = depth2 === 0 ? index : rootIndex;
392
+ out.push({ chapter, depth: depth2, rootIndex: effectiveRoot, parentIndex });
393
+ if (chapter.children?.length) walk(chapter.children, depth2 + 1, index, effectiveRoot);
394
+ }
395
+ };
396
+ walk(chapters, depth, -1, -1);
397
+ return out;
398
+ }
399
+
400
+ // src/use-chapters.ts
401
+ function useChapters(chapters) {
402
+ const [mounted, setMounted] = useState4(false);
403
+ const flat = useMemo3(() => flattenChapters(chapters), [chapters]);
404
+ const flatRef = useRef4(flat);
405
+ flatRef.current = flat;
406
+ const [active, setActive] = useState4(0);
407
+ const activeRef = useRef4(0);
408
+ activeRef.current = active;
409
+ const core = useSteps(flat.length, { initialIndex: 0 });
410
+ const coreGoRef = useRef4(core.go);
411
+ coreGoRef.current = core.go;
412
+ useEffect4(() => {
413
+ if (!mounted) return;
414
+ let raf = 0;
415
+ const update = () => {
416
+ raf = 0;
417
+ const flat2 = flatRef.current;
418
+ const mid = window.innerHeight / 2;
419
+ let best = -1;
420
+ for (let i = 0; i < flat2.length; i++) {
421
+ const el = document.getElementById(flat2[i].chapter.id);
422
+ if (!el) continue;
423
+ const rect = el.getBoundingClientRect();
424
+ if (rect.top <= mid && rect.bottom >= mid) best = i;
425
+ }
426
+ if (best === -1) {
427
+ for (let i = 0; i < flat2.length; i++) {
428
+ const el = document.getElementById(flat2[i].chapter.id);
429
+ if (el && el.getBoundingClientRect().top <= mid) best = i;
430
+ }
431
+ if (best === -1) best = 0;
432
+ }
433
+ if (best !== activeRef.current) {
434
+ setActive(best);
435
+ coreGoRef.current(best);
436
+ }
437
+ };
438
+ const onScroll = () => {
439
+ if (!raf) raf = requestAnimationFrame(update);
440
+ };
441
+ update();
442
+ window.addEventListener("scroll", onScroll, { passive: true });
443
+ window.addEventListener("resize", onScroll);
444
+ return () => {
445
+ window.removeEventListener("scroll", onScroll);
446
+ window.removeEventListener("resize", onScroll);
447
+ if (raf) cancelAnimationFrame(raf);
448
+ };
449
+ }, [mounted, flat.length]);
450
+ useEffect4(() => setMounted(true), []);
451
+ const goTo = useCallback4((i) => {
452
+ const entry = flatRef.current[i];
453
+ if (!entry) return;
454
+ setActive(i);
455
+ coreGoRef.current(i);
456
+ const el = document.getElementById(entry.chapter.id);
457
+ if (!el) return;
458
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
459
+ }, []);
460
+ const next = useCallback4(() => {
461
+ if (activeRef.current < flatRef.current.length - 1) goTo(activeRef.current + 1);
462
+ }, [goTo]);
463
+ const prev = useCallback4(() => {
464
+ if (activeRef.current > 0) goTo(activeRef.current - 1);
465
+ }, [goTo]);
466
+ return { active: core.index, goTo, next, prev };
467
+ }
468
+
469
+ // src/use-global-keys.ts
470
+ import { useEffect as useEffect5, useRef as useRef5 } from "react";
471
+ var DEFAULT_IGNORED_TAGS = ["INPUT", "TEXTAREA", "SELECT", "BUTTON"];
472
+ function useGlobalKeys(map, opts) {
473
+ const mapRef = useRef5(map);
474
+ mapRef.current = map;
475
+ const ignoreRef = useRef5(new Set(opts?.ignoreTags ?? DEFAULT_IGNORED_TAGS));
476
+ const enabledRef = useRef5(opts?.enabled ?? true);
477
+ enabledRef.current = opts?.enabled ?? true;
478
+ useEffect5(() => {
479
+ const onKey = (e) => {
480
+ const gate = enabledRef.current;
481
+ const enabled = typeof gate === "function" ? gate() : gate;
482
+ if (!enabled && e.key !== "Escape") return;
483
+ const tag = e.target?.tagName;
484
+ if (e.key !== "Escape" && tag && ignoreRef.current.has(tag)) return;
485
+ const fn = mapRef.current[e.key] ?? mapRef.current[e.key.toLowerCase()];
486
+ if (!fn) return;
487
+ if (e.key !== "Escape") e.preventDefault();
488
+ fn(e);
489
+ };
490
+ window.addEventListener("keydown", onKey);
491
+ return () => window.removeEventListener("keydown", onKey);
492
+ }, []);
493
+ }
494
+
495
+ // src/use-discoveries.ts
496
+ import { useCallback as useCallback5, useEffect as useEffect6, useMemo as useMemo4, useRef as useRef6, useState as useState5 } from "react";
497
+ function localStorageStore(key = "stepwise.discoveries") {
498
+ return {
499
+ read() {
500
+ if (typeof window === "undefined") return [];
501
+ try {
502
+ const parsed = JSON.parse(window.localStorage.getItem(key) ?? "[]");
503
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
504
+ } catch {
505
+ return [];
506
+ }
507
+ },
508
+ write(ids) {
509
+ if (typeof window === "undefined") return;
510
+ try {
511
+ window.localStorage.setItem(key, JSON.stringify(ids));
512
+ } catch {
513
+ }
514
+ }
515
+ };
516
+ }
517
+ function cookieStore(key = "stepwise.discoveries") {
518
+ const read = () => {
519
+ if (typeof document === "undefined") return [];
520
+ const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`));
521
+ if (!match) return [];
522
+ try {
523
+ const parsed = JSON.parse(decodeURIComponent(match[1]));
524
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
525
+ } catch {
526
+ return [];
527
+ }
528
+ };
529
+ return {
530
+ read,
531
+ write(ids) {
532
+ if (typeof document === "undefined") return;
533
+ document.cookie = `${key}=${encodeURIComponent(JSON.stringify(ids))}; max-age=31536000; path=/; SameSite=Lax`;
534
+ }
535
+ };
536
+ }
537
+ function memoryStore(seed = []) {
538
+ let ids = [...seed];
539
+ return {
540
+ read: () => [...ids],
541
+ write(next) {
542
+ ids = [...next];
543
+ }
544
+ };
545
+ }
546
+ function useDiscoveries(opts) {
547
+ const defaultStoreRef = useRef6(null);
548
+ const storeRef = useRef6(null);
549
+ if (opts?.store) {
550
+ storeRef.current = opts.store;
551
+ } else {
552
+ if (!defaultStoreRef.current) defaultStoreRef.current = localStorageStore(opts?.key);
553
+ storeRef.current = defaultStoreRef.current;
554
+ }
555
+ const [ids, setIds] = useState5(() => /* @__PURE__ */ new Set());
556
+ const [ready, setReady] = useState5(false);
557
+ const idsRef = useRef6(ids);
558
+ idsRef.current = ids;
559
+ useEffect6(() => {
560
+ setIds(new Set(storeRef.current.read()));
561
+ setReady(true);
562
+ }, []);
563
+ const markDiscovered = useCallback5((id) => {
564
+ const additions = Array.isArray(id) ? id : [id];
565
+ const merged = new Set(idsRef.current);
566
+ let changed = false;
567
+ for (const x of additions) {
568
+ if (!merged.has(x)) {
569
+ merged.add(x);
570
+ changed = true;
571
+ }
572
+ }
573
+ if (!changed) return;
574
+ idsRef.current = merged;
575
+ setIds(merged);
576
+ storeRef.current.write([...merged]);
577
+ }, []);
578
+ const reset = useCallback5(() => {
579
+ const empty = /* @__PURE__ */ new Set();
580
+ idsRef.current = empty;
581
+ setIds(empty);
582
+ storeRef.current.write([]);
583
+ }, []);
584
+ const isDiscovered = useCallback5((id) => idsRef.current.has(id), []);
585
+ return useMemo4(
586
+ () => ({ discovered: ids, ready, isDiscovered, markDiscovered, reset }),
587
+ [ids, ready, isDiscovered, markDiscovered, reset]
588
+ );
589
+ }
590
+
591
+ // src/use-focus-trap.ts
592
+ import { useEffect as useEffect7, useRef as useRef7 } from "react";
593
+ function useFocusTrap(active) {
594
+ const ref = useRef7(null);
595
+ useEffect7(() => {
596
+ if (!active) return;
597
+ const container = ref.current;
598
+ if (!container) return;
599
+ const previous = document.activeElement;
600
+ container.focus({ preventScroll: true });
601
+ const onKey = (e) => {
602
+ if (e.key !== "Tab") return;
603
+ const focusables = Array.from(
604
+ container.querySelectorAll(
605
+ 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
606
+ )
607
+ ).filter((el) => el.offsetParent !== null || el === container);
608
+ if (focusables.length === 0) return;
609
+ const first = focusables[0];
610
+ const last = focusables[focusables.length - 1];
611
+ const current = document.activeElement;
612
+ if (e.shiftKey && (current === first || !container.contains(current))) {
613
+ e.preventDefault();
614
+ last.focus();
615
+ } else if (!e.shiftKey && (current === last || !container.contains(current))) {
616
+ e.preventDefault();
617
+ first.focus();
618
+ }
619
+ };
620
+ document.addEventListener("keydown", onKey, true);
621
+ return () => {
622
+ document.removeEventListener("keydown", onKey, true);
623
+ previous?.focus({ preventScroll: true });
624
+ };
625
+ }, [active]);
626
+ return ref;
627
+ }
628
+
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
+ // src/glossary.tsx
644
+ import {
645
+ createContext,
646
+ useCallback as useCallback6,
647
+ useContext,
648
+ useEffect as useEffect9,
649
+ useId,
650
+ useLayoutEffect,
651
+ useMemo as useMemo5,
652
+ useRef as useRef8,
653
+ useState as useState7
654
+ } from "react";
655
+ import { createPortal } from "react-dom";
656
+
657
+ // src/cn.ts
658
+ import { clsx } from "clsx";
659
+ import { twMerge } from "tailwind-merge";
660
+ function cn(...inputs) {
661
+ return twMerge(clsx(inputs));
662
+ }
663
+
664
+ // src/theme.ts
665
+ var defaultTheme = {
666
+ toneSegment: {
667
+ neutral: "bg-white/10",
668
+ accent: "bg-accent/70",
669
+ good: "bg-primary",
670
+ bad: "bg-destructive/70",
671
+ write: "bg-foreground/30",
672
+ idle: "bg-white/5"
673
+ },
674
+ toneChip: {
675
+ neutral: "text-foreground/50 border-white/10",
676
+ accent: "text-accent border-accent/40",
677
+ good: "text-primary border-primary/40",
678
+ bad: "text-destructive border-destructive/40",
679
+ write: "text-foreground/70 border-white/20",
680
+ idle: "text-foreground/30 border-white/10"
681
+ },
682
+ buttonBase: "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm font-mono transition-colors duration-200 disabled:pointer-events-none disabled:opacity-50 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
683
+ buttonVariants: {
684
+ ghost: "border border-transparent text-foreground/70 hover:bg-white/5 hover:text-foreground",
685
+ outline: "border border-primary/40 bg-background/40 text-foreground/85 hover:border-primary/70 hover:text-foreground",
686
+ primary: "border border-primary bg-primary/90 text-primary-foreground hover:bg-primary"
687
+ },
688
+ surface: "border border-white/10 bg-background/85 backdrop-blur-md",
689
+ overlay: "bg-black/70 backdrop-blur-sm",
690
+ codeBlock: "rounded-sm border border-white/10 bg-black/40 px-2.5 py-2 font-mono text-[11px] leading-5 overflow-x-auto",
691
+ codeMuted: "text-foreground/35",
692
+ kbd: "font-mono text-[10px] px-1.5 py-0.5 border border-white/20 bg-white/5 rounded-sm text-foreground/80",
693
+ spotlight: "rounded-sm ring-2 ring-primary/80 transition-all duration-300",
694
+ muted: "text-foreground/40"
695
+ };
696
+
697
+ // src/glossary.tsx
698
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
699
+ var MAX_DEPTH = 4;
700
+ var CARD_WIDTH = 288;
701
+ var OPEN_DELAY_MS = 60;
702
+ var CLOSE_GRACE_MS = 120;
703
+ var GlossaryContext = createContext(null);
704
+ var DepthContext = createContext(0);
705
+ var AppendixContext = createContext(false);
706
+ function GlossaryProvider({
707
+ entries,
708
+ theme = defaultTheme,
709
+ children
710
+ }) {
711
+ const value = useMemo5(
712
+ () => ({ byId: new Map(entries.map((e) => [e.id, e])), entries, theme }),
713
+ [entries, theme]
714
+ );
715
+ return /* @__PURE__ */ jsx(GlossaryContext.Provider, { value, children });
716
+ }
717
+ function Term({ id, children, className }) {
718
+ const ctx = useContext(GlossaryContext);
719
+ const depth = useContext(DepthContext);
720
+ 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);
729
+ const tipId = useId();
730
+ const display = children ?? entry?.term ?? id;
731
+ const closeNow = useCallback6(() => {
732
+ window.clearTimeout(openTimer.current);
733
+ window.clearTimeout(closeTimer.current);
734
+ openRef.current = false;
735
+ setOpen(false);
736
+ }, []);
737
+ const openNow = useCallback6((focusCard = false) => {
738
+ window.clearTimeout(closeTimer.current);
739
+ window.clearTimeout(openTimer.current);
740
+ openRef.current = true;
741
+ setOpen(true);
742
+ if (focusCard) {
743
+ requestAnimationFrame(() => cardRef.current?.focus({ preventScroll: true }));
744
+ }
745
+ }, []);
746
+ const scheduleOpen = useCallback6(() => {
747
+ window.clearTimeout(closeTimer.current);
748
+ if (openRef.current) return;
749
+ window.clearTimeout(openTimer.current);
750
+ openTimer.current = window.setTimeout(() => openNow(), OPEN_DELAY_MS);
751
+ }, [openNow]);
752
+ const scheduleClose = useCallback6(() => {
753
+ window.clearTimeout(openTimer.current);
754
+ window.clearTimeout(closeTimer.current);
755
+ closeTimer.current = window.setTimeout(closeNow, CLOSE_GRACE_MS);
756
+ }, [closeNow]);
757
+ useLayoutEffect(() => {
758
+ if (!open) {
759
+ setPos(null);
760
+ return;
761
+ }
762
+ let raf = 0;
763
+ const update = () => {
764
+ raf = 0;
765
+ const trigger = triggerRef.current;
766
+ const card = cardRef.current;
767
+ if (!trigger) return;
768
+ const r = trigger.getBoundingClientRect();
769
+ const margin = 8;
770
+ const gap = 8;
771
+ const cw = card?.offsetWidth ?? CARD_WIDTH;
772
+ const ch = card?.offsetHeight ?? 160;
773
+ let top = r.bottom + gap;
774
+ if (top + ch > window.innerHeight - margin) {
775
+ top = r.top - gap - ch;
776
+ if (top < margin) top = margin;
777
+ }
778
+ let left = r.left;
779
+ left = Math.max(margin, Math.min(left, window.innerWidth - cw - margin));
780
+ setPos({ left, top });
781
+ };
782
+ const onMove = () => {
783
+ if (!raf) raf = requestAnimationFrame(update);
784
+ };
785
+ update();
786
+ window.addEventListener("scroll", onMove, { passive: true });
787
+ window.addEventListener("resize", onMove);
788
+ return () => {
789
+ window.removeEventListener("scroll", onMove);
790
+ window.removeEventListener("resize", onMove);
791
+ if (raf) cancelAnimationFrame(raf);
792
+ };
793
+ }, [open]);
794
+ useEffect9(() => {
795
+ if (!open) return;
796
+ const onPointerDown = (e) => {
797
+ const target = e.target;
798
+ if (wrapperRef.current?.contains(target)) return;
799
+ if (cardRef.current?.contains(target)) return;
800
+ closeNow();
801
+ };
802
+ document.addEventListener("pointerdown", onPointerDown);
803
+ return () => document.removeEventListener("pointerdown", onPointerDown);
804
+ }, [open, closeNow]);
805
+ useEffect9(() => () => {
806
+ window.clearTimeout(openTimer.current);
807
+ window.clearTimeout(closeTimer.current);
808
+ }, []);
809
+ const capped = depth >= MAX_DEPTH;
810
+ const inAppendix = useContext(AppendixContext);
811
+ const interactive = !capped && !inAppendix;
812
+ const t = ctx?.theme ?? defaultTheme;
813
+ if (inAppendix) {
814
+ const linked = ctx?.byId.get(id);
815
+ if (!linked) return /* @__PURE__ */ jsx("span", { className, children: display });
816
+ return /* @__PURE__ */ jsx(
817
+ "a",
818
+ {
819
+ href: `#glossary-${linked.id}`,
820
+ title: linked.term,
821
+ className: cn("text-accent underline underline-offset-4 hover:text-primary", className),
822
+ children: display
823
+ }
824
+ );
825
+ }
826
+ const triggerProps = interactive ? {
827
+ role: "button",
828
+ tabIndex: 0,
829
+ "aria-expanded": open,
830
+ "aria-describedby": open ? tipId : void 0,
831
+ onMouseEnter: scheduleOpen,
832
+ onMouseLeave: scheduleClose,
833
+ onFocus: () => openNow(),
834
+ onBlur: (e) => {
835
+ const next = e.relatedTarget;
836
+ if (next && (wrapperRef.current?.contains(next) || cardRef.current?.contains(next))) return;
837
+ scheduleClose();
838
+ },
839
+ onClick: () => openNow(),
840
+ onKeyDown: (e) => {
841
+ if (e.key === "Escape") {
842
+ e.stopPropagation();
843
+ closeNow();
844
+ } else if ((e.key === "Enter" || e.key === " ") && !open) {
845
+ e.preventDefault();
846
+ openNow(true);
847
+ }
848
+ }
849
+ } : {};
850
+ if (!ctx || !entry) {
851
+ return /* @__PURE__ */ jsx("span", { className: cn("text-foreground/85", className), children: display });
852
+ }
853
+ return /* @__PURE__ */ jsxs(
854
+ "span",
855
+ {
856
+ ref: wrapperRef,
857
+ className: "relative inline",
858
+ onMouseEnter: interactive ? scheduleOpen : void 0,
859
+ onMouseLeave: interactive ? scheduleClose : void 0,
860
+ children: [
861
+ /* @__PURE__ */ jsx(
862
+ "span",
863
+ {
864
+ ref: triggerRef,
865
+ ...triggerProps,
866
+ className: cn(
867
+ "border-b border-dotted",
868
+ interactive ? "border-primary/50 text-foreground/85 hover:text-primary focus-visible:text-primary cursor-help" : "text-foreground/70",
869
+ className
870
+ ),
871
+ children: display
872
+ }
873
+ ),
874
+ open && createPortal(
875
+ /* @__PURE__ */ jsx(DepthContext.Provider, { value: depth + 1, children: /* @__PURE__ */ jsxs(
876
+ "span",
877
+ {
878
+ ref: cardRef,
879
+ role: "tooltip",
880
+ id: tipId,
881
+ tabIndex: -1,
882
+ onKeyDown: (e) => {
883
+ if (e.key === "Escape") {
884
+ e.stopPropagation();
885
+ closeNow();
886
+ triggerRef.current?.focus({ preventScroll: true });
887
+ }
888
+ },
889
+ onMouseEnter: interactive ? () => window.clearTimeout(closeTimer.current) : void 0,
890
+ onMouseLeave: interactive ? scheduleClose : void 0,
891
+ className: cn(
892
+ "fixed z-50 block w-72 max-w-[calc(100vw-1rem)] rounded-sm p-3 shadow-2xl outline-none motion-reduce:transition-none",
893
+ "transition-opacity duration-150",
894
+ t.surface,
895
+ "border-white/15 bg-popover",
896
+ pos === null && "invisible"
897
+ ),
898
+ style: pos ? { left: pos.left, top: pos.top, maxHeight: "min(60vh, 20rem)", overflowY: "auto" } : { visibility: "hidden" },
899
+ children: [
900
+ /* @__PURE__ */ jsx("span", { className: "mb-1 block font-mono text-[11px] uppercase tracking-widest text-primary/90", children: entry.term }),
901
+ /* @__PURE__ */ jsx("span", { className: "block text-[11px] font-mono leading-relaxed text-foreground/70", children: entry.definition })
902
+ ]
903
+ }
904
+ ) }),
905
+ document.body
906
+ )
907
+ ]
908
+ }
909
+ );
910
+ }
911
+ function GlossaryAppendix({
912
+ entries,
913
+ title = "Glossary",
914
+ intro,
915
+ footer,
916
+ columns = 2,
917
+ bare = false,
918
+ className,
919
+ entryClassName,
920
+ renderEntry
921
+ }) {
922
+ const ctxEntries = useContext(GlossaryContext)?.entries;
923
+ const theme = useContext(GlossaryContext)?.theme ?? defaultTheme;
924
+ const list = entries ?? ctxEntries;
925
+ if (!list || list.length === 0) return null;
926
+ const grid = /* @__PURE__ */ jsx(
927
+ "div",
928
+ {
929
+ className: cn(
930
+ "grid grid-cols-1 gap-x-10 gap-y-8",
931
+ columns === 2 ? "xl:grid-cols-2" : "grid-cols-1"
932
+ ),
933
+ children: list.map(
934
+ (entry) => renderEntry ? /* @__PURE__ */ jsx("div", { id: `glossary-${entry.id}`, className: "scroll-mt-40", children: renderEntry(entry) }, entry.id) : /* @__PURE__ */ jsxs("div", { id: `glossary-${entry.id}`, className: cn("scroll-mt-40", entryClassName), children: [
935
+ /* @__PURE__ */ jsx("h3", { className: "font-mono text-[11px] uppercase tracking-widest text-primary/90 mb-1.5", children: entry.term }),
936
+ /* @__PURE__ */ jsx("div", { className: "text-[12px] font-mono text-foreground/60 leading-relaxed [&_strong]:text-foreground/80", children: entry.definition })
937
+ ] }, entry.id)
938
+ )
939
+ }
940
+ );
941
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
942
+ intro && /* @__PURE__ */ jsx("div", { className: "mb-8 text-[12px] font-mono text-foreground/55 leading-relaxed max-w-3xl [&_a]:text-accent [&_a]:underline [&_a]:underline-offset-4", children: intro }),
943
+ grid,
944
+ footer && /* @__PURE__ */ jsx("div", { className: "mt-8", children: footer })
945
+ ] });
946
+ if (bare) {
947
+ return /* @__PURE__ */ jsx(AppendixContext.Provider, { value: true, children: body });
948
+ }
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: [
950
+ /* @__PURE__ */ jsx("p", { className: cn("text-[11px] font-mono uppercase tracking-widest mb-6", theme.muted), children: title }),
951
+ body
952
+ ] }) });
953
+ }
954
+
955
+ // src/components.tsx
956
+ import { useEffect as useEffect10, useState as useState8 } from "react";
957
+ import { createPortal as createPortal2 } from "react-dom";
958
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
959
+ var TONE_SEGMENT_CLASS = defaultTheme.toneSegment;
960
+ var TONE_CHIP_CLASS = defaultTheme.toneChip;
961
+ var BTN_BASE = defaultTheme.buttonBase;
962
+ var BTN_GHOST = defaultTheme.buttonVariants.ghost;
963
+ var BTN_OUTLINE = defaultTheme.buttonVariants.outline;
964
+ var BTN_PRIMARY = defaultTheme.buttonVariants.primary;
965
+ function StepButton({
966
+ variant = "ghost",
967
+ className,
968
+ ...props
969
+ }) {
970
+ const variantClass = variant === "outline" ? BTN_OUTLINE : variant === "primary" ? BTN_PRIMARY : BTN_GHOST;
971
+ return /* @__PURE__ */ jsx2("button", { type: "button", ...props, className: cn(BTN_BASE, variantClass, className) });
972
+ }
973
+ function CodeBlock({
974
+ lines,
975
+ theme = defaultTheme,
976
+ className
977
+ }) {
978
+ return /* @__PURE__ */ jsx2("div", { className: cn(theme.codeBlock, className), children: lines.map((line, i) => {
979
+ const commentStart = line.indexOf("//");
980
+ if (commentStart === 0) {
981
+ return /* @__PURE__ */ jsx2("div", { className: cn("whitespace-pre", theme.codeMuted), children: line }, i);
982
+ }
983
+ if (commentStart > 0) {
984
+ return /* @__PURE__ */ jsxs2("div", { className: "whitespace-pre", children: [
985
+ /* @__PURE__ */ jsx2("span", { className: "text-foreground/85", children: line.slice(0, commentStart) }),
986
+ /* @__PURE__ */ jsx2("span", { className: theme.codeMuted, children: line.slice(commentStart) })
987
+ ] }, i);
988
+ }
989
+ return /* @__PURE__ */ jsx2("div", { className: "whitespace-pre text-foreground/85", children: line }, i);
990
+ }) });
991
+ }
992
+ function StepScrubber({
993
+ count,
994
+ index,
995
+ toneAt,
996
+ titleAt,
997
+ onSeek,
998
+ emptyHint,
999
+ className,
1000
+ theme = defaultTheme,
1001
+ labels,
1002
+ groupOf
1003
+ }) {
1004
+ const onKey = (e) => {
1005
+ if (count === 0) return;
1006
+ if (e.key === "ArrowRight" || e.key === "ArrowUp") {
1007
+ e.preventDefault();
1008
+ e.stopPropagation();
1009
+ onSeek(Math.min(count - 1, index + 1));
1010
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
1011
+ e.preventDefault();
1012
+ e.stopPropagation();
1013
+ onSeek(Math.max(0, index - 1));
1014
+ } else if (e.key === "Home") {
1015
+ e.preventDefault();
1016
+ e.stopPropagation();
1017
+ onSeek(0);
1018
+ } else if (e.key === "End") {
1019
+ e.preventDefault();
1020
+ e.stopPropagation();
1021
+ onSeek(count - 1);
1022
+ }
1023
+ };
1024
+ 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 }) });
1026
+ }
1027
+ const valueText = labels?.frame ? labels.frame(index) : titleAt?.(index) ?? `frame ${index + 1} of ${count}`;
1028
+ const segment = (i) => /* @__PURE__ */ jsx2(
1029
+ "div",
1030
+ {
1031
+ "aria-hidden": "true",
1032
+ title: titleAt ? titleAt(i) : `frame ${i + 1}`,
1033
+ onClick: () => onSeek(i),
1034
+ className: cn(
1035
+ "h-3.5 flex-1 min-w-[3px] rounded-[2px] transition-all cursor-pointer",
1036
+ theme.toneSegment[toneAt?.(i) ?? "neutral"],
1037
+ i === index && "ring-2 ring-primary scale-y-150"
1038
+ )
1039
+ },
1040
+ i
1041
+ );
1042
+ let rows;
1043
+ if (groupOf) {
1044
+ const groups = [];
1045
+ for (let i = 0; i < count; i++) {
1046
+ const g = groupOf(i);
1047
+ const lastGroup = groups[groups.length - 1];
1048
+ if (lastGroup && groupOf(lastGroup[0]) === g) lastGroup.push(i);
1049
+ else groups.push([i]);
1050
+ }
1051
+ rows = groups.map((units, gi) => /* @__PURE__ */ jsx2(
1052
+ "div",
1053
+ {
1054
+ "aria-hidden": "true",
1055
+ className: "flex-1 min-w-[3px] flex gap-[2px] items-center",
1056
+ style: { flexGrow: units.length },
1057
+ children: units.map(segment)
1058
+ },
1059
+ gi
1060
+ ));
1061
+ } else {
1062
+ rows = Array.from({ length: count }, (_, i) => segment(i));
1063
+ }
1064
+ return /* @__PURE__ */ jsx2(
1065
+ "div",
1066
+ {
1067
+ role: "slider",
1068
+ tabIndex: 0,
1069
+ "aria-label": labels?.scrubber ?? "Frames",
1070
+ "aria-valuemin": 1,
1071
+ "aria-valuemax": count,
1072
+ "aria-valuenow": index + 1,
1073
+ "aria-valuetext": valueText,
1074
+ "aria-orientation": "horizontal",
1075
+ onKeyDown: onKey,
1076
+ className: cn(
1077
+ "flex-1 flex items-center gap-[3px] min-w-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-sm",
1078
+ className
1079
+ ),
1080
+ children: rows
1081
+ }
1082
+ );
1083
+ }
1084
+ function PlayerBar({
1085
+ player,
1086
+ toneAt,
1087
+ titleAt,
1088
+ leading,
1089
+ onEmpty,
1090
+ emptyHint,
1091
+ className,
1092
+ theme = defaultTheme,
1093
+ groupOf,
1094
+ labels
1095
+ }) {
1096
+ const L = {
1097
+ previous: labels?.previous ?? "Previous step",
1098
+ play: labels?.play ?? "Play",
1099
+ pause: labels?.pause ?? "Pause",
1100
+ replay: labels?.replay ?? "Replay",
1101
+ next: labels?.next ?? "Next step"
1102
+ };
1103
+ 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: [
1105
+ leading,
1106
+ /* @__PURE__ */ jsx2(StepButton, { variant: "ghost", onClick: () => player.prev(), className: "h-7 px-2 text-xs", "aria-label": L.previous, children: "\u2190" }),
1107
+ /* @__PURE__ */ jsx2(StepButton, { variant: "outline", onClick: () => player.toggle(onEmpty), className: "h-7 px-3.5 text-[11px]", children: playLabel }),
1108
+ /* @__PURE__ */ jsx2(StepButton, { variant: "ghost", onClick: () => player.next(onEmpty), className: "h-7 px-2 text-xs", "aria-label": L.next, children: "\u2192" }),
1109
+ /* @__PURE__ */ jsx2(
1110
+ StepScrubber,
1111
+ {
1112
+ count: player.total,
1113
+ index: player.index,
1114
+ toneAt,
1115
+ titleAt,
1116
+ onSeek: (i) => player.seek(i),
1117
+ emptyHint,
1118
+ theme,
1119
+ groupOf
1120
+ }
1121
+ ),
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" })
1123
+ ] });
1124
+ }
1125
+ function ChapterNav({
1126
+ chapters,
1127
+ active,
1128
+ onNavigate,
1129
+ hint,
1130
+ className,
1131
+ theme = defaultTheme
1132
+ }) {
1133
+ const flat = flattenChapters(chapters);
1134
+ const renderRow = (items, depth) => {
1135
+ const indent = "\u2014 ".repeat(depth);
1136
+ return items.map((chapter) => {
1137
+ const flatIndex = flat.findIndex((f) => f.chapter.id === chapter.id);
1138
+ const isActive = flatIndex === active;
1139
+ return /* @__PURE__ */ jsxs2("div", { className: cn("flex items-center", depth > 0 && "gap-1"), children: [
1140
+ /* @__PURE__ */ jsxs2(
1141
+ StepButton,
1142
+ {
1143
+ type: "button",
1144
+ onClick: () => onNavigate(flatIndex),
1145
+ "aria-current": isActive ? "true" : void 0,
1146
+ title: chapter.hint ?? chapter.label,
1147
+ className: cn(
1148
+ "flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[11px] font-mono border transition-colors cursor-pointer whitespace-nowrap",
1149
+ depth > 0 && "px-2 text-[10px] border-transparent",
1150
+ isActive ? depth === 0 ? "border-primary/50 text-primary bg-primary/10" : "text-primary bg-primary/5" : depth === 0 ? "border-transparent text-foreground/50 hover:text-foreground hover:border-white/15" : "text-foreground/45 hover:text-foreground"
1151
+ ),
1152
+ children: [
1153
+ depth === 0 && chapter.numbered !== false && /* @__PURE__ */ jsx2("span", { className: cn("text-[9px]", isActive ? "text-primary/80" : "text-foreground/30"), children: String(flatIndex + 1).padStart(2, "0") }),
1154
+ depth > 0 && /* @__PURE__ */ jsx2("span", { className: "text-foreground/25", children: indent }),
1155
+ chapter.label
1156
+ ]
1157
+ }
1158
+ ),
1159
+ chapter.children && chapter.children.length > 0 && /* @__PURE__ */ jsx2("div", { className: "flex flex-wrap items-center gap-1", children: renderRow(chapter.children, depth + 1) })
1160
+ ] }, chapter.id);
1161
+ });
1162
+ };
1163
+ return /* @__PURE__ */ jsx2(
1164
+ "nav",
1165
+ {
1166
+ "aria-label": "Chapters",
1167
+ className: cn(
1168
+ "sticky top-20 md:top-24 z-30 rounded-sm shadow-[0_8px_24px_rgba(0,0,0,0.35)]",
1169
+ theme.surface,
1170
+ className
1171
+ ),
1172
+ children: /* @__PURE__ */ jsxs2("div", { className: "p-1.5", children: [
1173
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-1 overflow-x-auto", children: [
1174
+ /* @__PURE__ */ jsx2("span", { className: cn("shrink-0 px-2 text-[9px] font-mono uppercase tracking-widest", theme.muted), children: "Chapters" }),
1175
+ renderRow(chapters, 0)
1176
+ ] }),
1177
+ hint && /* @__PURE__ */ jsx2("div", { className: "flex justify-end px-2 pt-1.5", children: /* @__PURE__ */ jsx2("span", { className: cn("text-[10px] font-mono hidden md:inline", theme.muted), children: hint }) })
1178
+ ] })
1179
+ }
1180
+ );
1181
+ }
1182
+ function LessonPager({
1183
+ prev,
1184
+ next,
1185
+ onNavigate,
1186
+ className,
1187
+ labels
1188
+ }) {
1189
+ return /* @__PURE__ */ jsxs2("div", { className: cn("flex items-center gap-2", className), children: [
1190
+ /* @__PURE__ */ jsxs2(
1191
+ StepButton,
1192
+ {
1193
+ variant: "outline",
1194
+ onClick: () => onNavigate("prev"),
1195
+ disabled: !prev,
1196
+ title: prev?.hint ?? prev?.label,
1197
+ className: "h-9 px-3 text-[11px]",
1198
+ "aria-label": prev ? `${labels?.previous ?? "Previous lesson"}: ${prev.label}` : "No previous lesson",
1199
+ children: [
1200
+ "\u2190 ",
1201
+ prev?.label ?? "\u2014"
1202
+ ]
1203
+ }
1204
+ ),
1205
+ /* @__PURE__ */ jsxs2(
1206
+ StepButton,
1207
+ {
1208
+ variant: "outline",
1209
+ onClick: () => onNavigate("next"),
1210
+ disabled: !next,
1211
+ title: next?.hint ?? next?.label,
1212
+ className: "h-9 px-3 text-[11px] ml-auto",
1213
+ "aria-label": next ? `${labels?.next ?? "Next lesson"}: ${next.label}` : "No next lesson",
1214
+ children: [
1215
+ next?.label ?? "\u2014",
1216
+ " \u2192"
1217
+ ]
1218
+ }
1219
+ )
1220
+ ] });
1221
+ }
1222
+ function TourSpotlight({
1223
+ target,
1224
+ theme = defaultTheme
1225
+ }) {
1226
+ const [rect, setRect] = useState8(null);
1227
+ useEffect10(() => {
1228
+ if (!target) {
1229
+ setRect(null);
1230
+ return;
1231
+ }
1232
+ let el = null;
1233
+ let scrolled = false;
1234
+ let raf = 0;
1235
+ let last = "";
1236
+ const tick = () => {
1237
+ if (!el) {
1238
+ el = document.getElementById(target) ?? document.querySelector(`[data-tour="${target}"]`);
1239
+ if (el && !scrolled) {
1240
+ scrolled = true;
1241
+ el.scrollIntoView({ block: "center", behavior: "smooth", inline: "nearest" });
1242
+ }
1243
+ }
1244
+ if (el) {
1245
+ const r = el.getBoundingClientRect();
1246
+ const key = [r.top, r.left, r.width, r.height].map((n) => Math.round(n)).join(",");
1247
+ if (key !== last) {
1248
+ last = key;
1249
+ setRect(r);
1250
+ }
1251
+ }
1252
+ raf = requestAnimationFrame(tick);
1253
+ };
1254
+ raf = requestAnimationFrame(tick);
1255
+ return () => cancelAnimationFrame(raf);
1256
+ }, [target]);
1257
+ if (!rect) return null;
1258
+ const pad = 6;
1259
+ return /* @__PURE__ */ jsx2("div", { className: "fixed inset-0 z-40 pointer-events-none", children: /* @__PURE__ */ jsx2(
1260
+ "div",
1261
+ {
1262
+ className: cn("absolute", theme.spotlight),
1263
+ style: {
1264
+ top: rect.top - pad,
1265
+ left: rect.left - pad,
1266
+ width: rect.width + pad * 2,
1267
+ height: rect.height + pad * 2,
1268
+ boxShadow: "0 0 0 9999px rgba(0,0,0,0.55)"
1269
+ }
1270
+ }
1271
+ ) });
1272
+ }
1273
+ function TourCard({
1274
+ step,
1275
+ index,
1276
+ total,
1277
+ onNext,
1278
+ onBack,
1279
+ onEnd,
1280
+ labels,
1281
+ className,
1282
+ theme = defaultTheme,
1283
+ nextDisabled = false
1284
+ }) {
1285
+ const L = {
1286
+ title: labels?.title ?? "Guided tour",
1287
+ back: labels?.back ?? "Back",
1288
+ next: labels?.next ?? "Next",
1289
+ finish: labels?.finish ?? "Finish",
1290
+ end: labels?.end ?? "End tour"
1291
+ };
1292
+ const trapRef = useFocusTrap(true);
1293
+ return /* @__PURE__ */ jsxs2(
1294
+ "div",
1295
+ {
1296
+ ref: trapRef,
1297
+ role: "dialog",
1298
+ "aria-modal": "true",
1299
+ "aria-label": `${L.title} \xB7 ${index + 1}/${total}`,
1300
+ tabIndex: -1,
1301
+ className: cn(
1302
+ "fixed bottom-6 left-1/2 -translate-x-1/2 z-50 w-[min(92vw,26rem)] rounded-sm p-4 outline-none",
1303
+ theme.surface,
1304
+ "border-primary/30 bg-background/95 shadow-2xl",
1305
+ className
1306
+ ),
1307
+ children: [
1308
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between mb-2", children: [
1309
+ /* @__PURE__ */ jsxs2("span", { className: "text-[10px] font-mono uppercase tracking-widest text-primary/70", children: [
1310
+ L.title,
1311
+ " \xB7 ",
1312
+ index + 1,
1313
+ "/",
1314
+ total
1315
+ ] }),
1316
+ /* @__PURE__ */ jsx2(
1317
+ StepButton,
1318
+ {
1319
+ type: "button",
1320
+ onClick: onEnd,
1321
+ "aria-label": L.end,
1322
+ className: "text-foreground/40 hover:text-foreground cursor-pointer leading-none",
1323
+ children: "\xD7"
1324
+ }
1325
+ )
1326
+ ] }),
1327
+ /* @__PURE__ */ jsx2("p", { className: "text-sm font-bold text-foreground/95 mb-1.5", children: step.title }),
1328
+ /* @__PURE__ */ jsx2("div", { className: "text-[12px] font-mono text-foreground/70 leading-relaxed", children: step.body }),
1329
+ step.waitHint && /* @__PURE__ */ jsx2("p", { className: "text-[11px] font-mono text-accent mt-2 animate-pulse", children: step.waitHint }),
1330
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2 mt-3 pt-3 border-t border-white/10", children: [
1331
+ /* @__PURE__ */ jsx2(StepButton, { variant: "outline", onClick: onBack, disabled: index === 0, className: "h-7 px-3 text-[11px]", children: L.back }),
1332
+ /* @__PURE__ */ jsx2(
1333
+ StepButton,
1334
+ {
1335
+ variant: "primary",
1336
+ onClick: onNext,
1337
+ disabled: nextDisabled,
1338
+ className: "h-7 px-4 text-[11px] ml-auto",
1339
+ children: index === total - 1 ? L.finish : L.next
1340
+ }
1341
+ )
1342
+ ] })
1343
+ ]
1344
+ }
1345
+ );
1346
+ }
1347
+ function ShortcutsDialog({
1348
+ open,
1349
+ onClose,
1350
+ items,
1351
+ title = "Keyboard shortcuts",
1352
+ className,
1353
+ theme = defaultTheme
1354
+ }) {
1355
+ const [mounted, setMounted] = useState8(false);
1356
+ const trapRef = useFocusTrap(open);
1357
+ useEffect10(() => setMounted(true), []);
1358
+ useEffect10(() => {
1359
+ if (!open) return;
1360
+ const onKey = (e) => {
1361
+ if (e.key === "Escape") onClose();
1362
+ };
1363
+ document.addEventListener("keydown", onKey);
1364
+ return () => document.removeEventListener("keydown", onKey);
1365
+ }, [open, onClose]);
1366
+ if (!open || !mounted) return null;
1367
+ return createPortal2(
1368
+ /* @__PURE__ */ jsx2(
1369
+ "div",
1370
+ {
1371
+ className: cn("fixed inset-0 z-50 flex items-center justify-center p-4", theme.overlay, className),
1372
+ onClick: onClose,
1373
+ children: /* @__PURE__ */ jsxs2(
1374
+ "div",
1375
+ {
1376
+ ref: trapRef,
1377
+ role: "dialog",
1378
+ "aria-modal": "true",
1379
+ "aria-label": title,
1380
+ tabIndex: -1,
1381
+ onClick: (e) => e.stopPropagation(),
1382
+ className: cn(
1383
+ "w-full max-w-md rounded-sm p-5 shadow-2xl outline-none",
1384
+ theme.surface,
1385
+ "border-white/15 bg-card"
1386
+ ),
1387
+ children: [
1388
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between mb-4", children: [
1389
+ /* @__PURE__ */ jsx2("h3", { className: "text-lg text-primary", children: title }),
1390
+ /* @__PURE__ */ jsx2(
1391
+ StepButton,
1392
+ {
1393
+ type: "button",
1394
+ onClick: onClose,
1395
+ "aria-label": "Close shortcuts",
1396
+ className: "text-foreground/40 hover:text-foreground cursor-pointer leading-none text-lg",
1397
+ children: "\xD7"
1398
+ }
1399
+ )
1400
+ ] }),
1401
+ /* @__PURE__ */ jsx2("dl", { className: "space-y-2", children: items.map((s) => /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between gap-4", children: [
1402
+ /* @__PURE__ */ jsx2("dt", { children: /* @__PURE__ */ jsx2("kbd", { className: theme.kbd, children: s.keys }) }),
1403
+ /* @__PURE__ */ jsx2("dd", { className: "font-mono text-[11px] text-foreground/60 text-right", children: s.action })
1404
+ ] }, s.keys)) })
1405
+ ]
1406
+ }
1407
+ )
1408
+ }
1409
+ ),
1410
+ document.body
1411
+ );
1412
+ }
1413
+ export {
1414
+ ChapterNav,
1415
+ CodeBlock,
1416
+ GlossaryAppendix,
1417
+ GlossaryProvider,
1418
+ LessonPager,
1419
+ PlayerBar,
1420
+ ShortcutsDialog,
1421
+ StepScrubber,
1422
+ TONE_CHIP_CLASS,
1423
+ TONE_SEGMENT_CLASS,
1424
+ Term,
1425
+ TourCard,
1426
+ TourSpotlight,
1427
+ cookieStore,
1428
+ defaultTheme,
1429
+ flattenChapters,
1430
+ localStorageStore,
1431
+ memoryStore,
1432
+ useChapters,
1433
+ useDiscoveries,
1434
+ useFocusTrap,
1435
+ useGlobalKeys,
1436
+ useGuidedTour,
1437
+ usePrefersReducedMotion,
1438
+ useStepPlayer,
1439
+ useSteps
1440
+ };
1441
+ //# sourceMappingURL=index.js.map