@sphynxoid/stepwise 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Sphynx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @sphynxoid/stepwise
2
+
3
+ A kit for interactive, narrated, step-driven visualizations — one primitive (`useSteps`) at every scale.
4
+
5
+ Frames in a trace, chapters in a lesson, lessons in a notebook, and tour steps are the same thing at the core: a clamped cursor over an ordered sequence. Timers, tour effects, scroll tracking, and third-party presenters are policies and adapters layered on top.
6
+
7
+ - **Headless core.** The hooks never render; the styled components are optional and theme-injectable.
8
+ - **One dependency by default.** The root barrel needs only React. GSAP and driver.js integrations are opt-in via adapter subpaths.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @sphynxoid/stepwise
14
+ ```
15
+
16
+ Optional adapters (install only what you use):
17
+
18
+ ```bash
19
+ npm install gsap # @sphynxoid/stepwise/adapters/gsap
20
+ npm install driver.js # @sphynxoid/stepwise/adapters/driver-js
21
+ # @sphynxoid/stepwise/adapters/scroll is dependency-free
22
+ ```
23
+
24
+ ## Styling — pick one
25
+
26
+ The hooks are headless. The styled components (`PlayerBar`, `TourCard`, `Term`, …) ship Tailwind class strings, and there are three ways to get them styled:
27
+
28
+ **1. No Tailwind — import the precompiled stylesheet** (once, at your app entry):
29
+
30
+ ```ts
31
+ import "@sphynxoid/stepwise/styles.css";
32
+ ```
33
+
34
+ It contains exactly the CSS the components use, no global reset (a small preflight is scoped to the components' `[data-stepwise]` roots), and light/dark defaults that follow `prefers-color-scheme` or a `.dark` / `[data-theme="dark"]` switch. Re-color it with CSS variables:
35
+
36
+ ```css
37
+ :root {
38
+ --stepwise-primary: #7c3aed; /* play button, active segment, rings */
39
+ --stepwise-accent: #0ea5e9; /* "accent" tone, links, wait hints */
40
+ }
41
+ ```
42
+
43
+ Tokens: `--stepwise-background`, `-foreground`, `-card`, `-popover`, `-primary`, `-primary-foreground`, `-accent`, `-destructive`, `-ring`, `-shade` (overlay/code-block tint).
44
+
45
+ **2. Tailwind + shadcn/ui — copy the components in with the shadcn CLI:**
46
+
47
+ ```bash
48
+ npx shadcn@latest add https://stepwise-demo.vercel.app/r/player-bar.json
49
+ ```
50
+
51
+ Items: `player-bar`, `code-block`, `tour`, `glossary`, `chapter-nav`, `lesson-pager`, `shortcuts-dialog` — or `stepwise` for all of them. The presentation source lands in `components/stepwise/` (plus `lib/stepwise-theme.ts`), where Tailwind scans it automatically; hooks and types stay imported from this package. Colors come from your shadcn tokens.
52
+
53
+ **3. Tailwind, using the components from npm** — register the package so Tailwind generates its classes (v4 skips `node_modules`):
54
+
55
+ ```css
56
+ @import "tailwindcss";
57
+ @source "../node_modules/@sphynxoid/stepwise/dist";
58
+ ```
59
+
60
+ Token contract, Tailwind v3 setup and the "everything renders unstyled" checklist: [TAILWIND-SETUP.md](https://github.com/TheSphynxoid/stepwise/blob/master/docs/TAILWIND-SETUP.md).
61
+
62
+ ## Quick start — a narrated player
63
+
64
+ ```tsx
65
+ "use client";
66
+ import { useTrace, PlayerBar, CodeBlock, type StepwiseStep } from "@sphynxoid/stepwise";
67
+
68
+ // Your domain payload rides along with the presentation fields.
69
+ const frames: StepwiseStep<{ code: string[] }>[] = [
70
+ { tone: "write", label: "Initialize", code: ["let i = 0;"] },
71
+ { tone: "neutral", label: "Loop", code: ["while (i < n) { … }"] },
72
+ { tone: "good", label: "Done", code: ["return arr;"] },
73
+ ];
74
+
75
+ export function Demo() {
76
+ const trace = useTrace(frames, { autoPlay: true, syncHash: "frame" });
77
+
78
+ return (
79
+ <>
80
+ <CodeBlock lines={trace.step!.code} />
81
+ <PlayerBar player={trace.player} toneAt={trace.toneAt} titleAt={trace.titleAt} />
82
+ </>
83
+ );
84
+ }
85
+ ```
86
+
87
+ `useTrace` binds a player to a typed trace: `step` is the frame to render (frame 0 before playback starts), and `toneAt` / `titleAt` feed the scrubber. Under it, `useStepPlayer` gives you a timer-driven playhead with play/pause/seek, speed control, and optional `#frame=2` deep links — or hand it an `engine` to drive the playhead from GSAP or scroll instead. Two end-state flavors: `atEnd` (cursor parked on the last frame, seeks included) and `ended` (playback ran to the finish, via the `onEnd` callback option) — clearing on any cursor movement, so a completion summary knows the demo actually finished. `autoPlay` respects `prefers-reduced-motion` (lands on frame 0, paused; opt out with `respectReducedMotion: false`), and `syncHash` writes only its own key, so it coexists with anchors and other players' keys.
88
+
89
+ ## The three scales
90
+
91
+ | Scale | Hook | Presentation |
92
+ |---|---|---|
93
+ | **Player** — auto-played frames in a trace | `useStepPlayer` (+ `PlayerEngine` adapters) | `PlayerBar`, `StepScrubber`, `CodeBlock` |
94
+ | **Tour** — steps that mutate app state via `onEnter` | `useGuidedTour` (+ `TourDriver` adapters) | `TourSpotlight`, `TourCard` |
95
+ | **Chapters** — scroll-tracked sections | `useChapters`, `flattenChapters` | `ChapterNav`, `LessonPager` |
96
+
97
+ All three compose the same `useSteps` cursor, so movement, clamping, and boundary flags behave identically everywhere.
98
+
99
+ Tours are small state machines: a step may declare `when` (skip on arrival when false), `gate` (block `next()` — and expose `blocked` — until the user actually does the thing), and `next` (edge override to another step's `id`, enabling branches and loops). `gotoId(id)` navigates by identity; programmatic navigation never trips gates.
100
+
101
+ ## Show-once tutorials (led discovery)
102
+
103
+ Pair `useDiscoveries` with `useGuidedTour` to auto-play a tour the first time a user encounters a feature:
104
+
105
+ ```tsx
106
+ const { discovered, ready, markDiscovered } = useDiscoveries();
107
+ const tour = useGuidedTour(steps);
108
+
109
+ // when this build carries an undiscovered feature, play its tour once
110
+ useEffect(() => {
111
+ if (ready && !tour.active && !discovered.has("search:v1")) tour.start();
112
+ }, [ready, tour.active, discovered]);
113
+
114
+ // mark on completion (or explicit dismiss) — never on mere trigger
115
+ useEffect(() => {
116
+ if (launched && !tour.active) markDiscovered("search:v1");
117
+ }, [tour.active]);
118
+ ```
119
+
120
+ The ledger is a set of opaque ids behind a `DiscoveryStore` seam: `localStorageStore` (default), `cookieStore` (when SSR markup must know), `memoryStore` (tests), or your own store backed by an API for cross-device discovery.
121
+
122
+ ## Supporting pieces
123
+
124
+ - `useGlobalKeys` — global keyboard bindings with a form-control guard
125
+ - `useFocusTrap` — dialog/tour focus containment
126
+ - `usePrefersReducedMotion` — motion preference gate
127
+ - `useDiscoveries` (+ `localStorageStore` / `cookieStore` / `memoryStore`) — persisted discovery ledger with a swappable storage seam
128
+ - `GlossaryProvider` / `Term` / `GlossaryAppendix` — recursive, depth-capped term tooltips with an auto-collecting appendix
129
+ - `ShortcutsDialog` — keyboard shortcut reference
130
+ - `defaultTheme` / `StepwiseTheme` — the default styling, overridable per component via a `theme` prop (swap in any design system)
131
+
132
+ ## Adapters
133
+
134
+ Import adapters from their subpaths — never from the root barrel:
135
+
136
+ ```ts
137
+ import { createGsapPlayerEngine } from "@sphynxoid/stepwise/adapters/gsap";
138
+ import { createDriverJsTourDriver } from "@sphynxoid/stepwise/adapters/driver-js";
139
+ import { createScrollPlayerEngine } from "@sphynxoid/stepwise/adapters/scroll";
140
+ ```
141
+
142
+ An adapter plugs into a tiny port (`PlayerEngine` / `TourDriver`), so you can write your own for Shepherd, Framer Motion, or anything else without touching the kit.
143
+
144
+ ## React
145
+
146
+ Supports React 18 and 19. All hooks are SSR-safe (`"use client"` modules, no window access at module evaluation) and every public hook returns referentially stable callbacks.
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,23 @@
1
+ import { T as TourDriver } from '../types-IT_9L8On.js';
2
+ import 'react';
3
+
4
+ interface DriverJsTourOptions {
5
+ /** Extra class for the driver.js popover, e.g. to blend with the app theme. */
6
+ popoverClass?: string;
7
+ }
8
+ /**
9
+ * TourDriver backed by driver.js. The hook still owns state coupling (onEnter
10
+ * effects fire per step); driver.js only presents the spotlight + popover.
11
+ *
12
+ * Caveats vs the built-in presenter:
13
+ * - every step needs a `target` (driver.js anchors a popover to an element);
14
+ * targetless steps are presented as empty passes — prefer the built-in
15
+ * presenter for tours with intro/outro steps.
16
+ * - step bodies must be plain strings.
17
+ *
18
+ * const driver = useMemo(() => createDriverJsTourDriver(), []);
19
+ * const tour = useGuidedTour(steps, { driver });
20
+ */
21
+ declare function createDriverJsTourDriver(opts?: DriverJsTourOptions): TourDriver;
22
+
23
+ export { type DriverJsTourOptions, createDriverJsTourDriver };
@@ -0,0 +1,45 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/adapters/driver-js.ts
5
+ import { driver } from "driver.js";
6
+ import "driver.js/dist/driver.css";
7
+ function createDriverJsTourDriver(opts = {}) {
8
+ let instance = null;
9
+ const destroy = () => {
10
+ if (instance?.isActive()) instance.destroy();
11
+ instance = null;
12
+ };
13
+ return {
14
+ show(step, ctx) {
15
+ destroy();
16
+ if (!step.target || !document.querySelector(`[data-tour="${step.target}"]`)) return;
17
+ instance = driver({
18
+ popoverClass: opts.popoverClass,
19
+ showProgress: false,
20
+ allowClose: true,
21
+ showButtons: ["next", "previous", "close"],
22
+ nextBtnText: "Next",
23
+ prevBtnText: "Back",
24
+ doneBtnText: "Finish",
25
+ onNextClick: () => ctx.next(),
26
+ onPrevClick: () => ctx.back(),
27
+ onCloseClick: () => ctx.end()
28
+ });
29
+ const stepConfig = {
30
+ element: `[data-tour="${step.target}"]`,
31
+ popover: {
32
+ title: step.title,
33
+ description: typeof step.body === "string" ? step.body : ""
34
+ },
35
+ waitForElement: 500
36
+ };
37
+ instance.highlight(stepConfig);
38
+ },
39
+ hide: destroy
40
+ };
41
+ }
42
+ export {
43
+ createDriverJsTourDriver
44
+ };
45
+ //# sourceMappingURL=driver-js.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/driver-js.ts"],"sourcesContent":["\"use client\";\n\nimport { driver, type DriveStep } from \"driver.js\";\nimport \"driver.js/dist/driver.css\";\nimport type { TourDriver, TourStepInput } from \"../types\";\n\nexport interface DriverJsTourOptions {\n /** Extra class for the driver.js popover, e.g. to blend with the app theme. */\n popoverClass?: string;\n}\n\n/**\n * TourDriver backed by driver.js. The hook still owns state coupling (onEnter\n * effects fire per step); driver.js only presents the spotlight + popover.\n *\n * Caveats vs the built-in presenter:\n * - every step needs a `target` (driver.js anchors a popover to an element);\n * targetless steps are presented as empty passes — prefer the built-in\n * presenter for tours with intro/outro steps.\n * - step bodies must be plain strings.\n *\n * const driver = useMemo(() => createDriverJsTourDriver(), []);\n * const tour = useGuidedTour(steps, { driver });\n */\nexport function createDriverJsTourDriver(opts: DriverJsTourOptions = {}): TourDriver {\n let instance: ReturnType<typeof driver> | null = null;\n\n const destroy = () => {\n if (instance?.isActive()) instance.destroy();\n instance = null;\n };\n\n return {\n show(step: TourStepInput, ctx) {\n destroy();\n if (!step.target || !document.querySelector(`[data-tour=\"${step.target}\"]`)) return;\n instance = driver({\n popoverClass: opts.popoverClass,\n showProgress: false,\n allowClose: true,\n showButtons: [\"next\", \"previous\", \"close\"],\n nextBtnText: \"Next\",\n prevBtnText: \"Back\",\n doneBtnText: \"Finish\",\n onNextClick: () => ctx.next(),\n onPrevClick: () => ctx.back(),\n onCloseClick: () => ctx.end(),\n });\n const stepConfig = {\n element: `[data-tour=\"${step.target}\"]`,\n popover: {\n title: step.title,\n description: typeof step.body === \"string\" ? step.body : \"\",\n },\n waitForElement: 500,\n } as DriveStep;\n instance.highlight(stepConfig);\n },\n hide: destroy,\n };\n}\n"],"mappings":";;;;AAEA,SAAS,cAA8B;AACvC,OAAO;AAqBA,SAAS,yBAAyB,OAA4B,CAAC,GAAe;AACnF,MAAI,WAA6C;AAEjD,QAAM,UAAU,MAAM;AACpB,QAAI,UAAU,SAAS,EAAG,UAAS,QAAQ;AAC3C,eAAW;AAAA,EACb;AAEA,SAAO;AAAA,IACL,KAAK,MAAqB,KAAK;AAC7B,cAAQ;AACR,UAAI,CAAC,KAAK,UAAU,CAAC,SAAS,cAAc,eAAe,KAAK,MAAM,IAAI,EAAG;AAC7E,iBAAW,OAAO;AAAA,QAChB,cAAc,KAAK;AAAA,QACnB,cAAc;AAAA,QACd,YAAY;AAAA,QACZ,aAAa,CAAC,QAAQ,YAAY,OAAO;AAAA,QACzC,aAAa;AAAA,QACb,aAAa;AAAA,QACb,aAAa;AAAA,QACb,aAAa,MAAM,IAAI,KAAK;AAAA,QAC5B,aAAa,MAAM,IAAI,KAAK;AAAA,QAC5B,cAAc,MAAM,IAAI,IAAI;AAAA,MAC9B,CAAC;AACD,YAAM,aAAa;AAAA,QACjB,SAAS,eAAe,KAAK,MAAM;AAAA,QACnC,SAAS;AAAA,UACP,OAAO,KAAK;AAAA,UACZ,aAAa,OAAO,KAAK,SAAS,WAAW,KAAK,OAAO;AAAA,QAC3D;AAAA,QACA,gBAAgB;AAAA,MAClB;AACA,eAAS,UAAU,UAAU;AAAA,IAC/B;AAAA,IACA,MAAM;AAAA,EACR;AACF;","names":[]}
@@ -0,0 +1,23 @@
1
+ import { P as PlayerEngine } from '../types-IT_9L8On.js';
2
+ import 'react';
3
+
4
+ interface GsapEngineOptions {
5
+ /** Frames in the current timeline. Recreate the engine when it changes. */
6
+ total: number;
7
+ /** Wall-clock time per frame at 1× speed. */
8
+ baseDelayMs?: number;
9
+ }
10
+ /**
11
+ * PlayerEngine backed by a GSAP tween. The playhead maps 1:1 onto frame
12
+ * indices, so play/pause/seek/rate come from GSAP and frames stream back
13
+ * through onUpdate. Swap-in for useStepPlayer's built-in timer:
14
+ *
15
+ * const engine = useMemo(
16
+ * () => createGsapPlayerEngine({ total: frames.length, baseDelayMs: 900 }),
17
+ * [frames.length]
18
+ * );
19
+ * const player = useStepPlayer({ total: frames.length, engine });
20
+ */
21
+ declare function createGsapPlayerEngine(opts: GsapEngineOptions): PlayerEngine;
22
+
23
+ export { type GsapEngineOptions, createGsapPlayerEngine };
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/adapters/gsap.ts
5
+ import gsap from "gsap";
6
+ function createGsapPlayerEngine(opts) {
7
+ return {
8
+ connect(api) {
9
+ const last = Math.max(0, opts.total - 1);
10
+ const proxy = { frame: 0 };
11
+ const duration = Math.max(1e-3, opts.total * (opts.baseDelayMs ?? 900) / 1e3);
12
+ let endFired = false;
13
+ const tween = gsap.to(proxy, {
14
+ frame: last,
15
+ duration,
16
+ ease: "none",
17
+ paused: true,
18
+ onUpdate: () => {
19
+ const frame = Math.min(Math.max(Math.floor(proxy.frame), 0), api.total - 1);
20
+ api.onFrame(frame);
21
+ if (!endFired && frame >= api.total - 1) {
22
+ endFired = true;
23
+ api.onEnd();
24
+ }
25
+ }
26
+ });
27
+ return {
28
+ play: () => {
29
+ tween.play();
30
+ },
31
+ pause: () => {
32
+ tween.pause();
33
+ },
34
+ seek: (frame) => {
35
+ tween.pause(Math.min(frame, last));
36
+ if (frame < last) endFired = false;
37
+ },
38
+ setRate: (rate) => {
39
+ tween.timeScale(rate);
40
+ },
41
+ disconnect: () => {
42
+ tween.kill();
43
+ }
44
+ };
45
+ }
46
+ };
47
+ }
48
+ export {
49
+ createGsapPlayerEngine
50
+ };
51
+ //# sourceMappingURL=gsap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/gsap.ts"],"sourcesContent":["\"use client\";\n\nimport gsap from \"gsap\";\nimport type { PlayerEngine, PlayerEngineApi, PlayerEngineHandle } from \"../types\";\n\nexport interface GsapEngineOptions {\n /** Frames in the current timeline. Recreate the engine when it changes. */\n total: number;\n /** Wall-clock time per frame at 1× speed. */\n baseDelayMs?: number;\n}\n\n/**\n * PlayerEngine backed by a GSAP tween. The playhead maps 1:1 onto frame\n * indices, so play/pause/seek/rate come from GSAP and frames stream back\n * through onUpdate. Swap-in for useStepPlayer's built-in timer:\n *\n * const engine = useMemo(\n * () => createGsapPlayerEngine({ total: frames.length, baseDelayMs: 900 }),\n * [frames.length]\n * );\n * const player = useStepPlayer({ total: frames.length, engine });\n */\nexport function createGsapPlayerEngine(opts: GsapEngineOptions): PlayerEngine {\n return {\n connect(api: PlayerEngineApi): PlayerEngineHandle {\n const last = Math.max(0, opts.total - 1);\n const proxy = { frame: 0 };\n const duration = Math.max(0.001, (opts.total * (opts.baseDelayMs ?? 900)) / 1000);\n // Port contract: onEnd fires once per run, on the transition into the\n // final frame — not on every parked tick. Rewinding (seek below the\n // last frame) re-arms it.\n let endFired = false;\n const tween = gsap.to(proxy, {\n frame: last,\n duration,\n ease: \"none\",\n paused: true,\n onUpdate: () => {\n const frame = Math.min(Math.max(Math.floor(proxy.frame), 0), api.total - 1);\n api.onFrame(frame);\n if (!endFired && frame >= api.total - 1) {\n endFired = true;\n api.onEnd();\n }\n },\n });\n return {\n play: () => {\n tween.play();\n },\n pause: () => {\n tween.pause();\n },\n seek: (frame) => {\n tween.pause(Math.min(frame, last));\n if (frame < last) endFired = false;\n },\n setRate: (rate) => {\n tween.timeScale(rate);\n },\n disconnect: () => {\n tween.kill();\n },\n };\n },\n };\n}\n"],"mappings":";;;;AAEA,OAAO,UAAU;AAqBV,SAAS,uBAAuB,MAAuC;AAC5E,SAAO;AAAA,IACL,QAAQ,KAA0C;AAChD,YAAM,OAAO,KAAK,IAAI,GAAG,KAAK,QAAQ,CAAC;AACvC,YAAM,QAAQ,EAAE,OAAO,EAAE;AACzB,YAAM,WAAW,KAAK,IAAI,MAAQ,KAAK,SAAS,KAAK,eAAe,OAAQ,GAAI;AAIhF,UAAI,WAAW;AACf,YAAM,QAAQ,KAAK,GAAG,OAAO;AAAA,QAC3B,OAAO;AAAA,QACP;AAAA,QACA,MAAM;AAAA,QACN,QAAQ;AAAA,QACR,UAAU,MAAM;AACd,gBAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,QAAQ,CAAC;AAC1E,cAAI,QAAQ,KAAK;AACjB,cAAI,CAAC,YAAY,SAAS,IAAI,QAAQ,GAAG;AACvC,uBAAW;AACX,gBAAI,MAAM;AAAA,UACZ;AAAA,QACF;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,MAAM,MAAM;AACV,gBAAM,KAAK;AAAA,QACb;AAAA,QACA,OAAO,MAAM;AACX,gBAAM,MAAM;AAAA,QACd;AAAA,QACA,MAAM,CAAC,UAAU;AACf,gBAAM,MAAM,KAAK,IAAI,OAAO,IAAI,CAAC;AACjC,cAAI,QAAQ,KAAM,YAAW;AAAA,QAC/B;AAAA,QACA,SAAS,CAAC,SAAS;AACjB,gBAAM,UAAU,IAAI;AAAA,QACtB;AAAA,QACA,YAAY,MAAM;AAChB,gBAAM,KAAK;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,23 @@
1
+ import { P as PlayerEngine } from '../types-IT_9L8On.js';
2
+ import 'react';
3
+
4
+ interface ScrollEngineOptions {
5
+ /** The scrollytelling section whose scroll progress drives the frames. */
6
+ getContainer: () => HTMLElement | null;
7
+ /** Frames in the current timeline. Recreate the engine when it changes. */
8
+ total: number;
9
+ }
10
+ /**
11
+ * PlayerEngine driven by scroll progress through a container section — the
12
+ * scrollytelling mode. Scrolling maps to frame indices; manual stepping seeks
13
+ * the scroll position; "play" smooth-scrolls to the end of the section.
14
+ * Dependency-free (a scrollama replacement for this specific job).
15
+ *
16
+ * const engine = useMemo(
17
+ * () => createScrollPlayerEngine({ getContainer: () => sectionRef.current, total: frames.length }),
18
+ * [frames.length]
19
+ * );
20
+ */
21
+ declare function createScrollPlayerEngine(opts: ScrollEngineOptions): PlayerEngine;
22
+
23
+ export { type ScrollEngineOptions, createScrollPlayerEngine };
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/adapters/scroll.ts
5
+ function createScrollPlayerEngine(opts) {
6
+ return {
7
+ connect(api) {
8
+ let playing = false;
9
+ let silent = false;
10
+ const progressToFrame = (progress) => Math.min(api.total - 1, Math.max(0, Math.floor(progress * api.total)));
11
+ const emitFromScroll = () => {
12
+ if (silent || !api) return;
13
+ const el = opts.getContainer();
14
+ if (!el) return;
15
+ const rect = el.getBoundingClientRect();
16
+ const scrollable = rect.height - window.innerHeight;
17
+ if (scrollable <= 0) return;
18
+ const progress = Math.min(1, Math.max(0, -rect.top / scrollable));
19
+ const frame = progressToFrame(progress);
20
+ api.onFrame(frame);
21
+ if (playing && frame >= api.total - 1) {
22
+ playing = false;
23
+ api.onEnd();
24
+ }
25
+ };
26
+ const scrollToProgress = (progress, smooth) => {
27
+ const el = opts.getContainer();
28
+ if (!el) return;
29
+ const rect = el.getBoundingClientRect();
30
+ const scrollable = rect.height - window.innerHeight;
31
+ if (scrollable <= 0) return;
32
+ const top = window.scrollY + rect.top + scrollable * Math.min(1, Math.max(0, progress));
33
+ silent = true;
34
+ window.scrollTo({ top, behavior: smooth ? "smooth" : "auto" });
35
+ window.setTimeout(() => {
36
+ silent = false;
37
+ }, smooth ? 700 : 50);
38
+ };
39
+ const onScroll = () => emitFromScroll();
40
+ window.addEventListener("scroll", onScroll, { passive: true });
41
+ window.addEventListener("resize", onScroll);
42
+ return {
43
+ play: () => {
44
+ playing = true;
45
+ const el = opts.getContainer();
46
+ if (!el) return;
47
+ scrollToProgress(1, true);
48
+ },
49
+ pause: () => {
50
+ playing = false;
51
+ },
52
+ seek: (frame) => {
53
+ const progress = api.total > 1 ? frame / api.total : 0;
54
+ scrollToProgress(progress, false);
55
+ api.onFrame(Math.min(frame, api.total - 1));
56
+ },
57
+ setRate: () => {
58
+ },
59
+ disconnect: () => {
60
+ playing = false;
61
+ window.removeEventListener("scroll", onScroll);
62
+ window.removeEventListener("resize", onScroll);
63
+ }
64
+ };
65
+ }
66
+ };
67
+ }
68
+ export {
69
+ createScrollPlayerEngine
70
+ };
71
+ //# sourceMappingURL=scroll.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/scroll.ts"],"sourcesContent":["\"use client\";\n\nimport type { PlayerEngine, PlayerEngineApi, PlayerEngineHandle } from \"../types\";\n\nexport interface ScrollEngineOptions {\n /** The scrollytelling section whose scroll progress drives the frames. */\n getContainer: () => HTMLElement | null;\n /** Frames in the current timeline. Recreate the engine when it changes. */\n total: number;\n}\n\n/**\n * PlayerEngine driven by scroll progress through a container section — the\n * scrollytelling mode. Scrolling maps to frame indices; manual stepping seeks\n * the scroll position; \"play\" smooth-scrolls to the end of the section.\n * Dependency-free (a scrollama replacement for this specific job).\n *\n * const engine = useMemo(\n * () => createScrollPlayerEngine({ getContainer: () => sectionRef.current, total: frames.length }),\n * [frames.length]\n * );\n */\nexport function createScrollPlayerEngine(opts: ScrollEngineOptions): PlayerEngine {\n return {\n connect(api: PlayerEngineApi): PlayerEngineHandle {\n let playing = false;\n let silent = false;\n\n const progressToFrame = (progress: number) =>\n Math.min(api.total - 1, Math.max(0, Math.floor(progress * api.total)));\n\n const emitFromScroll = () => {\n if (silent || !api) return;\n const el = opts.getContainer();\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const scrollable = rect.height - window.innerHeight;\n if (scrollable <= 0) return;\n const progress = Math.min(1, Math.max(0, -rect.top / scrollable));\n const frame = progressToFrame(progress);\n api.onFrame(frame);\n if (playing && frame >= api.total - 1) {\n playing = false;\n api.onEnd();\n }\n };\n\n /** Scroll the window so the section sits at `progress` (0..1). */\n const scrollToProgress = (progress: number, smooth: boolean) => {\n const el = opts.getContainer();\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const scrollable = rect.height - window.innerHeight;\n if (scrollable <= 0) return;\n const top = window.scrollY + rect.top + scrollable * Math.min(1, Math.max(0, progress));\n silent = true;\n window.scrollTo({ top, behavior: smooth ? \"smooth\" : \"auto\" });\n // Silence the synthetic scroll events until the scroll settles.\n window.setTimeout(() => {\n silent = false;\n }, smooth ? 700 : 50);\n };\n\n const onScroll = () => emitFromScroll();\n\n window.addEventListener(\"scroll\", onScroll, { passive: true });\n window.addEventListener(\"resize\", onScroll);\n\n return {\n play: () => {\n playing = true;\n const el = opts.getContainer();\n if (!el) return;\n scrollToProgress(1, true);\n },\n pause: () => {\n playing = false;\n },\n seek: (frame) => {\n const progress = api.total > 1 ? frame / api.total : 0;\n scrollToProgress(progress, false);\n api.onFrame(Math.min(frame, api.total - 1));\n },\n setRate: () => {\n // Scroll position is user-driven; rate has no meaning here.\n },\n disconnect: () => {\n playing = false;\n window.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"resize\", onScroll);\n },\n };\n },\n };\n}\n"],"mappings":";;;;AAsBO,SAAS,yBAAyB,MAAyC;AAChF,SAAO;AAAA,IACL,QAAQ,KAA0C;AAChD,UAAI,UAAU;AACd,UAAI,SAAS;AAEb,YAAM,kBAAkB,CAAC,aACvB,KAAK,IAAI,IAAI,QAAQ,GAAG,KAAK,IAAI,GAAG,KAAK,MAAM,WAAW,IAAI,KAAK,CAAC,CAAC;AAEvE,YAAM,iBAAiB,MAAM;AAC3B,YAAI,UAAU,CAAC,IAAK;AACpB,cAAM,KAAK,KAAK,aAAa;AAC7B,YAAI,CAAC,GAAI;AACT,cAAM,OAAO,GAAG,sBAAsB;AACtC,cAAM,aAAa,KAAK,SAAS,OAAO;AACxC,YAAI,cAAc,EAAG;AACrB,cAAM,WAAW,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,CAAC,KAAK,MAAM,UAAU,CAAC;AAChE,cAAM,QAAQ,gBAAgB,QAAQ;AACtC,YAAI,QAAQ,KAAK;AACjB,YAAI,WAAW,SAAS,IAAI,QAAQ,GAAG;AACrC,oBAAU;AACV,cAAI,MAAM;AAAA,QACZ;AAAA,MACF;AAGA,YAAM,mBAAmB,CAAC,UAAkB,WAAoB;AAC9D,cAAM,KAAK,KAAK,aAAa;AAC7B,YAAI,CAAC,GAAI;AACT,cAAM,OAAO,GAAG,sBAAsB;AACtC,cAAM,aAAa,KAAK,SAAS,OAAO;AACxC,YAAI,cAAc,EAAG;AACrB,cAAM,MAAM,OAAO,UAAU,KAAK,MAAM,aAAa,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,CAAC;AACtF,iBAAS;AACT,eAAO,SAAS,EAAE,KAAK,UAAU,SAAS,WAAW,OAAO,CAAC;AAE7D,eAAO,WAAW,MAAM;AACtB,mBAAS;AAAA,QACX,GAAG,SAAS,MAAM,EAAE;AAAA,MACtB;AAEA,YAAM,WAAW,MAAM,eAAe;AAEtC,aAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,KAAK,CAAC;AAC7D,aAAO,iBAAiB,UAAU,QAAQ;AAE1C,aAAO;AAAA,QACL,MAAM,MAAM;AACV,oBAAU;AACV,gBAAM,KAAK,KAAK,aAAa;AAC7B,cAAI,CAAC,GAAI;AACT,2BAAiB,GAAG,IAAI;AAAA,QAC1B;AAAA,QACA,OAAO,MAAM;AACX,oBAAU;AAAA,QACZ;AAAA,QACA,MAAM,CAAC,UAAU;AACf,gBAAM,WAAW,IAAI,QAAQ,IAAI,QAAQ,IAAI,QAAQ;AACrD,2BAAiB,UAAU,KAAK;AAChC,cAAI,QAAQ,KAAK,IAAI,OAAO,IAAI,QAAQ,CAAC,CAAC;AAAA,QAC5C;AAAA,QACA,SAAS,MAAM;AAAA,QAEf;AAAA,QACA,YAAY,MAAM;AAChB,oBAAU;AACV,iBAAO,oBAAoB,UAAU,QAAQ;AAC7C,iBAAO,oBAAoB,UAAU,QAAQ;AAAA,QAC/C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}