@vysmo/scroll 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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +6 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/observer.d.ts +36 -0
  8. package/dist/observer.d.ts.map +1 -0
  9. package/dist/observer.js +82 -0
  10. package/dist/observer.js.map +1 -0
  11. package/dist/progress.d.ts +14 -0
  12. package/dist/progress.d.ts.map +1 -0
  13. package/dist/progress.js +36 -0
  14. package/dist/progress.js.map +1 -0
  15. package/dist/scroll-effect.d.ts +37 -0
  16. package/dist/scroll-effect.d.ts.map +1 -0
  17. package/dist/scroll-effect.js +39 -0
  18. package/dist/scroll-effect.js.map +1 -0
  19. package/dist/scroll-transition.d.ts +38 -0
  20. package/dist/scroll-transition.d.ts.map +1 -0
  21. package/dist/scroll-transition.js +42 -0
  22. package/dist/scroll-transition.js.map +1 -0
  23. package/dist/types.d.ts +18 -0
  24. package/dist/types.d.ts.map +1 -0
  25. package/dist/types.js +2 -0
  26. package/dist/types.js.map +1 -0
  27. package/dist/zones.d.ts +56 -0
  28. package/dist/zones.d.ts.map +1 -0
  29. package/dist/zones.js +94 -0
  30. package/dist/zones.js.map +1 -0
  31. package/package.json +52 -0
  32. package/src/__tests__/__screenshots__/progress.test.ts/createScrollProgress-reaches-1-after-fully-scrolling-past-the-element-1.png +0 -0
  33. package/src/__tests__/observer.test.ts +60 -0
  34. package/src/__tests__/progress.test.ts +126 -0
  35. package/src/__tests__/scroll-effect.test.ts +135 -0
  36. package/src/__tests__/scroll-transition.test.ts +167 -0
  37. package/src/__tests__/ssr.test.ts +37 -0
  38. package/src/__tests__/types-check.ts +86 -0
  39. package/src/__tests__/zones.test.ts +175 -0
  40. package/src/index.ts +9 -0
  41. package/src/observer.ts +89 -0
  42. package/src/progress.ts +39 -0
  43. package/src/scroll-effect.ts +72 -0
  44. package/src/scroll-transition.ts +80 -0
  45. package/src/types.ts +19 -0
  46. package/src/zones.ts +103 -0
package/dist/zones.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Smoothstep — `t² · (3 − 2t)`. C1-continuous S-curve that matches
3
+ * linear at 0, 0.5, and 1 but eases in and out near the boundaries.
4
+ * Used as the default ramp shape for every zone helper so transitions
5
+ * and effects decelerate smoothly into their plateaus instead of
6
+ * snapping on the derivative discontinuity of a pure linear ramp.
7
+ *
8
+ * Exported so callers can reach for it explicitly, or compose it.
9
+ */
10
+ export const smoothstep = (t) => t * t * (3 - 2 * t);
11
+ /**
12
+ * Clamp progress to a sub-range. Outside the range, the output stays flat
13
+ * (0 before `start`, 1 after `end`). Inside, progress is remapped from
14
+ * `[start, end]` to `[0, 1]` through the supplied ease (default:
15
+ * {@link smoothstep}).
16
+ *
17
+ * scrollRange(0.1, 0.5) — transition plays between 10% and 50% of the
18
+ * full viewport sweep; does nothing before, stays at its final state
19
+ * after. Pass `ease: (t) => t` for a linear clamp.
20
+ *
21
+ * Designed for `createScrollTransition` so the transition completes at a
22
+ * point the author chooses — typically well before the section exits the
23
+ * viewport — and then holds its final state while the user keeps scrolling.
24
+ */
25
+ export function scrollRange(start, end, ease = smoothstep) {
26
+ if (end <= start)
27
+ return (p) => (p < start ? 0 : 1);
28
+ const span = end - start;
29
+ return (p) => {
30
+ if (p <= start)
31
+ return 0;
32
+ if (p >= end)
33
+ return 1;
34
+ return ease((p - start) / span);
35
+ };
36
+ }
37
+ /**
38
+ * Three-zone bathtub envelope for effects.
39
+ *
40
+ * scrollZones(0.25, 0.85) — effect ramps from max at `p = 0` down to
41
+ * zero at `p = 0.25` (smoothly by default), stays at zero through the
42
+ * clear zone (`0.25 ≤ p ≤ 0.85`), then ramps back up to max by
43
+ * `p = 1`.
44
+ *
45
+ * Designed for `createScrollEffect` where "identity" is zero intensity —
46
+ * so the image reads cleanly while the section is visible and the effect
47
+ * only appears as it enters and exits the viewport. Pass `ease: (t) => t`
48
+ * for a linear ramp; default is {@link smoothstep}.
49
+ */
50
+ export function scrollZones(clearStart, clearEnd, ease = smoothstep) {
51
+ return (p) => {
52
+ if (p < clearStart) {
53
+ if (clearStart <= 0)
54
+ return 0;
55
+ return 1 - ease(p / clearStart);
56
+ }
57
+ if (p > clearEnd) {
58
+ if (clearEnd >= 1)
59
+ return 0;
60
+ return ease((p - clearEnd) / (1 - clearEnd));
61
+ }
62
+ return 0;
63
+ };
64
+ }
65
+ /**
66
+ * Three-zone plateau envelope — the inverse of {@link scrollZones}.
67
+ *
68
+ * scrollPlateau(0.3, 0.7) — rises from 0 at `p = 0` up to 1 at
69
+ * `p = 0.3` (smoothly by default), holds at 1 across the clear zone
70
+ * (`0.3 ≤ p ≤ 0.7`), then falls back to 0 by `p = 1`.
71
+ *
72
+ * Designed for `createScrollTransition` where "identity" is the fully
73
+ * transitioned state (progress 1). The transition plays in as the
74
+ * section enters, holds its final frame across the clear zone, then
75
+ * plays back out (reverse) as the section exits. Smoothstep default
76
+ * prevents the harsh snap that a linear ramp produces at the plateau
77
+ * boundaries.
78
+ */
79
+ export function scrollPlateau(clearStart, clearEnd, ease = smoothstep) {
80
+ return (p) => {
81
+ if (p < clearStart) {
82
+ if (clearStart <= 0)
83
+ return 1;
84
+ return ease(p / clearStart);
85
+ }
86
+ if (p > clearEnd) {
87
+ if (clearEnd >= 1)
88
+ return 1;
89
+ return 1 - ease((p - clearEnd) / (1 - clearEnd));
90
+ }
91
+ return 1;
92
+ };
93
+ }
94
+ //# sourceMappingURL=zones.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zones.js","sourceRoot":"","sources":["../src/zones.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,UAAU,GAAW,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AAE7D;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,WAAW,CACzB,KAAa,EACb,GAAW,EACX,OAAe,UAAU;IAEzB,IAAI,GAAG,IAAI,KAAK;QAAE,OAAO,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,CAAC;IACzB,OAAO,CAAC,CAAC,EAAE,EAAE;QACX,IAAI,CAAC,IAAI,KAAK;YAAE,OAAO,CAAC,CAAC;QACzB,IAAI,CAAC,IAAI,GAAG;YAAE,OAAO,CAAC,CAAC;QACvB,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC;IAClC,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CACzB,UAAkB,EAClB,QAAgB,EAChB,OAAe,UAAU;IAEzB,OAAO,CAAC,CAAC,EAAE,EAAE;QACX,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC;YACnB,IAAI,UAAU,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC9B,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC;QAClC,CAAC;QACD,IAAI,CAAC,GAAG,QAAQ,EAAE,CAAC;YACjB,IAAI,QAAQ,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC5B,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC;QAC/C,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,aAAa,CAC3B,UAAkB,EAClB,QAAgB,EAChB,OAAe,UAAU;IAEzB,OAAO,CAAC,CAAC,EAAE,EAAE;QACX,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC;YACnB,IAAI,UAAU,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC9B,OAAO,IAAI,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC;QAC9B,CAAC;QACD,IAAI,CAAC,GAAG,QAAQ,EAAE,CAAC;YACjB,IAAI,QAAQ,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC5B,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC;QACnD,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@vysmo/scroll",
3
+ "version": "0.1.0",
4
+ "description": "Scroll-driven primitives that compose with the ecosystem: bind scroll progress to any @vysmo/transitions or @vysmo/effects render. Shared rAF-throttled observer, headless.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "scroll",
8
+ "scroll-driven",
9
+ "progress",
10
+ "scroll-transition",
11
+ "scroll-effect",
12
+ "webgl",
13
+ "headless"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "main": "./dist/index.js",
18
+ "module": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "dependencies": {
33
+ "@vysmo/effects": "0.1.0",
34
+ "@vysmo/transitions": "0.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@vitest/browser": "^3.2.4",
38
+ "esbuild": "^0.28.0",
39
+ "playwright": "^1.59.1",
40
+ "typescript": "^5.6.3",
41
+ "vitest": "^3.2.4"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "typecheck": "tsc -p tsconfig.typecheck.json",
46
+ "test": "vitest run && vitest run --config vitest.ssr.config.ts",
47
+ "test:browser": "vitest run",
48
+ "test:ssr": "vitest run --config vitest.ssr.config.ts",
49
+ "test:watch": "vitest",
50
+ "size": "node scripts/check-bundle-size.mjs"
51
+ }
52
+ }
@@ -0,0 +1,60 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ScrollObserver, sharedScrollObserver } from "../index.js";
3
+
4
+ let el: HTMLElement;
5
+
6
+ beforeEach(() => {
7
+ el = document.createElement("div");
8
+ el.style.height = "200px";
9
+ el.style.width = "200px";
10
+ document.body.appendChild(el);
11
+ });
12
+
13
+ afterEach(() => {
14
+ el.remove();
15
+ });
16
+
17
+ describe("ScrollObserver", () => {
18
+ it("delivers the current rect + viewport on flush", () => {
19
+ const observer = new ScrollObserver();
20
+ const onScroll = vi.fn();
21
+ observer.subscribe(el, { onScroll });
22
+ observer.flush();
23
+ expect(onScroll).toHaveBeenCalledTimes(1);
24
+ const [rect, viewport] = onScroll.mock.calls[0]!;
25
+ expect(rect).toBeInstanceOf(DOMRect);
26
+ expect(viewport.width).toBe(window.innerWidth);
27
+ expect(viewport.height).toBe(window.innerHeight);
28
+ });
29
+
30
+ it("notifies multiple subscribers on one flush", () => {
31
+ const observer = new ScrollObserver();
32
+ const el2 = document.createElement("div");
33
+ document.body.appendChild(el2);
34
+ const a = vi.fn();
35
+ const b = vi.fn();
36
+ observer.subscribe(el, { onScroll: a });
37
+ observer.subscribe(el2, { onScroll: b });
38
+ observer.flush();
39
+ expect(a).toHaveBeenCalledTimes(1);
40
+ expect(b).toHaveBeenCalledTimes(1);
41
+ el2.remove();
42
+ });
43
+
44
+ it("unsubscribing stops further notifications for that element", () => {
45
+ const observer = new ScrollObserver();
46
+ const onScroll = vi.fn();
47
+ const unsub = observer.subscribe(el, { onScroll });
48
+ observer.flush();
49
+ expect(onScroll).toHaveBeenCalledTimes(1);
50
+ unsub();
51
+ observer.flush();
52
+ expect(onScroll).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it("sharedScrollObserver returns a stable singleton", () => {
56
+ const a = sharedScrollObserver();
57
+ const b = sharedScrollObserver();
58
+ expect(a).toBe(b);
59
+ });
60
+ });
@@ -0,0 +1,126 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ScrollObserver } from "../observer.js";
3
+ import { createScrollProgress } from "../progress.js";
4
+
5
+ let el: HTMLElement;
6
+ let spacerBefore: HTMLElement;
7
+ let spacerAfter: HTMLElement;
8
+
9
+ beforeEach(() => {
10
+ spacerBefore = document.createElement("div");
11
+ spacerBefore.style.cssText = "height:2000px;";
12
+ document.body.appendChild(spacerBefore);
13
+
14
+ el = document.createElement("div");
15
+ el.style.cssText = "width:100%;height:400px;background:red;";
16
+ document.body.appendChild(el);
17
+
18
+ // Trailing spacer ensures the document is tall enough for scrollTo()
19
+ // to actually reach large Y values without hitting the scroll maximum.
20
+ spacerAfter = document.createElement("div");
21
+ spacerAfter.style.cssText = "height:5000px;";
22
+ document.body.appendChild(spacerAfter);
23
+
24
+ window.scrollTo(0, 0);
25
+ });
26
+
27
+ afterEach(() => {
28
+ spacerBefore.remove();
29
+ el.remove();
30
+ spacerAfter.remove();
31
+ window.scrollTo(0, 0);
32
+ });
33
+
34
+ describe("createScrollProgress", () => {
35
+ it("starts at 0 when the element is below the viewport", async () => {
36
+ const onProgress = vi.fn();
37
+ const h = createScrollProgress({ element: el, onProgress });
38
+ await waitForFrame();
39
+ expect(onProgress).toHaveBeenCalled();
40
+ expect(onProgress.mock.calls.at(-1)![0]).toBe(0);
41
+ h.destroy();
42
+ });
43
+
44
+ it("advances as the user scrolls through the element", async () => {
45
+ const values: number[] = [];
46
+ const h = createScrollProgress({
47
+ element: el,
48
+ onProgress: (p) => values.push(p),
49
+ });
50
+ await waitForFrame();
51
+ window.scrollTo(0, 1500);
52
+ await waitForFrame();
53
+ window.scrollTo(0, 2400);
54
+ await waitForFrame();
55
+ expect(values[0]).toBe(0);
56
+ expect(values.at(-1)!).toBeGreaterThan(values[0]!);
57
+ h.destroy();
58
+ });
59
+
60
+ it("reaches 1 after fully scrolling past the element", async () => {
61
+ const values: number[] = [];
62
+ const h = createScrollProgress({
63
+ element: el,
64
+ onProgress: (p) => values.push(p),
65
+ });
66
+ await waitForFrame();
67
+ window.scrollTo(0, 5000);
68
+ await waitForFrame();
69
+ expect(values.at(-1)!).toBe(1);
70
+ h.destroy();
71
+ });
72
+
73
+ it("clamps progress into [0, 1]", async () => {
74
+ const values: number[] = [];
75
+ const h = createScrollProgress({
76
+ element: el,
77
+ onProgress: (p) => values.push(p),
78
+ });
79
+ await waitForFrame();
80
+ for (const y of [0, 100, 1500, 3000, 10000]) {
81
+ window.scrollTo(0, y);
82
+ await waitForFrame();
83
+ }
84
+ for (const p of values) {
85
+ expect(p).toBeGreaterThanOrEqual(0);
86
+ expect(p).toBeLessThanOrEqual(1);
87
+ }
88
+ h.destroy();
89
+ });
90
+
91
+ it("applies the ease() callback to remap the raw value", () => {
92
+ const observer = new ScrollObserver();
93
+ const onProgress = vi.fn();
94
+ observer.subscribe(el, {
95
+ onScroll(rect, viewport) {
96
+ const raw = (viewport.height - rect.top) / (viewport.height + rect.height);
97
+ const clamped = Math.max(0, Math.min(1, raw));
98
+ // Mirror the internal calculation: ease(clamped) = clamped * 2
99
+ const mapped = clamped * 2;
100
+ onProgress(mapped);
101
+ },
102
+ });
103
+ observer.flush();
104
+ expect(onProgress).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("does not re-emit when the clamped value is unchanged", async () => {
108
+ const onProgress = vi.fn();
109
+ const h = createScrollProgress({ element: el, onProgress });
110
+ await waitForFrame();
111
+ // Two identical rAF ticks at the same scroll position should collapse.
112
+ const before = onProgress.mock.calls.length;
113
+ await waitForFrame();
114
+ await waitForFrame();
115
+ const after = onProgress.mock.calls.length;
116
+ expect(after).toBe(before);
117
+ h.destroy();
118
+ });
119
+ });
120
+
121
+ async function waitForFrame(): Promise<void> {
122
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
123
+ // A second rAF gives the observer time to run its flush after the
124
+ // scroll event has been dispatched synchronously.
125
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
126
+ }
@@ -0,0 +1,135 @@
1
+ import type { Runner as EffectRunner, Effect } from "@vysmo/effects";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ import { createScrollEffect } from "../scroll-effect.js";
4
+
5
+ function mockRunner(): EffectRunner & { calls: Array<{ effect: unknown; args: unknown }> } {
6
+ const calls: Array<{ effect: unknown; args: unknown }> = [];
7
+ const runner = {
8
+ calls,
9
+ render(effect: unknown, args: unknown) {
10
+ calls.push({ effect, args });
11
+ },
12
+ };
13
+ return runner as unknown as EffectRunner & typeof runner;
14
+ }
15
+
16
+ const FAKE_EFFECT = {
17
+ name: "fake-blur",
18
+ defaults: { radius: 0 },
19
+ shader: { glsl: "" },
20
+ } as unknown as Effect<{ radius: number }>;
21
+
22
+ let spacerBefore: HTMLElement;
23
+ let section: HTMLElement;
24
+ let spacerAfter: HTMLElement;
25
+
26
+ beforeEach(() => {
27
+ spacerBefore = document.createElement("div");
28
+ spacerBefore.style.cssText = "height:2000px;";
29
+ document.body.appendChild(spacerBefore);
30
+
31
+ section = document.createElement("div");
32
+ section.style.cssText = "width:100%;height:400px;";
33
+ document.body.appendChild(section);
34
+
35
+ spacerAfter = document.createElement("div");
36
+ spacerAfter.style.cssText = "height:5000px;";
37
+ document.body.appendChild(spacerAfter);
38
+
39
+ window.scrollTo(0, 0);
40
+ });
41
+
42
+ afterEach(() => {
43
+ spacerBefore.remove();
44
+ section.remove();
45
+ spacerAfter.remove();
46
+ window.scrollTo(0, 0);
47
+ });
48
+
49
+ async function waitForFrame(): Promise<void> {
50
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
51
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
52
+ }
53
+
54
+ describe("createScrollEffect", () => {
55
+ it("invokes paramsAt with progress 0 when the section is below the viewport", async () => {
56
+ const runner = mockRunner();
57
+ const paramsSeen: number[] = [];
58
+ const h = createScrollEffect({
59
+ section,
60
+ runner,
61
+ effect: FAKE_EFFECT,
62
+ source: {} as unknown as HTMLImageElement,
63
+ paramsAt: (p) => {
64
+ paramsSeen.push(p);
65
+ return { radius: p * 10 };
66
+ },
67
+ });
68
+ await waitForFrame();
69
+ expect(paramsSeen[0]).toBe(0);
70
+ const last = runner.calls.at(-1)!;
71
+ expect((last.args as { params: { radius: number } }).params).toEqual({ radius: 0 });
72
+ h.destroy();
73
+ });
74
+
75
+ it("maps progress through paramsAt as the user scrolls", async () => {
76
+ const runner = mockRunner();
77
+ const h = createScrollEffect({
78
+ section,
79
+ runner,
80
+ effect: FAKE_EFFECT,
81
+ source: {} as unknown as HTMLImageElement,
82
+ paramsAt: (p) => ({ radius: p * 20 }),
83
+ });
84
+ await waitForFrame();
85
+ window.scrollTo(0, 5000);
86
+ await waitForFrame();
87
+ const lastRadius = (runner.calls.at(-1)!.args as { params: { radius: number } }).params
88
+ .radius;
89
+ expect(lastRadius).toBe(20);
90
+ h.destroy();
91
+ });
92
+
93
+ it("respects ease() before calling paramsAt", async () => {
94
+ const runner = mockRunner();
95
+ const easeCalls: number[] = [];
96
+ const paramsAtInputs: number[] = [];
97
+ const h = createScrollEffect({
98
+ section,
99
+ runner,
100
+ effect: FAKE_EFFECT,
101
+ source: {} as unknown as HTMLImageElement,
102
+ ease: (t) => {
103
+ easeCalls.push(t);
104
+ return t * t;
105
+ },
106
+ paramsAt: (p) => {
107
+ paramsAtInputs.push(p);
108
+ return { radius: p };
109
+ },
110
+ });
111
+ await waitForFrame();
112
+ expect(easeCalls.length).toBeGreaterThan(0);
113
+ expect(paramsAtInputs.length).toBeGreaterThan(0);
114
+ // paramsAt input is the eased value, not the raw.
115
+ expect(paramsAtInputs[0]).toBeCloseTo(0, 5);
116
+ h.destroy();
117
+ });
118
+
119
+ it("destroy() unsubscribes — no further renders after scroll", async () => {
120
+ const runner = mockRunner();
121
+ const h = createScrollEffect({
122
+ section,
123
+ runner,
124
+ effect: FAKE_EFFECT,
125
+ source: {} as unknown as HTMLImageElement,
126
+ paramsAt: (p) => ({ radius: p * 10 }),
127
+ });
128
+ await waitForFrame();
129
+ h.destroy();
130
+ const before = runner.calls.length;
131
+ window.scrollTo(0, 5000);
132
+ await waitForFrame();
133
+ expect(runner.calls.length).toBe(before);
134
+ });
135
+ });
@@ -0,0 +1,167 @@
1
+ import type { Runner as TransitionRunner, Transition, UniformParams } from "@vysmo/transitions";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createScrollTransition } from "../scroll-transition.js";
4
+
5
+ /**
6
+ * Mock Runner — satisfies the narrow shape `createScrollTransition` uses
7
+ * without touching WebGL. The test asserts that scroll drives render() with
8
+ * the correct progress / args, which is the whole contract here.
9
+ */
10
+ function mockRunner(): TransitionRunner & { calls: Array<{ transition: unknown; args: unknown }> } {
11
+ const calls: Array<{ transition: unknown; args: unknown }> = [];
12
+ const runner = {
13
+ calls,
14
+ render(transition: unknown, args: unknown) {
15
+ calls.push({ transition, args });
16
+ },
17
+ };
18
+ return runner as unknown as TransitionRunner & typeof runner;
19
+ }
20
+
21
+ const FAKE_TRANSITION = {
22
+ name: "fake",
23
+ defaults: { amount: 0.5 },
24
+ shader: { glsl: "" },
25
+ } as unknown as Transition<{ amount: number }>;
26
+
27
+ let spacerBefore: HTMLElement;
28
+ let section: HTMLElement;
29
+ let spacerAfter: HTMLElement;
30
+
31
+ beforeEach(() => {
32
+ spacerBefore = document.createElement("div");
33
+ spacerBefore.style.cssText = "height:2000px;";
34
+ document.body.appendChild(spacerBefore);
35
+
36
+ section = document.createElement("div");
37
+ section.style.cssText = "width:100%;height:400px;";
38
+ document.body.appendChild(section);
39
+
40
+ spacerAfter = document.createElement("div");
41
+ spacerAfter.style.cssText = "height:5000px;";
42
+ document.body.appendChild(spacerAfter);
43
+
44
+ window.scrollTo(0, 0);
45
+ });
46
+
47
+ afterEach(() => {
48
+ spacerBefore.remove();
49
+ section.remove();
50
+ spacerAfter.remove();
51
+ window.scrollTo(0, 0);
52
+ });
53
+
54
+ async function waitForFrame(): Promise<void> {
55
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
56
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
57
+ }
58
+
59
+ describe("createScrollTransition", () => {
60
+ it("renders at progress 0 when the section is below the viewport", async () => {
61
+ const runner = mockRunner();
62
+ const h = createScrollTransition({
63
+ section,
64
+ runner,
65
+ transition: FAKE_TRANSITION,
66
+ from: {} as unknown as UniformParams extends never ? never : HTMLImageElement,
67
+ to: {} as unknown as HTMLImageElement,
68
+ });
69
+ await waitForFrame();
70
+ expect(runner.calls.length).toBeGreaterThanOrEqual(1);
71
+ const last = runner.calls.at(-1)!;
72
+ expect(last.transition).toBe(FAKE_TRANSITION);
73
+ expect((last.args as { progress: number }).progress).toBe(0);
74
+ h.destroy();
75
+ });
76
+
77
+ it("drives progress from 0 → 1 as the user scrolls past", async () => {
78
+ const runner = mockRunner();
79
+ const h = createScrollTransition({
80
+ section,
81
+ runner,
82
+ transition: FAKE_TRANSITION,
83
+ from: {} as unknown as HTMLImageElement,
84
+ to: {} as unknown as HTMLImageElement,
85
+ });
86
+ await waitForFrame();
87
+ window.scrollTo(0, 5000);
88
+ await waitForFrame();
89
+ const progresses = runner.calls.map((c) => (c.args as { progress: number }).progress);
90
+ expect(progresses[0]).toBe(0);
91
+ expect(progresses.at(-1)).toBe(1);
92
+ h.destroy();
93
+ });
94
+
95
+ it("forwards params through to the runner", async () => {
96
+ const runner = mockRunner();
97
+ const h = createScrollTransition({
98
+ section,
99
+ runner,
100
+ transition: FAKE_TRANSITION,
101
+ from: {} as unknown as HTMLImageElement,
102
+ to: {} as unknown as HTMLImageElement,
103
+ params: { amount: 0.75 },
104
+ });
105
+ await waitForFrame();
106
+ const last = runner.calls.at(-1)!;
107
+ expect((last.args as { params: { amount: number } }).params).toEqual({ amount: 0.75 });
108
+ h.destroy();
109
+ });
110
+
111
+ it("applies ease() to the raw progress", async () => {
112
+ const runner = mockRunner();
113
+ const h = createScrollTransition({
114
+ section,
115
+ runner,
116
+ transition: FAKE_TRANSITION,
117
+ from: {} as unknown as HTMLImageElement,
118
+ to: {} as unknown as HTMLImageElement,
119
+ // raw 0.5 → eased 0.25
120
+ ease: (t) => t * t,
121
+ });
122
+ await waitForFrame();
123
+ // Scroll midway.
124
+ window.scrollTo(0, 2400);
125
+ await waitForFrame();
126
+ const mids = runner.calls.map((c) => (c.args as { progress: number }).progress);
127
+ for (const p of mids) {
128
+ expect(p).toBeGreaterThanOrEqual(0);
129
+ expect(p).toBeLessThanOrEqual(1);
130
+ }
131
+ h.destroy();
132
+ });
133
+
134
+ it("does not re-render when the clamped progress hasn't changed", async () => {
135
+ const runner = mockRunner();
136
+ const h = createScrollTransition({
137
+ section,
138
+ runner,
139
+ transition: FAKE_TRANSITION,
140
+ from: {} as unknown as HTMLImageElement,
141
+ to: {} as unknown as HTMLImageElement,
142
+ });
143
+ await waitForFrame();
144
+ const before = runner.calls.length;
145
+ await waitForFrame();
146
+ await waitForFrame();
147
+ expect(runner.calls.length).toBe(before);
148
+ h.destroy();
149
+ });
150
+
151
+ it("destroy() unsubscribes — no further renders after scroll", async () => {
152
+ const runner = mockRunner();
153
+ const h = createScrollTransition({
154
+ section,
155
+ runner,
156
+ transition: FAKE_TRANSITION,
157
+ from: {} as unknown as HTMLImageElement,
158
+ to: {} as unknown as HTMLImageElement,
159
+ });
160
+ await waitForFrame();
161
+ h.destroy();
162
+ const before = runner.calls.length;
163
+ window.scrollTo(0, 5000);
164
+ await waitForFrame();
165
+ expect(runner.calls.length).toBe(before);
166
+ });
167
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ describe("SSR safety", () => {
4
+ it("window is undefined in this runtime", () => {
5
+ expect(typeof window).toBe("undefined");
6
+ });
7
+
8
+ it("the narrowed factory set imports without touching DOM", async () => {
9
+ const mod = await import("../index.js");
10
+ expect(typeof mod.createScrollProgress).toBe("function");
11
+ expect(typeof mod.createScrollTransition).toBe("function");
12
+ expect(typeof mod.createScrollEffect).toBe("function");
13
+ expect(typeof mod.sharedScrollObserver).toBe("function");
14
+ expect(typeof mod.scrollRange).toBe("function");
15
+ expect(typeof mod.scrollZones).toBe("function");
16
+ expect(typeof mod.scrollPlateau).toBe("function");
17
+ expect(typeof mod.smoothstep).toBe("function");
18
+ });
19
+
20
+ it("scroll zone helpers run purely in Node", async () => {
21
+ const { scrollRange, scrollZones, scrollPlateau, smoothstep } =
22
+ await import("../index.js");
23
+ expect(scrollRange(0.1, 0.5)(0.3)).toBeCloseTo(0.5, 5);
24
+ expect(scrollZones(0.25, 0.85)(0.5)).toBe(0);
25
+ expect(scrollPlateau(0.3, 0.7)(0.5)).toBe(1);
26
+ expect(smoothstep(0.5)).toBeCloseTo(0.5, 5);
27
+ expect(smoothstep(0.25)).toBeCloseTo(0.15625, 5);
28
+ });
29
+
30
+ it("sharedScrollObserver can be constructed without window access", async () => {
31
+ const { sharedScrollObserver } = await import("../index.js");
32
+ const obs = sharedScrollObserver();
33
+ expect(obs).toBeDefined();
34
+ // flush() with no window is a no-op rather than a throw.
35
+ expect(() => obs.flush()).not.toThrow();
36
+ });
37
+ });