nodality 1.0.221 → 1.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/API.md +227 -0
- package/bin/nodality.js +64 -0
- package/dist/animator.cjs.js +1 -1
- package/dist/animator.cjs.js.LICENSE.txt +1 -1
- package/dist/animator.esm.js +1 -1
- package/dist/animator.esm.js.LICENSE.txt +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.cjs.js.LICENSE.txt +1 -1
- package/dist/audionew.esm.js +1 -1
- package/dist/audionew.esm.js.LICENSE.txt +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.cjs.js.LICENSE.txt +1 -1
- package/dist/base.esm.js +1 -1
- package/dist/base.esm.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.esm.js +1 -1
- package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.esm.js +1 -1
- package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/bundle.umd.js.LICENSE.txt +131 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.cjs.js.LICENSE.txt +1 -1
- package/dist/button.esm.js +1 -1
- package/dist/button.esm.js.LICENSE.txt +1 -1
- package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/card-getter.esm.js.LICENSE.txt +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.cjs.js.LICENSE.txt +1 -1
- package/dist/center.esm.js +1 -1
- package/dist/center.esm.js.LICENSE.txt +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
- package/dist/checkbox.esm.js +1 -1
- package/dist/checkbox.esm.js.LICENSE.txt +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.cjs.js.LICENSE.txt +1 -1
- package/dist/code.esm.js +1 -1
- package/dist/code.esm.js.LICENSE.txt +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.cjs.js.LICENSE.txt +1 -1
- package/dist/container.esm.js +1 -1
- package/dist/container.esm.js.LICENSE.txt +1 -1
- package/dist/data-list.cjs.js +1 -1
- package/dist/data-list.cjs.js.LICENSE.txt +1 -1
- package/dist/data-list.esm.js +1 -1
- package/dist/data-list.esm.js.LICENSE.txt +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.cjs.js.LICENSE.txt +97 -1
- package/dist/designer.esm.js +1 -1
- package/dist/designer.esm.js.LICENSE.txt +97 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.cjs.js.LICENSE.txt +17 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/element-mapper.esm.js.LICENSE.txt +17 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/finalresult.esm.js.LICENSE.txt +158 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-card.esm.js +1 -1
- package/dist/flex-card.esm.js.LICENSE.txt +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-grid.esm.js +1 -1
- package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-row.esm.js +1 -1
- package/dist/flex-row.esm.js.LICENSE.txt +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
- package/dist/floating-input.esm.js +1 -1
- package/dist/floating-input.esm.js.LICENSE.txt +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.cjs.js.LICENSE.txt +1 -1
- package/dist/free.esm.js +1 -1
- package/dist/free.esm.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
- package/dist/image-picker.esm.js +1 -1
- package/dist/image-picker.esm.js.LICENSE.txt +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.cjs.js.LICENSE.txt +1 -1
- package/dist/image.esm.js +1 -1
- package/dist/image.esm.js.LICENSE.txt +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.LICENSE.txt +158 -1
- package/dist/index.d.ts +962 -0
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.LICENSE.txt +158 -1
- package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/link-getter.esm.js +1 -1
- package/dist/link-getter.esm.js.LICENSE.txt +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.cjs.js.LICENSE.txt +1 -1
- package/dist/link.esm.js +1 -1
- package/dist/link.esm.js.LICENSE.txt +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
- package/dist/meta-adder.esm.js +1 -1
- package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/modal-2025.esm.js +1 -1
- package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.esm.js +1 -1
- package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.esm.js +1 -1
- package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.cjs.js.LICENSE.txt +1 -1
- package/dist/picker.esm.js +1 -1
- package/dist/picker.esm.js.LICENSE.txt +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.cjs.js.LICENSE.txt +1 -1
- package/dist/progress.esm.js +1 -1
- package/dist/progress.esm.js.LICENSE.txt +1 -1
- package/dist/radio.cjs.js +1 -1
- package/dist/radio.cjs.js.LICENSE.txt +1 -1
- package/dist/radio.esm.js +1 -1
- package/dist/radio.esm.js.LICENSE.txt +1 -1
- package/dist/range.cjs.js +1 -1
- package/dist/range.cjs.js.LICENSE.txt +1 -1
- package/dist/range.esm.js +1 -1
- package/dist/range.esm.js.LICENSE.txt +1 -1
- package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
- package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-bar.esm.js +1 -1
- package/dist/side-bar.esm.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.esm.js +1 -1
- package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/simple-bar.esm.js +1 -1
- package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/slider-2025.esm.js +1 -1
- package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.cjs.js.LICENSE.txt +1 -1
- package/dist/spacer.esm.js +1 -1
- package/dist/spacer.esm.js.LICENSE.txt +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.cjs.js.LICENSE.txt +1 -1
- package/dist/stack.esm.js +1 -1
- package/dist/stack.esm.js.LICENSE.txt +1 -1
- package/dist/stacker.cjs.js.LICENSE.txt +1 -1
- package/dist/stacker.esm.js.LICENSE.txt +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.cjs.js.LICENSE.txt +1 -1
- package/dist/table.esm.js +1 -1
- package/dist/table.esm.js.LICENSE.txt +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.cjs.js.LICENSE.txt +1 -1
- package/dist/text-field.esm.js +1 -1
- package/dist/text-field.esm.js.LICENSE.txt +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.cjs.js.LICENSE.txt +1 -1
- package/dist/text.esm.js +1 -1
- package/dist/text.esm.js.LICENSE.txt +1 -1
- package/dist/theme.cjs.js.LICENSE.txt +1 -1
- package/dist/theme.esm.js.LICENSE.txt +1 -1
- package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
- package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.cjs.js.LICENSE.txt +1 -1
- package/dist/ulist.esm.js +1 -1
- package/dist/ulist.esm.js.LICENSE.txt +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.cjs.js.LICENSE.txt +1 -1
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.LICENSE.txt +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.cjs.js.LICENSE.txt +1 -1
- package/dist/wrap.esm.js +1 -1
- package/dist/wrap.esm.js.LICENSE.txt +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
- package/dist/zoom-card.esm.js +1 -1
- package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
- package/examples/custom-raster-op.js +174 -0
- package/layout/animator.js +30 -2
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +3 -2
- package/layout/button.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +2 -2
- package/layout/circle.js +3 -2
- package/layout/code.js +3 -2
- package/layout/container.js +3 -4
- package/layout/dropdown-2025.js +1 -1
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +3 -2
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +2 -2
- package/layout/form-components/floating-input.js +2 -2
- package/layout/form-components/form-all.js +2 -2
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +2 -2
- package/layout/form-components/radio.js +2 -2
- package/layout/form-components/radiogroup.js +2 -2
- package/layout/form-components/range.js +2 -2
- package/layout/free.js +3 -4
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/link.js +5 -5
- package/layout/list.js +3 -4
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/morph.js +1003 -0
- package/layout/multiswitcher.js +11 -3
- package/layout/nav-bar.js +2 -3
- package/layout/nav-factor/custom-div.js +2 -2
- package/layout/new-nav-bar.js +11 -4
- package/layout/polygon.js +3 -4
- package/layout/prerender-site.js +1 -1
- package/layout/prerender.js +1 -1
- package/layout/progress.js +1 -1
- package/layout/row.js +1 -1
- package/layout/scroll-video.js +1 -1
- package/layout/side-bar.js +1 -1
- package/layout/side-nav-bar.js +1 -1
- package/layout/simple-bar.js +1 -1
- package/layout/slider-2025.js +1 -1
- package/layout/spacer.js +1 -1
- package/layout/stack.js +1 -1
- package/layout/svg.js +3 -4
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +5 -5
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/wrap.js +1 -1
- package/layout/zoom-card.js +1 -1
- package/lib/card-getter.js +1 -1
- package/lib/codegen.js +62 -0
- package/lib/data.js +37 -1
- package/lib/designer.js +18 -1
- package/lib/element-mapper.js +351 -247
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/morph-node.js +424 -0
- package/lib/raster-inspect.js +409 -0
- package/lib/raster-ops.js +1980 -81
- package/lib/raster-presets.js +329 -0
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +28 -1
- package/lib/stacker.js +1 -1
- package/lib/suggest.js +66 -0
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/lib/transition.js +205 -0
- package/package.json +20 -7
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* nodality v1.1.0
|
|
3
|
+
* (c) 2026 Filip Vabrousek
|
|
4
|
+
* License: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/*!
|
|
8
|
+
* raster-presets.js — named, COMPOSABLE raster op chains.
|
|
9
|
+
*
|
|
10
|
+
* A preset is data: an array of raster nodes, exactly what you would have
|
|
11
|
+
* written by hand. `preset("liquid-hero")` is a shortcut, not a wrapper —
|
|
12
|
+
* the value it returns is plain nodes, and once you have it the preset has
|
|
13
|
+
* no further existence. Nothing at runtime depends on this module.
|
|
14
|
+
*
|
|
15
|
+
* new Text("Hold").set({ raster: preset("liquid-hero") })
|
|
16
|
+
*
|
|
17
|
+
* COMBINING is the point (the Houdini part). Chains already compose in the
|
|
18
|
+
* pipeline — stages run field → warp → cell → displace → color whatever
|
|
19
|
+
* order the nodes are written in — but two chains concatenated naively can
|
|
20
|
+
* still collide, in one specific way: FIELDS ARE GLOBAL. If two presets
|
|
21
|
+
* both write `as: "mask"`, the second silently overwrites the first and
|
|
22
|
+
* both look subtly wrong. `compose()` renames each preset's private fields
|
|
23
|
+
* so that cannot happen:
|
|
24
|
+
*
|
|
25
|
+
* compose("liquid-hero", "print-shop") // fluid, then screened
|
|
26
|
+
* compose("spotlight", ["ripple", { strength: 40 }])
|
|
27
|
+
*
|
|
28
|
+
* A field a preset CONSUMES but does not PRODUCE is left untouched, so
|
|
29
|
+
* deliberate cross-wiring still works: produce a field in one preset, read
|
|
30
|
+
* it in the next.
|
|
31
|
+
*
|
|
32
|
+
* Pure — no DOM, no imports beyond the op vocabulary it validates against.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { RASTER_OP_NAMES, DRIVER_NAMES } from "./raster-ops.js";
|
|
36
|
+
import { didYouMean } from "./suggest.js";
|
|
37
|
+
|
|
38
|
+
// `live` records what the HTML-in-Canvas backend buys a preset:
|
|
39
|
+
// undefined — snapshot is the whole effect; live changes nothing visible
|
|
40
|
+
// "enhanced" — works on snapshot, better live (a static field vs a live one)
|
|
41
|
+
// "required" — needs live capture; on snapshot it correctly does nothing
|
|
42
|
+
//
|
|
43
|
+
// The gallery badges from this. It is documentation, not a gate: the
|
|
44
|
+
// pipeline decides the backend itself and degrades silently either way.
|
|
45
|
+
const PRESETS = {
|
|
46
|
+
// ── transitions (phase T3) ───────────────────────────────────────
|
|
47
|
+
// Chains meant for transition mode: every param keyframed so the
|
|
48
|
+
// effect is absent at both ends and peaks in the middle. They are
|
|
49
|
+
// ordinary nodes — no transition-specific authoring language — which
|
|
50
|
+
// is why compose() and every third-party op still work here.
|
|
51
|
+
"t-vhs": {
|
|
52
|
+
summary: "Tape-glitch morph: chroma splits and the image tears, " +
|
|
53
|
+
"strongest at the midpoint, gone at both ends.",
|
|
54
|
+
nodes: [
|
|
55
|
+
{ op: "aberration", amount: [0, 18, 0] },
|
|
56
|
+
// Windowed so the tear starts after the split has opened —
|
|
57
|
+
// staggering inside one preset rather than needing a timeline.
|
|
58
|
+
{ op: "flow", strength: [0, 34, 0], scale: 60, speed: 0,
|
|
59
|
+
window: [0.15, 0.95], ease: "in-out" },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
"t-dissolve": {
|
|
63
|
+
summary: "Ordered-dither dissolve — the old state breaks into " +
|
|
64
|
+
"pixels before the new one resolves.",
|
|
65
|
+
nodes: [
|
|
66
|
+
// Identity at BOTH ends. A transition preset that still shows
|
|
67
|
+
// its effect at t=0 or t=1 pops when the canvas hands the
|
|
68
|
+
// frame back to the real element.
|
|
69
|
+
{ op: "dither", levels: [8, 2, 8], size: [1, 7, 1], amount: [0, 1, 0],
|
|
70
|
+
ease: "in-out" },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
// The first preset that treats the two halves of a morph as separate
|
|
74
|
+
// things: the outgoing state burns to ink while the incoming one
|
|
75
|
+
// resolves out of a screen. A crossfade decorated after the blend
|
|
76
|
+
// cannot express this — both ends would get the same treatment.
|
|
77
|
+
"t-split": {
|
|
78
|
+
summary: "Per-side morph: the old state posterises to duotone while " +
|
|
79
|
+
"the new one resolves through a halftone screen.",
|
|
80
|
+
nodes: [
|
|
81
|
+
{ op: "duotone", side: "old", amount: [0, 1, 1],
|
|
82
|
+
dark: "#0B1B2B", light: "#1abc9c" },
|
|
83
|
+
{ op: "halftone", side: "new", size: [10, 6, 2], amount: [1, 1, 0],
|
|
84
|
+
softness: 0.35, angle: 15 },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
"t-bloom": {
|
|
89
|
+
summary: "A soft halftone bloom through the middle of the morph.",
|
|
90
|
+
nodes: [
|
|
91
|
+
{ op: "halftone", size: [2, 9, 2], amount: [0, 1, 0], softness: 0.4, angle: 15,
|
|
92
|
+
window: [0.1, 0.9] },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// ── fluid ────────────────────────────────────────────────────────
|
|
97
|
+
// The two `stir` numbers are the PROVEN values from
|
|
98
|
+
// designerTest/raster-spot.html, not fresh guesses: radius 0.055 is
|
|
99
|
+
// the splat width at which consecutive pointer samples overlap into a
|
|
100
|
+
// stroke rather than a wash, and resolution 256 is where the
|
|
101
|
+
// deformation reads as soft instead of faceted.
|
|
102
|
+
"liquid-hero": {
|
|
103
|
+
summary: "A ripple that trails the pointer, then relaxes back in ~1.5s.",
|
|
104
|
+
live: "enhanced",
|
|
105
|
+
nodes: [{ op: "stir", by: "mouse", radius: 0.055, force: 2.2,
|
|
106
|
+
strength: 16, curl: 0.8, resolution: 256, decay: 0.988 }],
|
|
107
|
+
},
|
|
108
|
+
linger: {
|
|
109
|
+
summary: "The same current, held for four or five seconds. Only `decay` differs.",
|
|
110
|
+
live: "enhanced",
|
|
111
|
+
nodes: [{ op: "stir", by: "mouse", radius: 0.055, force: 2.2,
|
|
112
|
+
strength: 7, curl: 0.8, resolution: 256, decay: 0.9985,
|
|
113
|
+
dye: 0, sheen: 0 }],
|
|
114
|
+
},
|
|
115
|
+
// Divergence-free by construction, so it looks fluid with no solver —
|
|
116
|
+
// and unlike `stir` it needs no float render targets.
|
|
117
|
+
"flow-field": {
|
|
118
|
+
summary: "A standing current across the whole element. No solver, runs anywhere.",
|
|
119
|
+
nodes: [{ op: "flow", strength: 18, scale: 120, speed: 0.25 }],
|
|
120
|
+
},
|
|
121
|
+
"flow-pointer": {
|
|
122
|
+
summary: "The same current, but only around the pointer.",
|
|
123
|
+
nodes: [{ op: "flow", by: "mouse", strength: 26, scale: 100, radius: 320 }],
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// ── print ────────────────────────────────────────────────────────
|
|
127
|
+
"print-shop": {
|
|
128
|
+
summary: "Offset-litho dot screen with white seams under the glyphs.",
|
|
129
|
+
nodes: [{ op: "halftone", size: 6, angle: 15 },
|
|
130
|
+
{ op: "edges", color: "#FFFFFF" }],
|
|
131
|
+
},
|
|
132
|
+
newsprint: {
|
|
133
|
+
summary: "One-bit ordered dither — ink on paper, no greys.",
|
|
134
|
+
nodes: [{ op: "dither", mono: true, ink: "#0B1B2B" }],
|
|
135
|
+
},
|
|
136
|
+
crt: {
|
|
137
|
+
summary: "Posterised to a few levels, with a lens-like colour split.",
|
|
138
|
+
nodes: [{ op: "dither", levels: 3 }, { op: "aberration", amount: 6 }],
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// ── glass and lenses ─────────────────────────────────────────────
|
|
142
|
+
glass: {
|
|
143
|
+
summary: "Refractive metaball lens over untouched content.",
|
|
144
|
+
nodes: [{ op: "blobs", refract: 70, dispersion: 1, frost: 0.25 }],
|
|
145
|
+
},
|
|
146
|
+
"hex-lens": {
|
|
147
|
+
summary: "Hex cells rise toward the pointer; flush and invisible at rest.",
|
|
148
|
+
nodes: [{ op: "hexalize", size: 26, lift: 0.3, by: "hover", radius: 220 }],
|
|
149
|
+
},
|
|
150
|
+
ripple: {
|
|
151
|
+
summary: "The coordinate space pushes away from the pointer.",
|
|
152
|
+
nodes: [{ op: "offset", by: "mouse", strength: 22, radius: 280 }],
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// ── masked ───────────────────────────────────────────────────────
|
|
156
|
+
// A field producer plus a consumer: the screen exists only inside a
|
|
157
|
+
// disc that rides with the pointer. `compose()` renames "spot" when
|
|
158
|
+
// this is combined with anything else that produces a field.
|
|
159
|
+
spotlight: {
|
|
160
|
+
summary: "A duotone disc that travels with the pointer; the rest is untouched.",
|
|
161
|
+
nodes: [{ op: "mask", from: "radial", as: "spot", by: "mouse", radius: 200 },
|
|
162
|
+
{ op: "duotone", masked: "spot", colors: ["#104B87", "#E8FF00"] }],
|
|
163
|
+
},
|
|
164
|
+
turbulent: {
|
|
165
|
+
summary: "Animated noise drives the displacement, so the warp breathes.",
|
|
166
|
+
nodes: [{ op: "noise", as: "turbulence", scale: 3, speed: 0.4 },
|
|
167
|
+
{ op: "offset", masked: "turbulence", by: "static", strength: 30 }],
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// ── temporal ─────────────────────────────────────────────────────
|
|
171
|
+
echoes: {
|
|
172
|
+
summary: "Changing content leaves a fading trail behind it.",
|
|
173
|
+
live: "required",
|
|
174
|
+
nodes: [{ op: "echo", decay: 0.92, strength: 0.85 }],
|
|
175
|
+
},
|
|
176
|
+
// The clearest live-only demonstration in the set, and the reason it is
|
|
177
|
+
// worth having a second one: a long tinted trail over content that is
|
|
178
|
+
// CHANGING. `echo` accumulates the texture into a persistent buffer, so
|
|
179
|
+
// on the snapshot backend — one frozen image, re-uploaded never — the
|
|
180
|
+
// accumulation converges to that same still frame and you see nothing
|
|
181
|
+
// at all. That is not a bug to work around; it is the honest signal
|
|
182
|
+
// that this effect needs per-frame capture of live DOM.
|
|
183
|
+
//
|
|
184
|
+
// Run it with the origin trial off and the content simply sits there.
|
|
185
|
+
// Turn on chrome://flags/#canvas-draw-element and it smears.
|
|
186
|
+
comet: {
|
|
187
|
+
summary: "A long, tinted comet tail. Needs live capture — on snapshot you see nothing move.",
|
|
188
|
+
live: "required",
|
|
189
|
+
nodes: [{ op: "echo", decay: 0.975, strength: 0.95,
|
|
190
|
+
tint: "#7FD4FF", tintAmount: 0.65 }],
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
195
|
+
|
|
196
|
+
/** Preset names, in declaration order. */
|
|
197
|
+
function presetNames() {
|
|
198
|
+
return Object.keys(PRESETS);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `{ summary, live }` for a preset — what the gallery badges from. */
|
|
202
|
+
function presetInfo(name) {
|
|
203
|
+
const def = PRESETS[name];
|
|
204
|
+
if (!def) throw new Error("[nodality] " + didYouMean(name, presetNames(), "raster preset"));
|
|
205
|
+
return { summary: def.summary, live: def.live };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A preset's nodes, as a fresh array.
|
|
210
|
+
*
|
|
211
|
+
* `overrides` is applied to EVERY node in the chain, shallowly — which is
|
|
212
|
+
* what you want for the single-node presets and for cross-cutting knobs
|
|
213
|
+
* like `by:`; reach for the raw array when a multi-node chain needs
|
|
214
|
+
* per-node control.
|
|
215
|
+
*
|
|
216
|
+
* Deep-cloned, so a caller mutating the result cannot corrupt the registry
|
|
217
|
+
* for the next caller. That matters more than it sounds: the pipeline
|
|
218
|
+
* writes to node objects (solver state, discovered uniform forms).
|
|
219
|
+
*/
|
|
220
|
+
function preset(name, overrides = {}) {
|
|
221
|
+
const def = PRESETS[name];
|
|
222
|
+
if (!def) throw new Error("[nodality] " + didYouMean(name, presetNames(), "raster preset"));
|
|
223
|
+
return clone(def.nodes).map((node) => Object.assign(node, overrides));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Which fields a chain writes (`as:`, defaulting to "mask") and which it
|
|
227
|
+
// reads (`masked:`, where `true` means the default field).
|
|
228
|
+
const producedField = (n) => (n.op === "mask" || n.op === "noise" ? (n.as || "mask") : null);
|
|
229
|
+
const consumedField = (n) =>
|
|
230
|
+
n.masked === true ? "mask" : (typeof n.masked === "string" ? n.masked : null);
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Concatenate presets into one chain, safe against field collisions.
|
|
234
|
+
*
|
|
235
|
+
* Accepts names, `[name, overrides]` pairs, or raw node arrays:
|
|
236
|
+
*
|
|
237
|
+
* compose("liquid-hero", "print-shop")
|
|
238
|
+
* compose(["ripple", { strength: 40 }], "newsprint")
|
|
239
|
+
* compose("spotlight", [{ op: "halftone" }])
|
|
240
|
+
*
|
|
241
|
+
* A field that a preset both produces and consumes is PRIVATE and gets
|
|
242
|
+
* renamed (`spot` → `c0_spot`), so two presets that happen to use the same
|
|
243
|
+
* field name cannot overwrite each other. A field consumed but not
|
|
244
|
+
* produced within the same preset is left alone, so you can still wire one
|
|
245
|
+
* preset's field into the next deliberately.
|
|
246
|
+
*
|
|
247
|
+
* Order is preserved and matters within a stage; across stages the
|
|
248
|
+
* pipeline's own field→warp→cell→displace→color ordering applies, so
|
|
249
|
+
* `compose("print-shop", "ripple")` and the reverse render the same.
|
|
250
|
+
*/
|
|
251
|
+
function compose(...parts) {
|
|
252
|
+
const out = [];
|
|
253
|
+
parts.forEach((part, i) => {
|
|
254
|
+
const nodes = Array.isArray(part) && part.length && typeof part[0] === "object"
|
|
255
|
+
? clone(part) // raw node array
|
|
256
|
+
: Array.isArray(part)
|
|
257
|
+
? preset(part[0], part[1] || {}) // [name, overrides]
|
|
258
|
+
: preset(part); // name
|
|
259
|
+
|
|
260
|
+
// A field is PRIVATE to this part only if the part both produces
|
|
261
|
+
// AND consumes it — that is a self-contained subgraph, and its
|
|
262
|
+
// name is an implementation detail nobody outside should see.
|
|
263
|
+
//
|
|
264
|
+
// Produced-but-not-consumed is EXPOSED: the part is publishing a
|
|
265
|
+
// field for a later part to read, so renaming it would break the
|
|
266
|
+
// wiring. Consumed-but-not-produced is likewise left alone: the
|
|
267
|
+
// part is reading someone else's field. Renaming either is how the
|
|
268
|
+
// first draft of this broke deliberate cross-wiring.
|
|
269
|
+
const produced = new Set(nodes.map(producedField).filter(Boolean));
|
|
270
|
+
const consumed = new Set(nodes.map(consumedField).filter(Boolean));
|
|
271
|
+
const isPrivate = (f) => produced.has(f) && consumed.has(f);
|
|
272
|
+
const rename = (f) => `c${i}_${f}`;
|
|
273
|
+
for (const node of nodes) {
|
|
274
|
+
const p = producedField(node);
|
|
275
|
+
if (p && isPrivate(p)) node.as = rename(p);
|
|
276
|
+
const c = consumedField(node);
|
|
277
|
+
if (c && isPrivate(c)) node.masked = rename(c);
|
|
278
|
+
}
|
|
279
|
+
out.push(...nodes);
|
|
280
|
+
});
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Every op name, driver and field reference in a chain, checked against the
|
|
286
|
+
* live vocabulary. Returns `{ ok, errors }` — never throws, so a gallery
|
|
287
|
+
* can render what is valid and report what is not.
|
|
288
|
+
*
|
|
289
|
+
* The field check is the one worth having: a `masked:` naming a field
|
|
290
|
+
* nothing produces is not an error in the pipeline — the shader declares
|
|
291
|
+
* every referenced field at 1.0, so the op simply renders unmasked. That
|
|
292
|
+
* is a silent no-op, which is the bug class this library is least willing
|
|
293
|
+
* to tolerate.
|
|
294
|
+
*/
|
|
295
|
+
function validateChain(nodes) {
|
|
296
|
+
const errors = [];
|
|
297
|
+
if (!Array.isArray(nodes)) return { ok: false, errors: ["chain must be an array"] };
|
|
298
|
+
|
|
299
|
+
const produced = new Set();
|
|
300
|
+
nodes.forEach((node, i) => {
|
|
301
|
+
if (!node || typeof node !== "object" || typeof node.op !== "string") {
|
|
302
|
+
errors.push(`[${i}] every node needs a string \`op\``);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!RASTER_OP_NAMES.includes(node.op)) {
|
|
306
|
+
errors.push(`[${i}] ` + didYouMean(node.op, RASTER_OP_NAMES, "raster op"));
|
|
307
|
+
}
|
|
308
|
+
if (node.by != null && !DRIVER_NAMES.includes(node.by)) {
|
|
309
|
+
errors.push(`[${i}] ` + didYouMean(node.by, DRIVER_NAMES, "driver"));
|
|
310
|
+
}
|
|
311
|
+
const p = producedField(node);
|
|
312
|
+
if (p) produced.add(p);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Second pass: a consumer may legitimately precede its producer in the
|
|
316
|
+
// array, because fields resolve in the field stage before any other.
|
|
317
|
+
nodes.forEach((node, i) => {
|
|
318
|
+
const c = node && consumedField(node);
|
|
319
|
+
if (c && !produced.has(c)) {
|
|
320
|
+
errors.push(`[${i}] \`masked: ${JSON.stringify(node.masked)}\` names a field ` +
|
|
321
|
+
`nothing in this chain produces, so the op renders unmasked. ` +
|
|
322
|
+
`Fields produced here: ${[...produced].join(", ") || "(none)"}.`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return { ok: errors.length === 0, errors };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export { PRESETS, presetNames, presetInfo, preset, compose, validateChain };
|
package/lib/scroll-video.js
CHANGED
package/lib/seo.js
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* nodality v1.0
|
|
2
|
+
* nodality v1.1.0
|
|
3
3
|
* (c) 2026 Filip Vabrousek
|
|
4
4
|
* License: MIT
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/*!
|
|
8
|
+
* nodality/seo — SEO metadata helpers
|
|
9
|
+
*
|
|
10
|
+
* Exposes a single `applySeoMeta()` call that idempotently writes a
|
|
11
|
+
* full per-page metadata bundle into <head>: description, canonical,
|
|
12
|
+
* Open Graph (Facebook / iMessage / WhatsApp / Slack / LinkedIn),
|
|
13
|
+
* Twitter / X card, and an optional schema.org JSON-LD payload.
|
|
14
|
+
*
|
|
15
|
+
* Also exports a handful of pre-built JSON-LD builders for the
|
|
16
|
+
* common schema types (Organization, WebSite, Product, Article,
|
|
17
|
+
* BreadcrumbList) so projects don't have to memorise the schema.org
|
|
18
|
+
* field names.
|
|
19
|
+
*
|
|
20
|
+
* Works in both directions:
|
|
21
|
+
* • At SSG time (nodality prerender) the tags land in the static
|
|
22
|
+
* HTML response — crawlers and social-card scrapers index them.
|
|
23
|
+
* • At browser hydration time the same call re-applies the tags
|
|
24
|
+
* against the live <head>; idempotent via `data-seo="1"` markers,
|
|
25
|
+
* so re-rendering the page doesn't pile up duplicates.
|
|
26
|
+
*
|
|
27
|
+
* Origin handling: pass `path` as a leading-slash route ("/" or
|
|
28
|
+
* "/blog/post-1") and configure the origin globally via
|
|
29
|
+
* `setSeoOrigin("https://example.com")` once at app boot. Falls back
|
|
30
|
+
* to `window.location.origin` in the browser if no origin is set;
|
|
31
|
+
* falls back to "" under SSG (canonical-relative).
|
|
32
|
+
*/
|
|
33
|
+
|
|
7
34
|
let configuredOrigin = "";
|
|
8
35
|
|
|
9
36
|
export function setSeoOrigin(origin) {
|
package/lib/stacker.js
CHANGED
package/lib/suggest.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* nodality v1.1.0
|
|
3
|
+
* (c) 2026 Filip Vabrousek
|
|
4
|
+
* License: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/*!
|
|
8
|
+
* suggest.js — "did you mean …?" over a closed vocabulary.
|
|
9
|
+
*
|
|
10
|
+
* The library's most expensive historical bug class is the silently
|
|
11
|
+
* ignored key: a typo'd option or element type that does nothing, reports
|
|
12
|
+
* nothing, and is found minutes later by eye. Everywhere a vocabulary is
|
|
13
|
+
* closed — morph's tokens/kinds/axes, ElementMapper's element types — the
|
|
14
|
+
* rule is the same: throw, and name the nearest valid word.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from layout/morph.js (phase M1) so the mapper can use the same
|
|
17
|
+
* matching, and so the ~15-line DP exists once. No dependency: writing it
|
|
18
|
+
* is cheaper than owning one, which is the same call the allocator made.
|
|
19
|
+
*
|
|
20
|
+
* Pure — no DOM, safe to import anywhere.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Levenshtein edit distance, iterative two-row DP.
|
|
25
|
+
*/
|
|
26
|
+
function levenshtein(a, b) {
|
|
27
|
+
if (a === b) return 0;
|
|
28
|
+
let prev = new Array(b.length + 1);
|
|
29
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
30
|
+
for (let i = 1; i <= a.length; i++) {
|
|
31
|
+
const row = [i];
|
|
32
|
+
for (let j = 1; j <= b.length; j++) {
|
|
33
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
34
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
|
|
35
|
+
}
|
|
36
|
+
prev = row;
|
|
37
|
+
}
|
|
38
|
+
return prev[b.length];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Candidates within `max` edits of `word`.
|
|
43
|
+
*
|
|
44
|
+
* Returned in the candidate list's own (declaration) order, so ties never
|
|
45
|
+
* depend on an unordered walk — the same determinism rule the rest of the
|
|
46
|
+
* expansion follows.
|
|
47
|
+
*/
|
|
48
|
+
function suggest(word, candidates, max = 2) {
|
|
49
|
+
const w = String(word);
|
|
50
|
+
return candidates.filter((c) => levenshtein(w, c) <= max);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The prose half: "Unknown x "y". Did you mean "z"? Valid …".
|
|
55
|
+
* Callers that need a machine-readable report build it themselves; this is
|
|
56
|
+
* for the throw sites where a human is the only consumer.
|
|
57
|
+
*/
|
|
58
|
+
function didYouMean(word, candidates, label = "value") {
|
|
59
|
+
const near = suggest(word, candidates);
|
|
60
|
+
let msg = `Unknown ${label} "${word}".`;
|
|
61
|
+
if (near.length) msg += ` Did you mean "${near[0]}"?`;
|
|
62
|
+
if (candidates.length) msg += ` Valid ${label}s: ${candidates.join(", ")}.`;
|
|
63
|
+
return msg;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { levenshtein, suggest, didYouMean };
|
package/lib/theme.js
CHANGED
package/lib/transform-anim.js
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* nodality v1.1.0
|
|
3
|
+
* (c) 2026 Filip Vabrousek
|
|
4
|
+
* License: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/*!
|
|
8
|
+
* transition.js — driving transition progress. Phase T4.
|
|
9
|
+
*
|
|
10
|
+
* The pipeline already renders any `t` you hand it (phases T1–T3). This
|
|
11
|
+
* module is only about WHERE t comes from: a timeline, a scroll range, a
|
|
12
|
+
* drag, a test. It owns no pixels.
|
|
13
|
+
*
|
|
14
|
+
* import { progressTimeline } from "nodality/transition";
|
|
15
|
+
*
|
|
16
|
+
* const tl = progressTimeline(pipeline, { duration: 600 });
|
|
17
|
+
* tl.to(1); // play forward
|
|
18
|
+
* tl.to(0); // reverse — from wherever it is NOW
|
|
19
|
+
* tl.bindScroll("#section");
|
|
20
|
+
* tl.set(0.37); // deterministic, for tests and gestures
|
|
21
|
+
*
|
|
22
|
+
* The one property everything rests on: **retargeting starts from the
|
|
23
|
+
* current value, never from zero.** Interrupting a half-finished morph
|
|
24
|
+
* and sending it back is `to(0)`, and it continues from 0.5 rather than
|
|
25
|
+
* snapping. That falls out of `t` being an input rather than a timer
|
|
26
|
+
* (P-1) — there is no timeline state to unwind, only a number to move.
|
|
27
|
+
*
|
|
28
|
+
* Duration scales with the distance actually travelled, so a reversal
|
|
29
|
+
* from the midpoint takes half as long as a full play. A fixed duration
|
|
30
|
+
* makes short corrections feel sluggish and is the usual reason
|
|
31
|
+
* interruptible animation feels wrong.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const EASE = {
|
|
35
|
+
linear: (x) => x,
|
|
36
|
+
in: (x) => x * x * x,
|
|
37
|
+
out: (x) => 1 - Math.pow(1 - x, 3),
|
|
38
|
+
"in-out": (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
42
|
+
|
|
43
|
+
const prefersReducedMotion = () =>
|
|
44
|
+
typeof window !== "undefined" && typeof window.matchMedia === "function" &&
|
|
45
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {{ setProgress(t: number): number, progress: number }} pipeline
|
|
49
|
+
* anything with the phase-T1 progress surface — a raster pipeline, or a
|
|
50
|
+
* test double.
|
|
51
|
+
* @param {object} [opts]
|
|
52
|
+
* @param {number} [opts.duration=600] ms for a FULL 0→1 traversal; a
|
|
53
|
+
* shorter journey takes proportionally less.
|
|
54
|
+
* @param {string} [opts.easing="in-out"]
|
|
55
|
+
* @param {boolean} [opts.respectReducedMotion=true] jump rather than
|
|
56
|
+
* animate when the user has asked for reduced motion. The transition
|
|
57
|
+
* still completes — only the motion is removed.
|
|
58
|
+
*/
|
|
59
|
+
function progressTimeline(pipeline, opts = {}) {
|
|
60
|
+
// A null pipeline is a legitimate, documented state, not a mistake:
|
|
61
|
+
// applyRasterPipeline refuses to attach under prefers-reduced-motion,
|
|
62
|
+
// and returns null headless or without WebGL. Callers should not have
|
|
63
|
+
// to branch on it — the transition still needs to COMPLETE so the app
|
|
64
|
+
// advances, it just completes without pixels. So drive a stub.
|
|
65
|
+
if (!pipeline || typeof pipeline.setProgress !== "function") {
|
|
66
|
+
let v = 0;
|
|
67
|
+
pipeline = { setProgress(t) { v = Math.min(1, Math.max(0, Number(t) || 0)); return v; },
|
|
68
|
+
get progress() { return v; } };
|
|
69
|
+
}
|
|
70
|
+
const duration = opts.duration != null ? opts.duration : 600;
|
|
71
|
+
const easing = EASE[opts.easing] || EASE["in-out"];
|
|
72
|
+
const respectRM = opts.respectReducedMotion !== false;
|
|
73
|
+
|
|
74
|
+
let raf = 0;
|
|
75
|
+
let from = pipeline.progress || 0;
|
|
76
|
+
let to = from;
|
|
77
|
+
let startedAt = 0;
|
|
78
|
+
let span = 0; // ms this particular journey should take
|
|
79
|
+
let running = false;
|
|
80
|
+
let pending = null; // resolve of the in-flight to()
|
|
81
|
+
let scroll = null; // { el, onScroll }
|
|
82
|
+
let destroyed = false;
|
|
83
|
+
|
|
84
|
+
const settle = () => {
|
|
85
|
+
running = false;
|
|
86
|
+
raf = 0;
|
|
87
|
+
const done = pending;
|
|
88
|
+
pending = null;
|
|
89
|
+
if (done) done(pipeline.progress);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const frame = () => {
|
|
93
|
+
if (destroyed) return;
|
|
94
|
+
const elapsed = now() - startedAt;
|
|
95
|
+
const x = span <= 0 ? 1 : Math.min(1, elapsed / span);
|
|
96
|
+
pipeline.setProgress(from + (to - from) * easing(x));
|
|
97
|
+
if (x >= 1) return settle();
|
|
98
|
+
raf = requestAnimationFrame(frame);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const api = {
|
|
102
|
+
get value() { return pipeline.progress; },
|
|
103
|
+
get running() { return running; },
|
|
104
|
+
|
|
105
|
+
/** Jump. No animation, no easing — the deterministic entry point. */
|
|
106
|
+
set(t) {
|
|
107
|
+
api.stop();
|
|
108
|
+
pipeline.setProgress(t);
|
|
109
|
+
return pipeline.progress;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Animate to `target`, STARTING FROM WHEREVER IT IS. Calling this
|
|
114
|
+
* mid-flight retargets rather than restarting, which is the whole
|
|
115
|
+
* of interruption and reversal.
|
|
116
|
+
*/
|
|
117
|
+
to(target, o = {}) {
|
|
118
|
+
if (destroyed) return Promise.resolve(pipeline.progress);
|
|
119
|
+
const dest = Math.min(1, Math.max(0, Number(target) || 0));
|
|
120
|
+
|
|
121
|
+
// Reduced motion: complete, do not animate. The user asked for
|
|
122
|
+
// no movement, not for a broken navigation.
|
|
123
|
+
if (respectRM && prefersReducedMotion()) {
|
|
124
|
+
api.set(dest);
|
|
125
|
+
return Promise.resolve(dest);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Resolve any in-flight promise before replacing it, so an
|
|
129
|
+
// interrupted `await tl.to(1)` does not hang forever.
|
|
130
|
+
if (pending) { const p = pending; pending = null; p(pipeline.progress); }
|
|
131
|
+
if (raf) cancelAnimationFrame(raf);
|
|
132
|
+
|
|
133
|
+
from = pipeline.progress;
|
|
134
|
+
to = dest;
|
|
135
|
+
const dist = Math.abs(to - from);
|
|
136
|
+
if (dist < 1e-4) { pipeline.setProgress(dest); return Promise.resolve(dest); }
|
|
137
|
+
// Distance-scaled: a correction from 0.9 to 1 is brief.
|
|
138
|
+
span = (o.duration != null ? o.duration : duration) * dist;
|
|
139
|
+
startedAt = now();
|
|
140
|
+
running = true;
|
|
141
|
+
raf = requestAnimationFrame(frame);
|
|
142
|
+
return new Promise((res) => { pending = res; });
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
stop() {
|
|
146
|
+
if (raf) cancelAnimationFrame(raf);
|
|
147
|
+
raf = 0;
|
|
148
|
+
running = false;
|
|
149
|
+
if (pending) { const p = pending; pending = null; p(pipeline.progress); }
|
|
150
|
+
return pipeline.progress;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Scrub `t` from how far `el` has travelled through the viewport.
|
|
155
|
+
* Same mapping as the `scroll` driver so the two agree.
|
|
156
|
+
*/
|
|
157
|
+
bindScroll(el, o = {}) {
|
|
158
|
+
const node = typeof el === "string" ? document.querySelector(el) : el;
|
|
159
|
+
if (!node) return api;
|
|
160
|
+
api.unbindScroll();
|
|
161
|
+
api.stop();
|
|
162
|
+
const start = o.start != null ? o.start : 0;
|
|
163
|
+
const end = o.end != null ? o.end : 1;
|
|
164
|
+
const update = () => {
|
|
165
|
+
if (destroyed) return;
|
|
166
|
+
const r = node.getBoundingClientRect();
|
|
167
|
+
const vh = window.innerHeight || 1;
|
|
168
|
+
// 0 when the element's top reaches the bottom of the
|
|
169
|
+
// viewport, 1 when its bottom reaches the top.
|
|
170
|
+
const raw = (vh - r.top) / (vh + r.height || 1);
|
|
171
|
+
const p = (Math.min(1, Math.max(0, raw)) - start) / ((end - start) || 1);
|
|
172
|
+
pipeline.setProgress(Math.min(1, Math.max(0, p)));
|
|
173
|
+
};
|
|
174
|
+
scroll = { node, update };
|
|
175
|
+
window.addEventListener("scroll", update, { passive: true });
|
|
176
|
+
window.addEventListener("resize", update, { passive: true });
|
|
177
|
+
update();
|
|
178
|
+
return api;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
unbindScroll() {
|
|
182
|
+
if (!scroll) return api;
|
|
183
|
+
window.removeEventListener("scroll", scroll.update);
|
|
184
|
+
window.removeEventListener("resize", scroll.update);
|
|
185
|
+
scroll = null;
|
|
186
|
+
return api;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Drive from any 0..1 source — a drag, a slider, a gesture. The
|
|
191
|
+
* caller owns the source; this just forwards it.
|
|
192
|
+
*/
|
|
193
|
+
drive(value) { return api.set(value); },
|
|
194
|
+
|
|
195
|
+
destroy() {
|
|
196
|
+
destroyed = true;
|
|
197
|
+
api.stop();
|
|
198
|
+
api.unbindScroll();
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return api;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export { progressTimeline, EASE as TIMELINE_EASINGS, prefersReducedMotion };
|