@prestyj/cli 5.28.1 → 5.29.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 (316) hide show
  1. package/assets/motion/bin/contact-sheet.mjs +9 -3
  2. package/assets/motion/bin/cues.mjs +337 -0
  3. package/assets/motion/bin/library.mjs +53 -10
  4. package/assets/motion/bin/motion-blur.mjs +943 -0
  5. package/assets/motion/bin/motion-check.mjs +354 -3
  6. package/assets/motion/bin/music-fit.mjs +436 -0
  7. package/assets/motion/bin/pdf-extract.mjs +22 -10
  8. package/assets/motion/bin/reference-study.mjs +346 -0
  9. package/assets/motion/bin/score-synth.mjs +1052 -93
  10. package/assets/motion/library/README.md +50 -14
  11. package/assets/motion/library/kit/moves.js +1981 -0
  12. package/assets/motion/library/library.json +232 -0
  13. package/assets/motion/library/pieces/camera-rig/meta.json +13 -0
  14. package/assets/motion/library/pieces/camera-rig/piece.html +153 -0
  15. package/assets/motion/library/pieces/camera-rig/preview.jpg +0 -0
  16. package/assets/motion/library/pieces/chain-knock/meta.json +13 -0
  17. package/assets/motion/library/pieces/chain-knock/piece.html +195 -0
  18. package/assets/motion/library/pieces/chain-knock/preview.jpg +0 -0
  19. package/assets/motion/library/pieces/gather-to-logo/meta.json +13 -0
  20. package/assets/motion/library/pieces/gather-to-logo/piece.html +159 -0
  21. package/assets/motion/library/pieces/gather-to-logo/preview.jpg +0 -0
  22. package/assets/motion/library/pieces/morph-carry/meta.json +13 -0
  23. package/assets/motion/library/pieces/morph-carry/piece.html +173 -0
  24. package/assets/motion/library/pieces/morph-carry/preview.jpg +0 -0
  25. package/assets/motion/library/pieces/one-shape-journey/meta.json +13 -0
  26. package/assets/motion/library/pieces/one-shape-journey/piece.html +195 -0
  27. package/assets/motion/library/pieces/one-shape-journey/preview.jpg +0 -0
  28. package/assets/motion/library/pieces/open-from-subject/meta.json +13 -0
  29. package/assets/motion/library/pieces/open-from-subject/piece.html +168 -0
  30. package/assets/motion/library/pieces/open-from-subject/preview.jpg +0 -0
  31. package/assets/motion/library/pieces/request-to-result/meta.json +13 -0
  32. package/assets/motion/library/pieces/request-to-result/piece.html +212 -0
  33. package/assets/motion/library/pieces/request-to-result/preview.jpg +0 -0
  34. package/assets/motion/library/pieces/scale-dive/meta.json +13 -0
  35. package/assets/motion/library/pieces/scale-dive/piece.html +321 -0
  36. package/assets/motion/library/pieces/scale-dive/preview.jpg +0 -0
  37. package/assets/motion/library/pieces/screen-replica-steps/meta.json +13 -0
  38. package/assets/motion/library/pieces/screen-replica-steps/piece.html +366 -0
  39. package/assets/motion/library/pieces/screen-replica-steps/preview.jpg +0 -0
  40. package/assets/motion/library/pieces/zoom-into-card/meta.json +13 -0
  41. package/assets/motion/library/pieces/zoom-into-card/piece.html +179 -0
  42. package/assets/motion/library/pieces/zoom-into-card/preview.jpg +0 -0
  43. package/assets/motion/library/sheets/diagram.jpg +0 -0
  44. package/assets/motion/library/sheets/frame.jpg +0 -0
  45. package/assets/motion/library/sheets/transition.jpg +0 -0
  46. package/assets/motion/library/sheets/ui.jpg +0 -0
  47. package/assets/motion/references/build-sheet.md +206 -0
  48. package/assets/motion/references/runtime/determinism-rules.md +1 -1
  49. package/assets/motion/references/runtime/gsap-easing-and-stagger.md +29 -29
  50. package/assets/motion/references/runtime/inputs-and-assets.md +7 -12
  51. package/assets/motion/references/runtime/lint-validate-inspect.md +3 -3
  52. package/assets/motion/references/runtime/minimal-composition.md +1 -1
  53. package/assets/motion/references/runtime/preview-render.md +3 -3
  54. package/assets/motion/skills/app-walkthrough/SKILL.md +66 -0
  55. package/assets/motion/skills/before-after/SKILL.md +53 -0
  56. package/assets/motion/skills/brand-kit/SKILL.md +3 -3
  57. package/assets/motion/skills/dev-tool-video/SKILL.md +57 -0
  58. package/assets/motion/skills/launch-video/SKILL.md +62 -0
  59. package/assets/motion/skills/match-reference/SKILL.md +58 -0
  60. package/assets/motion/skills/motion/SKILL.md +78 -85
  61. package/assets/motion/skills/source-ingest/SKILL.md +17 -6
  62. package/assets/motion/skills/website-video/SKILL.md +59 -0
  63. package/assets/skills/bulletproof/SKILL.md +36 -11
  64. package/assets/skills/bulletproof/references/agent-surface.md +19 -9
  65. package/assets/skills/bulletproof/references/audit-protocol.md +20 -5
  66. package/assets/skills/bulletproof/references/platform-playbooks.md +5 -4
  67. package/assets/skills/bulletproof/references/provenance.md +26 -1
  68. package/assets/skills/bulletproof/references/secure-defaults.md +6 -5
  69. package/assets/skills/bulletproof/references/supply-chain.md +21 -17
  70. package/assets/skills/bulletproof/references/threat-landscape.md +28 -26
  71. package/assets/skills/bulletproof/references/verification.md +2 -0
  72. package/assets/skills/clarify/SKILL.md +25 -16
  73. package/assets/skills/code-review/SKILL.md +71 -13
  74. package/assets/skills/code-review/references/agent-diffs.md +27 -0
  75. package/assets/skills/code-review/references/tests.md +19 -0
  76. package/assets/skills/compliance-guard/SKILL.md +20 -5
  77. package/assets/skills/compliance-guard/references/artifacts.md +1 -1
  78. package/assets/skills/compliance-guard/references/eu-uk.md +16 -16
  79. package/assets/skills/compliance-guard/references/lawsuit-vectors.md +5 -5
  80. package/assets/skills/compliance-guard/references/provenance.md +41 -2
  81. package/assets/skills/compliance-guard/references/sector-gates.md +3 -3
  82. package/assets/skills/compliance-guard/references/security-baseline.md +2 -2
  83. package/assets/skills/compliance-guard/references/trigger-map.md +5 -5
  84. package/assets/skills/compliance-guard/references/us.md +27 -21
  85. package/assets/skills/durable/SKILL.md +87 -79
  86. package/assets/skills/durable/references/agent-db-safety.md +69 -0
  87. package/assets/skills/durable/references/backups-and-runtime.md +19 -12
  88. package/assets/skills/durable/references/migrations-and-schema.md +13 -6
  89. package/assets/skills/evidence-led-ui/SKILL.md +69 -127
  90. package/assets/skills/evidence-led-ui/references/anti-defaults.md +107 -208
  91. package/assets/skills/evidence-led-ui/references/direction.md +124 -0
  92. package/assets/skills/evidence-led-ui/references/production-contract.md +8 -0
  93. package/assets/skills/evidence-led-ui/references/provenance.md +24 -1
  94. package/assets/skills/lean/SKILL.md +90 -71
  95. package/assets/skills/lean/references/memory-and-processes.md +3 -2
  96. package/assets/skills/lean/references/playbooks.md +37 -12
  97. package/assets/skills/refactoring/SKILL.md +24 -3
  98. package/assets/skills/refactoring/references/agent-pitfalls.md +4 -1
  99. package/assets/skills/refactoring/references/legacy.md +21 -0
  100. package/assets/skills/root-cause/SKILL.md +20 -10
  101. package/assets/skills/shared-language/SKILL.md +16 -14
  102. package/assets/skills/tdd/SKILL.md +27 -15
  103. package/dist/app-sidecar.js +203 -47
  104. package/dist/app-sidecar.js.map +1 -1
  105. package/dist/cli.js +17 -26
  106. package/dist/cli.js.map +1 -1
  107. package/dist/core/acceptance-checks.d.ts +48 -0
  108. package/dist/core/acceptance-checks.js +144 -0
  109. package/dist/core/acceptance-checks.js.map +1 -0
  110. package/dist/core/agent-session.d.ts +106 -72
  111. package/dist/core/agent-session.js +539 -399
  112. package/dist/core/agent-session.js.map +1 -1
  113. package/dist/core/agents.d.ts +6 -5
  114. package/dist/core/agents.js.map +1 -1
  115. package/dist/core/ask-user.d.ts +90 -8
  116. package/dist/core/ask-user.js +124 -13
  117. package/dist/core/ask-user.js.map +1 -1
  118. package/dist/core/bundled-agents.js +1 -3
  119. package/dist/core/bundled-agents.js.map +1 -1
  120. package/dist/core/cache-diagnostics.d.ts +68 -0
  121. package/dist/core/cache-diagnostics.js +196 -0
  122. package/dist/core/cache-diagnostics.js.map +1 -0
  123. package/dist/core/cache-expiry.d.ts +87 -0
  124. package/dist/core/cache-expiry.js +111 -0
  125. package/dist/core/cache-expiry.js.map +1 -0
  126. package/dist/core/compaction/compactor.js +78 -48
  127. package/dist/core/compaction/compactor.js.map +1 -1
  128. package/dist/core/compaction/plan-step-policy.d.ts +46 -0
  129. package/dist/core/compaction/plan-step-policy.js +57 -0
  130. package/dist/core/compaction/plan-step-policy.js.map +1 -0
  131. package/dist/core/destructive-git-guard.d.ts +90 -0
  132. package/dist/core/destructive-git-guard.js +871 -0
  133. package/dist/core/destructive-git-guard.js.map +1 -0
  134. package/dist/core/event-bus.d.ts +3 -0
  135. package/dist/core/event-bus.js +5 -0
  136. package/dist/core/event-bus.js.map +1 -1
  137. package/dist/core/injection-detect.d.ts +38 -0
  138. package/dist/core/injection-detect.js +232 -0
  139. package/dist/core/injection-detect.js.map +1 -0
  140. package/dist/core/keep-awake.d.ts +88 -0
  141. package/dist/core/keep-awake.js +251 -0
  142. package/dist/core/keep-awake.js.map +1 -0
  143. package/dist/core/mcp/client.d.ts +72 -0
  144. package/dist/core/mcp/client.js +264 -41
  145. package/dist/core/mcp/client.js.map +1 -1
  146. package/dist/core/mcp/content.js +6 -2
  147. package/dist/core/mcp/content.js.map +1 -1
  148. package/dist/core/mcp/store.d.ts +6 -1
  149. package/dist/core/mcp/store.js +12 -1
  150. package/dist/core/mcp/store.js.map +1 -1
  151. package/dist/core/mcp/types.d.ts +18 -0
  152. package/dist/core/model-unavailable.d.ts +14 -0
  153. package/dist/core/model-unavailable.js +23 -0
  154. package/dist/core/model-unavailable.js.map +1 -0
  155. package/dist/core/node-debugger.d.ts +148 -0
  156. package/dist/core/node-debugger.js +642 -0
  157. package/dist/core/node-debugger.js.map +1 -0
  158. package/dist/core/package-threats.d.ts +18 -0
  159. package/dist/core/package-threats.js +168 -0
  160. package/dist/core/package-threats.js.map +1 -0
  161. package/dist/core/persistent-shell.d.ts +58 -6
  162. package/dist/core/persistent-shell.js +331 -49
  163. package/dist/core/persistent-shell.js.map +1 -1
  164. package/dist/core/process-manager.d.ts +14 -0
  165. package/dist/core/process-manager.js +61 -0
  166. package/dist/core/process-manager.js.map +1 -1
  167. package/dist/core/progress/git-xp.js +8 -14
  168. package/dist/core/progress/git-xp.js.map +1 -1
  169. package/dist/core/session-history.d.ts +12 -0
  170. package/dist/core/session-history.js +27 -0
  171. package/dist/core/session-history.js.map +1 -1
  172. package/dist/core/session-manager.d.ts +13 -1
  173. package/dist/core/session-manager.js +38 -18
  174. package/dist/core/session-manager.js.map +1 -1
  175. package/dist/core/session-summary-index.d.ts +37 -0
  176. package/dist/core/session-summary-index.js +172 -0
  177. package/dist/core/session-summary-index.js.map +1 -0
  178. package/dist/core/settings-manager.d.ts +2 -0
  179. package/dist/core/settings-manager.js +10 -0
  180. package/dist/core/settings-manager.js.map +1 -1
  181. package/dist/core/shell-threats-popular-packages.d.ts +11 -0
  182. package/dist/core/shell-threats-popular-packages.js +675 -0
  183. package/dist/core/shell-threats-popular-packages.js.map +1 -0
  184. package/dist/core/shell-threats.d.ts +8 -0
  185. package/dist/core/shell-threats.js +186 -0
  186. package/dist/core/shell-threats.js.map +1 -0
  187. package/dist/core/skills.js +3 -1
  188. package/dist/core/skills.js.map +1 -1
  189. package/dist/core/stream-rules.d.ts +30 -0
  190. package/dist/core/stream-rules.js +151 -0
  191. package/dist/core/stream-rules.js.map +1 -0
  192. package/dist/core/subagent-manager.d.ts +20 -5
  193. package/dist/core/subagent-manager.js +22 -8
  194. package/dist/core/subagent-manager.js.map +1 -1
  195. package/dist/core/subagent-receipt.d.ts +54 -0
  196. package/dist/core/subagent-receipt.js +276 -0
  197. package/dist/core/subagent-receipt.js.map +1 -0
  198. package/dist/core/subagent-turn-record.d.ts +2 -0
  199. package/dist/core/subagent-turn-record.js.map +1 -1
  200. package/dist/core/test-impact.d.ts +73 -0
  201. package/dist/core/test-impact.js +467 -0
  202. package/dist/core/test-impact.js.map +1 -0
  203. package/dist/core/thinking-level.d.ts +1 -1
  204. package/dist/core/thinking-level.js +1 -1
  205. package/dist/core/thinking-level.js.map +1 -1
  206. package/dist/core/verification-gate.d.ts +2 -0
  207. package/dist/core/verification-gate.js +4 -0
  208. package/dist/core/verification-gate.js.map +1 -1
  209. package/dist/core/verification-snapshot.js +3 -5
  210. package/dist/core/verification-snapshot.js.map +1 -1
  211. package/dist/core/workspace-guard.d.ts +18 -7
  212. package/dist/core/workspace-guard.js +227 -60
  213. package/dist/core/workspace-guard.js.map +1 -1
  214. package/dist/interactive.js +2 -1
  215. package/dist/interactive.js.map +1 -1
  216. package/dist/modes/subagent-worker-mode.js +34 -6
  217. package/dist/modes/subagent-worker-mode.js.map +1 -1
  218. package/dist/motion-agent/motion-agent.d.ts +6 -2
  219. package/dist/motion-agent/motion-agent.js +5 -7
  220. package/dist/motion-agent/motion-agent.js.map +1 -1
  221. package/dist/motion-agent/motion-prompt.d.ts +1 -1
  222. package/dist/motion-agent/motion-prompt.js +14 -19
  223. package/dist/motion-agent/motion-prompt.js.map +1 -1
  224. package/dist/motion-agent/motion-review.d.ts +10 -3
  225. package/dist/motion-agent/motion-review.js +15 -7
  226. package/dist/motion-agent/motion-review.js.map +1 -1
  227. package/dist/motion-agent/motion-studio-context.js +1 -1
  228. package/dist/motion-agent/motion-studio-context.js.map +1 -1
  229. package/dist/system-prompt.js +3 -1
  230. package/dist/system-prompt.js.map +1 -1
  231. package/dist/test-support/keep-alive.d.ts +14 -0
  232. package/dist/test-support/keep-alive.js +17 -0
  233. package/dist/test-support/keep-alive.js.map +1 -0
  234. package/dist/tools/ask-user.js +3 -3
  235. package/dist/tools/ask-user.js.map +1 -1
  236. package/dist/tools/bash-read-evidence.d.ts +10 -0
  237. package/dist/tools/bash-read-evidence.js +133 -0
  238. package/dist/tools/bash-read-evidence.js.map +1 -0
  239. package/dist/tools/bash.d.ts +10 -1
  240. package/dist/tools/bash.js +115 -7
  241. package/dist/tools/bash.js.map +1 -1
  242. package/dist/tools/debug.d.ts +54 -0
  243. package/dist/tools/debug.js +233 -0
  244. package/dist/tools/debug.js.map +1 -0
  245. package/dist/tools/edit.js +12 -4
  246. package/dist/tools/edit.js.map +1 -1
  247. package/dist/tools/goals.d.ts +1 -1
  248. package/dist/tools/index.d.ts +19 -2
  249. package/dist/tools/index.js +47 -7
  250. package/dist/tools/index.js.map +1 -1
  251. package/dist/tools/prompt-hints.js +2 -0
  252. package/dist/tools/prompt-hints.js.map +1 -1
  253. package/dist/tools/read-tracker.d.ts +5 -0
  254. package/dist/tools/read-tracker.js +19 -9
  255. package/dist/tools/read-tracker.js.map +1 -1
  256. package/dist/tools/read.js +3 -2
  257. package/dist/tools/read.js.map +1 -1
  258. package/dist/tools/skill.js +5 -0
  259. package/dist/tools/skill.js.map +1 -1
  260. package/dist/tools/subagent-control.js +44 -8
  261. package/dist/tools/subagent-control.js.map +1 -1
  262. package/dist/tools/subagent-shared.d.ts +29 -8
  263. package/dist/tools/subagent-shared.js +45 -14
  264. package/dist/tools/subagent-shared.js.map +1 -1
  265. package/dist/tools/subagent.d.ts +8 -2
  266. package/dist/tools/subagent.js +28 -10
  267. package/dist/tools/subagent.js.map +1 -1
  268. package/dist/tools/task-output.js +3 -2
  269. package/dist/tools/task-output.js.map +1 -1
  270. package/dist/tools/task-send.d.ts +1 -1
  271. package/dist/tools/task-send.js +15 -1
  272. package/dist/tools/task-send.js.map +1 -1
  273. package/dist/tools/tool-tiers.d.ts +2 -2
  274. package/dist/tools/tool-tiers.js +3 -2
  275. package/dist/tools/tool-tiers.js.map +1 -1
  276. package/dist/tools/truncate.d.ts +21 -0
  277. package/dist/tools/truncate.js +187 -0
  278. package/dist/tools/truncate.js.map +1 -1
  279. package/dist/tools/ui-adopt.js +2 -0
  280. package/dist/tools/ui-adopt.js.map +1 -1
  281. package/dist/ui/App.d.ts +0 -4
  282. package/dist/ui/App.js +5 -28
  283. package/dist/ui/App.js.map +1 -1
  284. package/dist/ui/components/ActivityIndicator.js +1 -0
  285. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  286. package/dist/ui/hooks/useAgentLoop.d.ts +1 -8
  287. package/dist/ui/hooks/useAgentLoop.js +1 -119
  288. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  289. package/dist/ui/render.d.ts +0 -4
  290. package/dist/ui/render.js +0 -2
  291. package/dist/ui/render.js.map +1 -1
  292. package/dist/utils/git.d.ts +77 -0
  293. package/dist/utils/git.js +285 -21
  294. package/dist/utils/git.js.map +1 -1
  295. package/dist/utils/github-ci.js +2 -1
  296. package/dist/utils/github-ci.js.map +1 -1
  297. package/dist/utils/github.js +11 -9
  298. package/dist/utils/github.js.map +1 -1
  299. package/dist/utils/image.d.ts +14 -0
  300. package/dist/utils/image.js +16 -0
  301. package/dist/utils/image.js.map +1 -1
  302. package/dist/utils/process.d.ts +20 -0
  303. package/dist/utils/process.js +98 -0
  304. package/dist/utils/process.js.map +1 -1
  305. package/package.json +5 -5
  306. package/assets/motion/references/motion-language.md +0 -128
  307. package/assets/motion/skills/video-qa/SKILL.md +0 -89
  308. package/dist/core/ideal-review-subagent.d.ts +0 -56
  309. package/dist/core/ideal-review-subagent.js +0 -112
  310. package/dist/core/ideal-review-subagent.js.map +0 -1
  311. package/dist/core/ideal-review.d.ts +0 -82
  312. package/dist/core/ideal-review.js +0 -242
  313. package/dist/core/ideal-review.js.map +0 -1
  314. package/dist/motion-agent/motion-check-tool.d.ts +0 -35
  315. package/dist/motion-agent/motion-check-tool.js +0 -514
  316. package/dist/motion-agent/motion-check-tool.js.map +0 -1
@@ -0,0 +1,1981 @@
1
+ // EZ Motion move kit: springs, arrivals, handoffs, contact, an operated
2
+ // camera and a pressing cursor for HyperFrames compositions.
3
+ //
4
+ // Install with `node library.mjs kit <project>` and put the printed <script>
5
+ // in the root index.html <head>, after GSAP. Then, in any composition script:
6
+ //
7
+ // const kit = window.GGMotionKit;
8
+ // const tl = gsap.timeline({ paused: true });
9
+ // const landed = kit.dropLetters(tl, root.querySelector(".name"), 0.4);
10
+ // kit.camera(tl, root.querySelector(".world"), [...keys], { duration: 8 });
11
+ //
12
+ // Every move adds tweens to the timeline you pass and returns the time of its
13
+ // key moment (landing, press, arrival) so the next move can start from it.
14
+ // Everything is a pure function of timeline time: no randomness except the
15
+ // seeded generator, no clocks, no free-running loops. Seeking any frame twice
16
+ // draws the same picture.
17
+ //
18
+ // Coordinates: positions are CSS pixels in the space of the element's offset
19
+ // parent (the composition's 1920x1080 stage unless you nest). `kit.centerOf`
20
+ // reads layout offsets, so camera and transform moves never disturb it.
21
+ (function (root) {
22
+ "use strict";
23
+
24
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
25
+ const lerp = (a, b, p) => a + (b - a) * p;
26
+
27
+ // ---------------------------------------------------------------- timing
28
+
29
+ /** Named spring settings. Stiffness, damping and mass, as in a physical spring. */
30
+ const springs = {
31
+ snappy: { stiffness: 380, damping: 30, mass: 1 },
32
+ bouncy: { stiffness: 240, damping: 13, mass: 1 },
33
+ soft: { stiffness: 110, damping: 19, mass: 1 },
34
+ heavy: { stiffness: 140, damping: 22, mass: 2.2 },
35
+ whip: { stiffness: 300, damping: 22, mass: 1 },
36
+ };
37
+
38
+ function springParams(spec) {
39
+ const s = typeof spec === "string" ? springs[spec] : spec;
40
+ if (!s) throw new Error(`Unknown spring "${spec}"`);
41
+ const stiffness = s.stiffness ?? 170;
42
+ const damping = s.damping ?? 16;
43
+ const mass = s.mass ?? 1;
44
+ const omega = Math.sqrt(stiffness / mass);
45
+ const zeta = damping / (2 * Math.sqrt(stiffness * mass));
46
+ return { omega, zeta, velocity: s.velocity ?? 0 };
47
+ }
48
+
49
+ /**
50
+ * Position of a damped spring released at 0 heading for 1, after t seconds.
51
+ * Closed form, so any time can be sampled directly.
52
+ */
53
+ function spring(t, spec = "bouncy") {
54
+ if (t <= 0) return 0;
55
+ const { omega, zeta, velocity: v0 } = springParams(spec);
56
+ if (zeta < 1) {
57
+ const wd = omega * Math.sqrt(1 - zeta * zeta);
58
+ const decay = Math.exp(-zeta * omega * t);
59
+ const b = (zeta * omega - v0) / wd;
60
+ return 1 - decay * (Math.cos(wd * t) + b * Math.sin(wd * t));
61
+ }
62
+ if (zeta === 1) {
63
+ return 1 - Math.exp(-omega * t) * (1 + (omega - v0) * t);
64
+ }
65
+ const root1 = -omega * (zeta - Math.sqrt(zeta * zeta - 1));
66
+ const root2 = -omega * (zeta + Math.sqrt(zeta * zeta - 1));
67
+ const a = (v0 + root2) / (root1 - root2);
68
+ return 1 + a * Math.exp(root1 * t) + (-1 - a) * Math.exp(root2 * t);
69
+ }
70
+
71
+ /** Seconds until the spring stays within `tolerance` of its target for good. */
72
+ function settleTime(spec = "bouncy", tolerance = 0.002) {
73
+ const { omega, zeta, velocity: v0 } = springParams(spec);
74
+ if (zeta < 1) {
75
+ // The motion never leaves its decaying envelope, so the bound is exact enough.
76
+ const wd = omega * Math.sqrt(1 - zeta * zeta);
77
+ const b = (zeta * omega - v0) / wd;
78
+ const amplitude = Math.sqrt(1 + b * b);
79
+ return Math.max(0.05, Math.log(amplitude / tolerance) / (zeta * omega));
80
+ }
81
+ const step = 1 / 600;
82
+ for (let t = step; t < 30; t += step) {
83
+ if (Math.abs(1 - spring(t, spec)) < tolerance) return t;
84
+ }
85
+ return 30;
86
+ }
87
+
88
+ /** A GSAP-compatible ease that follows the spring over its settle time. */
89
+ function springEase(spec = "bouncy") {
90
+ const settle = settleTime(spec);
91
+ return (p) => (p <= 0 ? 0 : p >= 1 ? 1 : spring(p * settle, spec));
92
+ }
93
+
94
+ /** Decaying vibration after a contact: 0 before it, then rings down. */
95
+ function ring(t, opts = {}) {
96
+ if (t < 0) return 0;
97
+ const freq = opts.freq ?? 11;
98
+ const decay = opts.decay ?? 7;
99
+ const amp = opts.amp ?? 1;
100
+ return amp * Math.exp(-decay * t) * Math.sin(2 * Math.PI * freq * t);
101
+ }
102
+
103
+ /** Curves, chosen by feel. All map 0..1 to 0..1 (springs overshoot first). */
104
+ const ease = {
105
+ linear: (p) => p,
106
+ // Sharp start, long glide into place: arrivals and reveals.
107
+ snap: (p) => 1 - (1 - p) ** 4,
108
+ // Starts slow, ends hard: things falling or diving into contact.
109
+ drop: (p) => p ** 2.4,
110
+ // Slow, even turn of the head: drifts and handheld pushes.
111
+ glide: (p) => 0.5 - 0.5 * Math.cos(Math.PI * p),
112
+ // Fast middle, soft ends: a hand moving a cursor or a camera across.
113
+ travel: (p) => (p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2),
114
+ // Quick move that overshoots a little and settles.
115
+ whip: springEase("whip"),
116
+ settle: springEase("soft"),
117
+ bounce: springEase("bouncy"),
118
+ firm: springEase("snappy"),
119
+ // Holds still, then jumps.
120
+ cut: (p) => (p > 0 ? 1 : 0),
121
+ };
122
+
123
+ function easeOf(name, fallback) {
124
+ if (typeof name === "function") return name;
125
+ const fn = ease[name ?? fallback];
126
+ if (!fn) throw new Error(`Unknown ease "${name}"`);
127
+ return fn;
128
+ }
129
+
130
+ /** Seeded generator: same seed, same sequence. Returns numbers in [0, 1). */
131
+ function rng(seed = 1) {
132
+ let s = seed >>> 0 || 0x9e3779b9;
133
+ return () => {
134
+ s ^= s << 13;
135
+ s >>>= 0;
136
+ s ^= s >>> 17;
137
+ s ^= s << 5;
138
+ s >>>= 0;
139
+ return s / 4294967296;
140
+ };
141
+ }
142
+
143
+ function hash(i, seed) {
144
+ let h = Math.imul(i | 0, 0x27d4eb2d) ^ Math.imul(seed | 0, 0x165667b1);
145
+ h = Math.imul(h ^ (h >>> 15), 0x85ebca77);
146
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae3d);
147
+ h ^= h >>> 16;
148
+ return (h >>> 0) / 4294967295;
149
+ }
150
+
151
+ /** Smooth deterministic noise in [-1, 1]; one wobble per unit of t. */
152
+ function noise(seed, t) {
153
+ const i = Math.floor(t);
154
+ const f = t - i;
155
+ const s = f * f * f * (f * (f * 6 - 15) + 10);
156
+ return lerp(hash(i, seed), hash(i + 1, seed), s) * 2 - 1;
157
+ }
158
+
159
+ // ------------------------------------------------------------ elements
160
+
161
+ /** Centre of an element in `space`'s coordinates, from layout offsets. */
162
+ function centerOf(el, space) {
163
+ let x = el.offsetWidth / 2;
164
+ let y = el.offsetHeight / 2;
165
+ let node = el;
166
+ while (node && node !== space) {
167
+ x += node.offsetLeft;
168
+ y += node.offsetTop;
169
+ node = node.offsetParent;
170
+ }
171
+ return { x, y };
172
+ }
173
+
174
+ /** Box of an element in `space`'s coordinates: { x, y, w, h } (top-left). */
175
+ function rectOf(el, space) {
176
+ const c = centerOf(el, space);
177
+ return {
178
+ x: c.x - el.offsetWidth / 2,
179
+ y: c.y - el.offsetHeight / 2,
180
+ w: el.offsetWidth,
181
+ h: el.offsetHeight,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Wrap an element's text in spans. "words" gives one span per word; "chars"
187
+ * gives one per character, grouped so words never break. `mask: true` puts
188
+ * each word in a clipping box for rise-from-below reveals.
189
+ */
190
+ function split(el, by = "words", opts = {}) {
191
+ const doc = el.ownerDocument;
192
+ const text = el.textContent.trim().replace(/\s+/g, " ");
193
+ el.textContent = "";
194
+ const parts = [];
195
+ text.split(" ").forEach((word, wi) => {
196
+ if (wi > 0) el.appendChild(doc.createTextNode(" "));
197
+ const outer = doc.createElement("span");
198
+ outer.style.display = "inline-block";
199
+ outer.style.whiteSpace = "nowrap";
200
+ if (opts.mask) {
201
+ outer.style.overflow = "hidden";
202
+ outer.style.verticalAlign = "top";
203
+ outer.style.paddingBottom = "0.14em";
204
+ outer.style.marginBottom = "-0.14em";
205
+ }
206
+ el.appendChild(outer);
207
+ if (by === "words") {
208
+ const inner = doc.createElement("span");
209
+ inner.style.display = "inline-block";
210
+ inner.textContent = word;
211
+ outer.appendChild(inner);
212
+ parts.push(inner);
213
+ return;
214
+ }
215
+ for (const ch of word) {
216
+ const span = doc.createElement("span");
217
+ span.style.display = "inline-block";
218
+ span.textContent = ch;
219
+ outer.appendChild(span);
220
+ parts.push(span);
221
+ }
222
+ });
223
+ return parts;
224
+ }
225
+
226
+ /**
227
+ * One tween from `at` for `duration` that calls draw(localTime) on every
228
+ * render, including seeks. Draws the first frame immediately.
229
+ */
230
+ function drive(tl, at, duration, draw) {
231
+ const state = { t: 0 };
232
+ draw(0);
233
+ tl.fromTo(
234
+ state,
235
+ { t: 0 },
236
+ {
237
+ t: duration,
238
+ duration,
239
+ ease: "none",
240
+ immediateRender: false,
241
+ onUpdate: () => draw(state.t),
242
+ },
243
+ at,
244
+ );
245
+ return at + duration;
246
+ }
247
+
248
+ // ------------------------------------------------------------ arrivals
249
+
250
+ /** Words rise into place from below their own line, one after another. */
251
+ function liftWords(tl, el, at, opts = {}) {
252
+ const words = split(el, "words", { mask: true });
253
+ const stagger = opts.stagger ?? 0.07;
254
+ const spec = opts.spring ?? "snappy";
255
+ const duration = opts.duration ?? settleTime(spec);
256
+ tl.fromTo(
257
+ words,
258
+ { yPercent: opts.from ?? 115 },
259
+ { yPercent: 0, duration, ease: springEase(spec), stagger },
260
+ at,
261
+ );
262
+ return at + stagger * Math.max(0, words.length - 1) + duration * 0.4;
263
+ }
264
+
265
+ /** Letters fall in, squash as they land and spring back up. Returns the last landing. */
266
+ function dropLetters(tl, el, at, opts = {}) {
267
+ const chars = split(el, "chars");
268
+ const height = opts.height ?? 260;
269
+ const fall = opts.fall ?? 0.38;
270
+ const stagger = opts.stagger ?? 0.05;
271
+ const squash = opts.squash ?? 0.24;
272
+ const recover = opts.spring ?? "bouncy";
273
+ let landed = at;
274
+ chars.forEach((ch, i) => {
275
+ const start = at + i * stagger;
276
+ landed = start + fall;
277
+ ch.style.transformOrigin = "50% 100%";
278
+ tl.fromTo(ch, { y: -height }, { y: 0, duration: fall, ease: ease.drop }, start);
279
+ tl.fromTo(
280
+ ch,
281
+ { opacity: 0 },
282
+ { opacity: 1, duration: Math.min(0.12, fall), ease: "none" },
283
+ start,
284
+ );
285
+ tl.to(
286
+ ch,
287
+ { scaleY: 1 - squash, scaleX: 1 + squash * 0.55, duration: 0.06, ease: "power1.out" },
288
+ landed,
289
+ );
290
+ tl.to(
291
+ ch,
292
+ { scaleY: 1, scaleX: 1, duration: settleTime(recover), ease: springEase(recover) },
293
+ landed + 0.06,
294
+ );
295
+ });
296
+ return landed;
297
+ }
298
+
299
+ /** A clip-path reveal from one side. */
300
+ function reveal(tl, el, at, opts = {}) {
301
+ const from = opts.from ?? "bottom";
302
+ const hidden = {
303
+ top: "inset(0% 0% 100% 0%)",
304
+ bottom: "inset(100% 0% 0% 0%)",
305
+ left: "inset(0% 100% 0% 0%)",
306
+ right: "inset(0% 0% 0% 100%)",
307
+ }[from];
308
+ if (!hidden) throw new Error(`reveal: unknown side "${from}"`);
309
+ const duration = opts.duration ?? 0.8;
310
+ tl.fromTo(
311
+ el,
312
+ { clipPath: hidden },
313
+ { clipPath: "inset(0% 0% 0% 0%)", duration, ease: easeOf(opts.ease, "snap") },
314
+ at,
315
+ );
316
+ return at + duration;
317
+ }
318
+
319
+ /**
320
+ * Keystroke times for typing `text`: uneven like a person, with a beat
321
+ * after spaces and punctuation. Pure; used by typewrite.
322
+ */
323
+ function typePlan(text, opts = {}) {
324
+ const cps = opts.cps ?? 22;
325
+ const jitter = opts.jitter ?? 0.4;
326
+ const next = rng(opts.seed ?? 11);
327
+ const times = [];
328
+ let t = 0;
329
+ for (let i = 0; i < text.length; i++) {
330
+ times.push(t);
331
+ let gap = (1 / cps) * (1 + jitter * (next() * 2 - 1));
332
+ if (/[\s]/.test(text[i])) gap *= 1.6;
333
+ if (/[.,;:!?]/.test(text[i])) gap *= 2.6;
334
+ t += gap;
335
+ }
336
+ return { times, duration: t };
337
+ }
338
+
339
+ /** Types the element's text (plain text only) with a caret while typing. */
340
+ function typewrite(tl, el, at, opts = {}) {
341
+ const text = opts.text ?? el.textContent;
342
+ const plan = typePlan(text, opts);
343
+ const caret = opts.caret === false ? "" : (opts.caret ?? "\u258d");
344
+ const hold = opts.caretHold ?? 0;
345
+ const draw = (t) => {
346
+ let n = 0;
347
+ while (n < plan.times.length && plan.times[n] <= t) n++;
348
+ const typing = t > 0 && n < text.length;
349
+ const blinking =
350
+ n === text.length &&
351
+ t - plan.duration < hold &&
352
+ Math.floor((t - plan.duration) * 2.2) % 2 === 0;
353
+ el.textContent = text.slice(0, n) + (caret && (typing || blinking) ? caret : "");
354
+ };
355
+ return drive(tl, at, plan.duration + hold, draw) - hold;
356
+ }
357
+
358
+ /** Draws an SVG stroke (a checkmark path, an underline) from start to end. */
359
+ function tick(tl, path, at, opts = {}) {
360
+ const length = path.getTotalLength();
361
+ const duration = opts.duration ?? 0.34;
362
+ tl.fromTo(
363
+ path,
364
+ { strokeDasharray: length, strokeDashoffset: length },
365
+ { strokeDashoffset: 0, duration, ease: easeOf(opts.ease, "snap") },
366
+ at,
367
+ );
368
+ // A round line cap still paints a dot at full offset: hide it until the draw.
369
+ tl.fromTo(path, { opacity: 0 }, { opacity: 1, duration: 0.01, ease: "none" }, at);
370
+ return at + duration;
371
+ }
372
+
373
+ /** Pops in from small with a spring overshoot. */
374
+ function pop(tl, el, at, opts = {}) {
375
+ const spec = opts.spring ?? "bouncy";
376
+ const duration = settleTime(spec);
377
+ tl.fromTo(el, { scale: opts.from ?? 0.3 }, { scale: 1, duration, ease: springEase(spec) }, at);
378
+ tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.1, ease: "none" }, at);
379
+ return at + duration * 0.35;
380
+ }
381
+
382
+ /** Slams down from large, squashes on contact and settles. Returns the contact time. */
383
+ function stamp(tl, el, at, opts = {}) {
384
+ const fall = opts.duration ?? 0.2;
385
+ const rotate = opts.rotate ?? -4;
386
+ const contact = at + fall;
387
+ tl.fromTo(
388
+ el,
389
+ { scale: opts.from ?? 2.4, rotation: rotate * 2.5, opacity: 0 },
390
+ { scale: 1, rotation: rotate, opacity: 1, duration: fall, ease: ease.drop },
391
+ at,
392
+ );
393
+ tl.to(el, { scale: 0.93, duration: 0.05, ease: "power1.out" }, contact);
394
+ tl.to(
395
+ el,
396
+ { scale: 1, duration: settleTime("bouncy"), ease: springEase("bouncy") },
397
+ contact + 0.05,
398
+ );
399
+ return contact;
400
+ }
401
+
402
+ // ------------------------------------------------------------ handoffs
403
+
404
+ /**
405
+ * A box becomes the next frame: morphs an absolutely positioned element's
406
+ * box (left, top, width, height, corner radius) into `to`, a { x, y, w, h,
407
+ * r } rect or an element measured in the same space. Defaults to the
408
+ * `travel` curve over 0.85 s so the change reads; pass `spring` for a
409
+ * small box that should land with weight.
410
+ */
411
+ function reshape(tl, el, at, to, opts = {}) {
412
+ const target = typeof to.offsetWidth === "number" ? rectOf(to, el.offsetParent) : to;
413
+ const duration = opts.duration ?? (opts.spring ? settleTime(opts.spring) : 0.85);
414
+ const vars = {
415
+ left: target.x,
416
+ top: target.y,
417
+ width: target.w,
418
+ height: target.h,
419
+ duration,
420
+ ease: opts.spring && !opts.ease ? springEase(opts.spring) : easeOf(opts.ease, "travel"),
421
+ };
422
+ if (target.r !== undefined) {
423
+ // A pill radius (999px) is clamped by the browser and would stay round
424
+ // until the last moment: start from the radius actually drawn.
425
+ const view = el.ownerDocument && el.ownerDocument.defaultView;
426
+ const drawn = view ? parseFloat(view.getComputedStyle(el).borderTopLeftRadius) || 0 : 0;
427
+ const cap = Math.min(el.offsetWidth, el.offsetHeight) / 2;
428
+ if (drawn > cap) tl.set(el, { borderRadius: cap }, at);
429
+ vars.borderRadius = target.r;
430
+ }
431
+ tl.to(el, vars, at);
432
+ return at + duration * 0.45;
433
+ }
434
+
435
+ /** Radius that covers the whole frame from (x, y). */
436
+ function coverRadius(x, y, width, height) {
437
+ return (
438
+ Math.max(
439
+ Math.hypot(x, y),
440
+ Math.hypot(width - x, y),
441
+ Math.hypot(x, height - y),
442
+ Math.hypot(width - x, height - y),
443
+ ) + 2
444
+ );
445
+ }
446
+
447
+ /** The next stage opens as a growing circle from a point on the subject. */
448
+ function openFrom(tl, stage, at, opts = {}) {
449
+ const width = opts.width ?? 1920;
450
+ const height = opts.height ?? 1080;
451
+ const x = opts.x ?? width / 2;
452
+ const y = opts.y ?? height / 2;
453
+ const duration = opts.duration ?? 0.9;
454
+ const end = coverRadius(x, y, width, height);
455
+ tl.fromTo(
456
+ stage,
457
+ { clipPath: `circle(${opts.from ?? 0}px at ${x}px ${y}px)` },
458
+ {
459
+ clipPath: `circle(${end}px at ${x}px ${y}px)`,
460
+ duration,
461
+ ease: easeOf(opts.ease, "travel"),
462
+ },
463
+ at,
464
+ );
465
+ return at + duration;
466
+ }
467
+
468
+ /**
469
+ * The camera pushes through a point (a card, a letter, a hole) until it
470
+ * fills the frame, then hands over to `next`. Give the scene its own layer:
471
+ * this sets transform-origin and scale on `layer`.
472
+ */
473
+ function diveThrough(tl, layer, at, opts = {}) {
474
+ const duration = opts.duration ?? 1.0;
475
+ const zoom = opts.zoom ?? 16;
476
+ tl.set(layer, { transformOrigin: `${opts.x ?? 960}px ${opts.y ?? 540}px` }, at);
477
+ tl.fromTo(layer, { scale: 1 }, { scale: zoom, duration, ease: easeOf(opts.ease, "drop") }, at);
478
+ const end = at + duration;
479
+ tl.set(layer, { visibility: "hidden" }, end);
480
+ if (opts.next) {
481
+ tl.set(opts.next, { visibility: "hidden" }, 0);
482
+ tl.set(opts.next, { visibility: "visible" }, end);
483
+ }
484
+ return end;
485
+ }
486
+
487
+ /** Point on a quadratic curve from a to b, bowed sideways by `bow` × distance. */
488
+ function arcPoint(a, b, bow, p) {
489
+ const mx = (a.x + b.x) / 2;
490
+ const my = (a.y + b.y) / 2;
491
+ const dx = b.x - a.x;
492
+ const dy = b.y - a.y;
493
+ const cx = mx - dy * bow;
494
+ const cy = my + dx * bow;
495
+ const u = 1 - p;
496
+ return {
497
+ x: u * u * a.x + 2 * u * p * cx + p * p * b.x,
498
+ y: u * u * a.y + 2 * u * p * cy + p * p * b.y,
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Scattered items travel on arcs into one point (the logo, the end card)
504
+ * and shrink into it. Owns each item's transform: to also pop or stamp an
505
+ * item, wrap it and converge the wrapper. Returns the last arrival.
506
+ */
507
+ function converge(tl, els, at, target, opts = {}) {
508
+ const list = Array.from(els);
509
+ const duration = opts.duration ?? 0.85;
510
+ const stagger = opts.stagger ?? 0.05;
511
+ const bow = opts.bow ?? 0.28;
512
+ const shrink = opts.shrink ?? 0.12;
513
+ const next = rng(opts.seed ?? 5);
514
+ const into = easeOf(opts.ease, "drop");
515
+ let last = at;
516
+ list.forEach((el, i) => {
517
+ const start = centerOf(el, opts.space ?? el.offsetParent);
518
+ const side = next() < 0.5 ? -1 : 1;
519
+ const myBow = bow * side * (0.6 + next() * 0.8);
520
+ const begin = at + i * stagger;
521
+ last = begin + duration;
522
+ drive(tl, begin, duration, (t) => {
523
+ const p = into(clamp(t / duration, 0, 1));
524
+ const pt = arcPoint(start, target, myBow, p);
525
+ const s = lerp(1, shrink, p);
526
+ el.style.transform = `translate(${pt.x - start.x}px, ${pt.y - start.y}px) scale(${s})`;
527
+ el.style.opacity = String(p >= 1 ? 0 : 1);
528
+ });
529
+ });
530
+ return last;
531
+ }
532
+
533
+ // ------------------------------------------------------------- contact
534
+
535
+ /**
536
+ * Height, width-scale and height-scale of a hop at local time t: crouch,
537
+ * a parabola with stretch, a squash on landing, a spring back. Pure.
538
+ */
539
+ function hopAt(t, opts = {}) {
540
+ const height = opts.height ?? 140;
541
+ const air = opts.duration ?? 0.5;
542
+ const squash = opts.squash ?? 0.2;
543
+ const crouch = 0.08;
544
+ const hit = 0.05;
545
+ if (t <= 0) return { lift: 0, sx: 1, sy: 1 };
546
+ if (t < crouch) {
547
+ const q = t / crouch;
548
+ return { lift: 0, sx: 1 + squash * 0.3 * q, sy: 1 - squash * 0.6 * q };
549
+ }
550
+ if (t < crouch + air) {
551
+ const p = (t - crouch) / air;
552
+ const stretch = 1 + squash * 0.5 * Math.sin(Math.PI * p);
553
+ return { lift: 4 * height * p * (1 - p), sx: 2 - stretch, sy: stretch };
554
+ }
555
+ const d = t - crouch - air;
556
+ if (d < hit) {
557
+ const q = d / hit;
558
+ return { lift: 0, sx: 1 + squash * 0.5 * q, sy: 1 - squash * q };
559
+ }
560
+ const e = spring(d - hit, "bouncy");
561
+ return { lift: 0, sx: lerp(1 + squash * 0.5, 1, e), sy: lerp(1 - squash, 1, e) };
562
+ }
563
+
564
+ /**
565
+ * Crouch, hop on a parabola, land with a squash. Owns the element's
566
+ * transform while it plays (wrap it to move it otherwise). Returns the
567
+ * landing time.
568
+ */
569
+ function hop(tl, el, at, opts = {}) {
570
+ const air = opts.duration ?? 0.5;
571
+ const landing = at + 0.08 + air;
572
+ el.style.transformOrigin = "50% 100%";
573
+ drive(tl, at, 0.08 + air + 0.05 + settleTime("bouncy"), (t) => {
574
+ const h = hopAt(t, opts);
575
+ el.style.transform = `translate(0px, ${-h.lift}px) scale(${h.sx}, ${h.sy})`;
576
+ });
577
+ return landing;
578
+ }
579
+
580
+ /**
581
+ * A hit: a short ring-down shake on `shake` (a layer that is not the
582
+ * camera's world; use camera shakes for that) and, with `split`, two halves
583
+ * thrown apart. Returns the hit time.
584
+ */
585
+ function impact(tl, at, opts = {}) {
586
+ const amount = opts.amount ?? 16;
587
+ if (opts.shake) {
588
+ const el = opts.shake;
589
+ const length = opts.duration ?? 0.6;
590
+ drive(tl, at, length, (t) => {
591
+ const x = amount * ring(t, { freq: 13, decay: 8 });
592
+ const y = amount * 0.6 * ring(t, { freq: 17, decay: 9 });
593
+ el.style.translate = `${x}px ${y}px`;
594
+ });
595
+ }
596
+ if (opts.split) {
597
+ const [left, right] = opts.split;
598
+ const spread = opts.spread ?? 140;
599
+ const duration = settleTime("soft");
600
+ tl.to(left, { x: -spread, rotation: -6, duration, ease: springEase("soft") }, at);
601
+ tl.to(right, { x: spread, rotation: 6, duration, ease: springEase("soft") }, at);
602
+ }
603
+ return at;
604
+ }
605
+
606
+ /**
607
+ * A chain reaction: each element is knocked along `axis`, then springs
608
+ * back, and hits the next one as it moves. Returns the last contact.
609
+ */
610
+ function knock(tl, els, at, opts = {}) {
611
+ const list = Array.from(els);
612
+ const push = opts.push ?? 48;
613
+ const gap = opts.gap ?? 0.11;
614
+ const tilt = opts.tilt ?? 7;
615
+ const prop = opts.axis === "y" ? "y" : "x";
616
+ let contact = at;
617
+ list.forEach((el, i) => {
618
+ contact = at + i * gap;
619
+ const decay = 1 - Math.min(0.5, i * 0.06);
620
+ tl.to(
621
+ el,
622
+ { [prop]: push * decay, rotation: tilt * decay, duration: 0.08, ease: "power2.out" },
623
+ contact,
624
+ );
625
+ tl.to(
626
+ el,
627
+ { [prop]: 0, rotation: 0, duration: settleTime("bouncy"), ease: springEase("bouncy") },
628
+ contact + 0.08,
629
+ );
630
+ });
631
+ return contact;
632
+ }
633
+
634
+ // -------------------------------------------------------------- camera
635
+
636
+ const BASE = { x: 960, y: 540, zoom: 1, rotate: 0 };
637
+
638
+ function poseOf(key) {
639
+ return { x: key.x ?? BASE.x, y: key.y ?? BASE.y, zoom: key.zoom ?? 1, rotate: key.rotate ?? 0 };
640
+ }
641
+
642
+ /**
643
+ * Prepares camera keys: each key is a move that starts at `at` (minus the
644
+ * lead) and takes `duration` to reach { x, y, zoom, rotate }, the world
645
+ * point at the centre of the frame. The first key is where it starts.
646
+ */
647
+ function cameraPlan(keys, opts = {}) {
648
+ if (!keys.length) throw new Error("camera: needs at least one key");
649
+ const lead = opts.lead ?? 0;
650
+ const moves = keys.slice(1).map((key) => ({
651
+ start: (key.at ?? 0) - lead,
652
+ duration: key.duration ?? (key.move === "cut" ? 0.001 : 0.9),
653
+ ease: easeOf(key.move, "whip"),
654
+ to: poseOf(key),
655
+ }));
656
+ moves.sort((a, b) => a.start - b.start);
657
+ const plan = { first: poseOf(keys[0]), moves: [] };
658
+ for (const move of moves) {
659
+ plan.moves.push({ ...move, from: posePlanned(plan, move.start) });
660
+ }
661
+ return plan;
662
+ }
663
+
664
+ function posePlanned(plan, t) {
665
+ let pose = plan.first;
666
+ for (const move of plan.moves) {
667
+ if (t < move.start) break;
668
+ const p = easeOf(move.ease)(clamp((t - move.start) / move.duration, 0, 1));
669
+ pose = {
670
+ x: lerp(move.from.x, move.to.x, p),
671
+ y: lerp(move.from.y, move.to.y, p),
672
+ zoom: Math.max(0.05, lerp(move.from.zoom, move.to.zoom, p)),
673
+ rotate: lerp(move.from.rotate, move.to.rotate, p),
674
+ };
675
+ }
676
+ return pose;
677
+ }
678
+
679
+ /** 0 inside any rest, rising smoothly to 1 within `fade` seconds of it. */
680
+ function restEnvelope(t, rests = [], fade = 0.3) {
681
+ let env = 1;
682
+ for (const [a, b] of rests) {
683
+ if (t >= a && t <= b) return 0;
684
+ const d = t < a ? a - t : t - b;
685
+ if (d < fade) env = Math.min(env, ease.glide(d / fade));
686
+ }
687
+ return env;
688
+ }
689
+
690
+ /** The hand-held float: small, slow, seeded drift that is exactly zero in rests. */
691
+ function handheld(t, opts = {}) {
692
+ const seed = opts.seed ?? 7;
693
+ const amp = opts.amp ?? 6;
694
+ const rot = opts.rotate ?? 0.2;
695
+ const rate = opts.rate ?? 0.5;
696
+ const env = restEnvelope(t, opts.rests, opts.fade);
697
+ if (env === 0) return { x: 0, y: 0, rotate: 0 };
698
+ const wob = (s) =>
699
+ 0.72 * noise(seed + s, t * rate) + 0.28 * noise(seed + s + 50, t * rate * 2.7);
700
+ return { x: amp * env * wob(0), y: amp * 0.8 * env * wob(1), rotate: rot * env * wob(2) };
701
+ }
702
+
703
+ /** Shake offsets at t from a list of { at, amount, decay } hits. */
704
+ function shakeAt(t, shakes = []) {
705
+ let x = 0;
706
+ let y = 0;
707
+ for (const s of shakes) {
708
+ const amount = s.amount ?? 14;
709
+ x += amount * ring(t - s.at, { freq: s.freq ?? 15, decay: s.decay ?? 9 });
710
+ y +=
711
+ amount * 0.7 * ring(t - s.at - 0.013, { freq: (s.freq ?? 15) * 1.27, decay: s.decay ?? 9 });
712
+ }
713
+ return { x, y };
714
+ }
715
+
716
+ /** Full camera state at t: planned pose plus hand-held float and shakes (screen px). */
717
+ function cameraAt(plan, t, opts = {}) {
718
+ const pose = posePlanned(plan, t);
719
+ const hand = opts.handheld
720
+ ? handheld(t, { rests: opts.rests, ...opts.handheld })
721
+ : { x: 0, y: 0, rotate: 0 };
722
+ const shake = shakeAt(t, opts.shakes);
723
+ return {
724
+ ...pose,
725
+ offsetX: hand.x + shake.x,
726
+ offsetY: hand.y + shake.y,
727
+ rotate: pose.rotate + hand.rotate,
728
+ };
729
+ }
730
+
731
+ /**
732
+ * The camera as seen by a layer at `depth`: 1 is the world itself, 0 is
733
+ * pinned to the screen (a far sky), 0.5 drifts half as far, 1.4 is a near
734
+ * foreground that rushes past. Pan, float and shake scale with depth; zoom
735
+ * scales geometrically so it stays positive. Pure.
736
+ */
737
+ function atDepth(cam, depth, width = 1920, height = 1080) {
738
+ const cx = width / 2;
739
+ const cy = height / 2;
740
+ return {
741
+ x: cx + (cam.x - cx) * depth,
742
+ y: cy + (cam.y - cy) * depth,
743
+ zoom: cam.zoom ** depth,
744
+ rotate: cam.rotate * depth,
745
+ offsetX: (cam.offsetX ?? 0) * depth,
746
+ offsetY: (cam.offsetY ?? 0) * depth,
747
+ };
748
+ }
749
+
750
+ /** CSS transform that puts camera state `cam` on a layer with origin 0 0. */
751
+ function cameraTransform(cam, width = 1920, height = 1080) {
752
+ return `translate(${width / 2 + (cam.offsetX ?? 0)}px, ${height / 2 + (cam.offsetY ?? 0)}px) rotate(${cam.rotate}deg) scale(${cam.zoom}) translate(${-cam.x}px, ${-cam.y}px)`;
753
+ }
754
+
755
+ /** Where world point (x, y) lands on screen under camera state `cam`. */
756
+ function toScreen(cam, x, y, width = 1920, height = 1080) {
757
+ const dx = (x - cam.x) * cam.zoom;
758
+ const dy = (y - cam.y) * cam.zoom;
759
+ const r = (cam.rotate * Math.PI) / 180;
760
+ return {
761
+ x: width / 2 + (cam.offsetX ?? 0) + dx * Math.cos(r) - dy * Math.sin(r),
762
+ y: height / 2 + (cam.offsetY ?? 0) + dx * Math.sin(r) + dy * Math.cos(r),
763
+ };
764
+ }
765
+
766
+ /**
767
+ * An operated camera on `world` (a layer holding the whole scene, in world
768
+ * coordinates). Keys say where it looks; options add lead, a hand-held
769
+ * float (zero in `rests`), impact `shakes`, and parallax `layers`
770
+ * ([{ el, depth }], each a full-frame layer in its own world coordinates;
771
+ * see atDepth). Returns the plan and a `toScreen(t, x, y)` helper for
772
+ * placing overlays.
773
+ */
774
+ function camera(tl, world, keys, opts = {}) {
775
+ const width = opts.width ?? 1920;
776
+ const height = opts.height ?? 1080;
777
+ const plan = cameraPlan(keys, opts);
778
+ const lastMove = plan.moves.reduce((end, m) => Math.max(end, m.start + m.duration), 0);
779
+ const duration = opts.duration ?? lastMove + 0.01;
780
+ const layers = Array.from(opts.layers ?? []);
781
+ for (const layer of layers) {
782
+ if (!layer || !layer.el || !Number.isFinite(layer.depth) || layer.depth < 0)
783
+ throw new Error("camera: each layer needs { el, depth } with depth >= 0");
784
+ }
785
+ world.style.transformOrigin = "0 0";
786
+ for (const layer of layers) layer.el.style.transformOrigin = "0 0";
787
+ airSources.push({ tl, plan, duration, width, height });
788
+ queueFlush();
789
+ drive(tl, 0, duration, (t) => {
790
+ const cam = cameraAt(plan, t, opts);
791
+ world.style.transform = cameraTransform(cam, width, height);
792
+ for (const layer of layers) {
793
+ layer.el.style.transform = cameraTransform(
794
+ atDepth(cam, layer.depth, width, height),
795
+ width,
796
+ height,
797
+ );
798
+ }
799
+ });
800
+ return {
801
+ plan,
802
+ at: (t) => cameraAt(plan, t, opts),
803
+ toScreen: (t, x, y) => toScreen(cameraAt(plan, t, opts), x, y, width, height),
804
+ };
805
+ }
806
+
807
+ // ----------------------------------------------------------- deep zoom
808
+
809
+ function finitePoint(p, label) {
810
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y))
811
+ throw new Error(`${label} must be { x, y } numbers`);
812
+ return { x: p.x, y: p.y };
813
+ }
814
+
815
+ function deepZoomOpts(opts) {
816
+ const width = opts.width ?? 1920;
817
+ const height = opts.height ?? 1080;
818
+ const centre = { x: width / 2, y: height / 2 };
819
+ const from = opts.from ?? 1;
820
+ const to = opts.to ?? 100;
821
+ for (const [name, z] of [
822
+ ["from", from],
823
+ ["to", to],
824
+ ]) {
825
+ if (!(Number.isFinite(z) && z > 0)) throw new Error(`deepZoom: ${name} zoom must be > 0`);
826
+ }
827
+ const duration = opts.duration ?? 3;
828
+ if (!(duration > 0)) throw new Error("deepZoom: duration must be > 0");
829
+ return {
830
+ width,
831
+ height,
832
+ anchor: finitePoint(opts.anchor ?? centre, "deepZoom: anchor"),
833
+ from,
834
+ to,
835
+ screenFrom: finitePoint(opts.screenFrom ?? centre, "deepZoom: screenFrom"),
836
+ screenTo: finitePoint(opts.screenTo ?? opts.screenFrom ?? centre, "deepZoom: screenTo"),
837
+ duration,
838
+ ease: easeOf(opts.ease, "glide"),
839
+ };
840
+ }
841
+
842
+ /**
843
+ * Camera state at local time t of a deep zoom: the zoom is eased in log
844
+ * space and the anchor's screen point is eased linearly; the world point at
845
+ * frame centre is recovered from those, so the anchor never drifts however
846
+ * far the zoom goes. Same shape as cameraAt (works with toScreen). Pure.
847
+ */
848
+ function deepZoomAt(t, opts = {}) {
849
+ const o = deepZoomOpts(opts);
850
+ const p = o.ease(clamp(t / o.duration, 0, 1));
851
+ const zoom = Math.exp(lerp(Math.log(o.from), Math.log(o.to), p));
852
+ const sx = lerp(o.screenFrom.x, o.screenTo.x, p);
853
+ const sy = lerp(o.screenFrom.y, o.screenTo.y, p);
854
+ return {
855
+ x: o.anchor.x - (sx - o.width / 2) / zoom,
856
+ y: o.anchor.y - (sy - o.height / 2) / zoom,
857
+ zoom,
858
+ rotate: 0,
859
+ offsetX: 0,
860
+ offsetY: 0,
861
+ };
862
+ }
863
+
864
+ /**
865
+ * Zooms `world` (same conventions as camera: world coordinates, origin 0 0)
866
+ * by any factor, keyed by where `anchor` sits on screen. Returns the end.
867
+ */
868
+ function deepZoom(tl, world, at, opts = {}) {
869
+ const o = deepZoomOpts(opts);
870
+ world.style.transformOrigin = "0 0";
871
+ return drive(tl, at, o.duration, (t) => {
872
+ world.style.transform = cameraTransform(deepZoomAt(t, opts), o.width, o.height);
873
+ });
874
+ }
875
+
876
+ // --------------------------------------------------------------- light
877
+
878
+ function lightOpts(opts) {
879
+ const rise = opts.rise ?? 0.9;
880
+ const hold = opts.hold ?? 0.6;
881
+ const drain = opts.drain ?? 0.9;
882
+ for (const [name, v] of [
883
+ ["rise", rise],
884
+ ["hold", hold],
885
+ ["drain", drain],
886
+ ]) {
887
+ if (!(Number.isFinite(v) && v >= 0)) throw new Error(`lightGround: ${name} must be >= 0`);
888
+ }
889
+ if (rise + hold + drain <= 0) throw new Error("lightGround: needs a duration");
890
+ return { rise, hold, drain, ease: easeOf(opts.ease, "glide") };
891
+ }
892
+
893
+ /** Light level 0..1 at local time t: rises, holds flooded, drains. Pure. */
894
+ function lightLevel(t, opts = {}) {
895
+ const o = lightOpts(opts);
896
+ if (t <= 0) return 0;
897
+ if (t < o.rise) return o.ease(t / o.rise);
898
+ if (t <= o.rise + o.hold) return 1;
899
+ if (t < o.rise + o.hold + o.drain) return 1 - o.ease((t - o.rise - o.hold) / o.drain);
900
+ return o.drain > 0 ? 0 : 1;
901
+ }
902
+
903
+ /**
904
+ * A soft field of light anchored below the frame (x, y default centre,
905
+ * height + 0.25 h) that rises, floods and drains. On a canvas it paints a
906
+ * radial gradient; on any other element it sets --light (0..1), --light-r
907
+ * (px), --light-x and --light-y for its CSS to use. Returns the flood time.
908
+ */
909
+ function lightGround(tl, el, at, opts = {}) {
910
+ const o = lightOpts(opts);
911
+ const width = opts.width ?? 1920;
912
+ const height = opts.height ?? 1080;
913
+ const x = opts.x ?? width / 2;
914
+ const y = opts.y ?? height * 1.25;
915
+ const color = opts.color ?? "255, 244, 222";
916
+ const full = coverRadius(x, y, width, height) * (opts.reach ?? 1.15);
917
+ const ctx = typeof el.getContext === "function" ? el.getContext("2d") : null;
918
+ drive(tl, at, o.rise + o.hold + o.drain, (t) => {
919
+ const level = lightLevel(t, opts);
920
+ const r = Math.max(1, full * level);
921
+ if (ctx) {
922
+ ctx.clearRect(0, 0, el.width, el.height);
923
+ if (level <= 0) return;
924
+ const sx = el.width / width;
925
+ const g = ctx.createRadialGradient(x * sx, y * sx, 0, x * sx, y * sx, r * sx);
926
+ g.addColorStop(0, `rgba(${color}, ${level})`);
927
+ g.addColorStop(0.55, `rgba(${color}, ${level * 0.85})`);
928
+ g.addColorStop(1, `rgba(${color}, 0)`);
929
+ ctx.fillStyle = g;
930
+ ctx.fillRect(0, 0, el.width, el.height);
931
+ return;
932
+ }
933
+ el.style.setProperty("--light", String(level));
934
+ el.style.setProperty("--light-r", `${r}px`);
935
+ el.style.setProperty("--light-x", `${x}px`);
936
+ el.style.setProperty("--light-y", `${y}px`);
937
+ });
938
+ return at + o.rise;
939
+ }
940
+
941
+ function ringsOpts(opts) {
942
+ const count = opts.count ?? 4;
943
+ if (!(Number.isInteger(count) && count >= 1)) throw new Error("rings: count must be >= 1");
944
+ const duration = opts.duration ?? 1.2;
945
+ if (!(duration > 0)) throw new Error("rings: duration must be > 0");
946
+ const stagger = opts.stagger ?? 0.14;
947
+ if (!(stagger >= 0)) throw new Error("rings: stagger must be >= 0");
948
+ const width = opts.width ?? 1920;
949
+ const height = opts.height ?? 1080;
950
+ const x = opts.x ?? width / 2;
951
+ const y = opts.y ?? height / 2;
952
+ return {
953
+ count,
954
+ duration,
955
+ stagger,
956
+ x,
957
+ y,
958
+ end: coverRadius(x, y, width, height),
959
+ ease: easeOf(opts.ease, "snap"),
960
+ };
961
+ }
962
+
963
+ /**
964
+ * Rings at local time t: [{ r, inner }] outer and inner radius of each
965
+ * revealed band, ring 0 first and largest. Each ring grows from the point
966
+ * and fills in to a disc, so ring 0 ends covering the frame. Pure.
967
+ */
968
+ function ringsAt(t, opts = {}) {
969
+ const o = ringsOpts(opts);
970
+ const out = [];
971
+ for (let i = 0; i < o.count; i++) {
972
+ const p = clamp((t - i * o.stagger) / o.duration, 0, 1);
973
+ const r = o.end * o.ease(p);
974
+ out.push({ r, inner: r * (1 - ease.drop(p)) });
975
+ }
976
+ return out;
977
+ }
978
+
979
+ /** Union of the ring bands as a CSS mask: opaque where revealed. */
980
+ function ringsMask(list, x, y) {
981
+ const bands = list
982
+ .filter((b) => b.r > 0.01)
983
+ .map((b) => [b.inner, b.r])
984
+ .sort((a, b) => a[0] - b[0]);
985
+ const merged = [];
986
+ for (const b of bands) {
987
+ const last = merged[merged.length - 1];
988
+ if (last && b[0] <= last[1]) last[1] = Math.max(last[1], b[1]);
989
+ else merged.push([...b]);
990
+ }
991
+ if (!merged.length)
992
+ return `radial-gradient(circle at ${x}px ${y}px, transparent 0, transparent 100%)`;
993
+ const stops = [];
994
+ for (const [a, b] of merged) {
995
+ stops.push(`transparent ${a}px`, `#000 ${a}px`, `#000 ${b}px`, `transparent ${b}px`);
996
+ }
997
+ return `radial-gradient(circle at ${x}px ${y}px, transparent 0px, ${stops.join(", ")})`;
998
+ }
999
+
1000
+ /**
1001
+ * Reveals `el` (the next scene, layered above the flood) through
1002
+ * concentric rings expanding from (x, y), ending fully shown: an end to a
1003
+ * flood without a wipe. Returns the time it is fully revealed.
1004
+ */
1005
+ function rings(tl, el, at, opts = {}) {
1006
+ const o = ringsOpts(opts);
1007
+ const total = o.duration + o.stagger * (o.count - 1);
1008
+ drive(tl, at, total, (t) => {
1009
+ const mask = ringsMask(ringsAt(t, opts), o.x, o.y);
1010
+ el.style.maskImage = mask;
1011
+ el.style.webkitMaskImage = mask;
1012
+ });
1013
+ return at + o.duration;
1014
+ }
1015
+
1016
+ // ---------------------------------------------------------------- silk
1017
+
1018
+ function parseColor(c) {
1019
+ const m = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(String(c).trim());
1020
+ if (!m) throw new Error(`silk: colours must be hex like #1a2b3c, got ${c}`);
1021
+ const h = m[1].length === 3 ? [...m[1]].map((d) => d + d).join("") : m[1];
1022
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
1023
+ }
1024
+
1025
+ /** Smooth 2D value noise in [-1, 1] from the kit's hash. */
1026
+ function noise2(seed, x, y) {
1027
+ const xi = Math.floor(x);
1028
+ const yi = Math.floor(y);
1029
+ const fade = (f) => f * f * f * (f * (f * 6 - 15) + 10);
1030
+ const u = fade(x - xi);
1031
+ const v = fade(y - yi);
1032
+ const h = (i, j) => hash(i * 73856093 + j * 19349663, seed);
1033
+ return (
1034
+ lerp(lerp(h(xi, yi), h(xi + 1, yi), u), lerp(h(xi, yi + 1), h(xi + 1, yi + 1), u), v) * 2 - 1
1035
+ );
1036
+ }
1037
+
1038
+ function silkOpts(opts) {
1039
+ if (!Array.isArray(opts.colors) || opts.colors.length < 2)
1040
+ throw new Error("silk: needs colors, at least two hex colours");
1041
+ const cols = opts.cols ?? 64;
1042
+ const rows = opts.rows ?? Math.round((cols * 9) / 16);
1043
+ if (!(Number.isInteger(cols) && cols >= 2 && Number.isInteger(rows) && rows >= 2))
1044
+ throw new Error("silk: cols and rows must be integers >= 2");
1045
+ return {
1046
+ colors: opts.colors.map(parseColor),
1047
+ seed: opts.seed ?? 1,
1048
+ speed: opts.speed ?? 0.08,
1049
+ scale: opts.scale ?? 2.2,
1050
+ warp: opts.warp ?? 1.2,
1051
+ cols,
1052
+ rows,
1053
+ };
1054
+ }
1055
+
1056
+ /**
1057
+ * RGBA pixels (cols x rows) of the silk field at time t: warped seeded
1058
+ * noise mapped across the colours. Pure.
1059
+ */
1060
+ function silkField(t, opts = {}) {
1061
+ const o = silkOpts(opts);
1062
+ const out = new Uint8ClampedArray(o.cols * o.rows * 4);
1063
+ const s = o.seed * 101;
1064
+ const time = t * o.speed;
1065
+ const last = o.colors.length - 1;
1066
+ for (let j = 0; j < o.rows; j++) {
1067
+ for (let i = 0; i < o.cols; i++) {
1068
+ const x = (i / o.cols) * o.scale * (o.cols / o.rows);
1069
+ const y = (j / o.rows) * o.scale;
1070
+ const qx = noise2(s + 1, x + time, y - time * 0.7);
1071
+ const qy = noise2(s + 2, x - time * 0.6, y + time + 5.2);
1072
+ const v =
1073
+ 0.65 * noise2(s + 3, x + o.warp * qx + time * 0.3, y + o.warp * qy) +
1074
+ 0.35 * noise2(s + 4, 2 * x - o.warp * qy, 2 * y + o.warp * qx - time * 0.5);
1075
+ const f = clamp(0.5 + 0.62 * v, 0, 1) * last;
1076
+ const k = Math.min(last - 1, Math.floor(f));
1077
+ const a = o.colors[k];
1078
+ const b = o.colors[k + 1];
1079
+ const p = ease.glide(f - k);
1080
+ const at = (j * o.cols + i) * 4;
1081
+ out[at] = lerp(a[0], b[0], p);
1082
+ out[at + 1] = lerp(a[1], b[1], p);
1083
+ out[at + 2] = lerp(a[2], b[2], p);
1084
+ out[at + 3] = 255;
1085
+ }
1086
+ }
1087
+ return out;
1088
+ }
1089
+
1090
+ /**
1091
+ * A slowly flowing colour field on `canvas`: drawn into a small grid
1092
+ * (cols x rows backing pixels) and stretched smoothly by CSS. Returns the end.
1093
+ */
1094
+ function silk(tl, canvas, at, duration, opts = {}) {
1095
+ const o = silkOpts(opts);
1096
+ if (!(duration > 0)) throw new Error("silk: duration must be > 0");
1097
+ canvas.width = o.cols;
1098
+ canvas.height = o.rows;
1099
+ canvas.style.imageRendering = "auto";
1100
+ const ctx = canvas.getContext("2d");
1101
+ const image = ctx.createImageData(o.cols, o.rows);
1102
+ return drive(tl, at, duration, (t) => {
1103
+ image.data.set(silkField(at + t, opts));
1104
+ ctx.putImageData(image, 0, 0);
1105
+ });
1106
+ }
1107
+
1108
+ // ---------------------------------------------------------------- belt
1109
+
1110
+ function beltOpts(opts) {
1111
+ const speed = opts.speed ?? 240;
1112
+ if (!(Number.isFinite(speed) && speed > 0)) throw new Error("belt: speed must be > 0");
1113
+ const run = opts.stop ?? 4;
1114
+ if (!(Number.isFinite(run) && run > 0)) throw new Error("belt: stop must be after the start");
1115
+ let rampIn = opts.rampIn ?? 0.6;
1116
+ let rampOut = opts.rampOut ?? 0.9;
1117
+ if (!(rampIn >= 0 && rampOut >= 0)) throw new Error("belt: ramps must be >= 0");
1118
+ if (rampIn + rampOut > run) {
1119
+ const k = run / (rampIn + rampOut);
1120
+ rampIn *= k;
1121
+ rampOut *= k;
1122
+ }
1123
+ return { speed, run, rampIn, rampOut };
1124
+ }
1125
+
1126
+ // Distance covered while easing up from rest over `ramp` seconds, after u seconds.
1127
+ function rampDistance(u, ramp) {
1128
+ if (ramp <= 0) return u;
1129
+ const q = clamp(u / ramp, 0, 1);
1130
+ return ramp * (q / 2 - Math.sin(Math.PI * q) / (2 * Math.PI)) + Math.max(0, u - ramp);
1131
+ }
1132
+
1133
+ /**
1134
+ * Belt state at local time t (`stop` here is local, seconds after the
1135
+ * start): offset travelled (px, exact integral of the speed), current speed
1136
+ * (px/s) and clock (offset / speed: running seconds that slow with it). Pure.
1137
+ */
1138
+ function beltAt(t, opts = {}) {
1139
+ const o = beltOpts(opts);
1140
+ const u = clamp(t, 0, o.run);
1141
+ const cruiseEnd = o.run - o.rampOut;
1142
+ let dist = rampDistance(Math.min(u, cruiseEnd), o.rampIn);
1143
+ let speed = o.rampIn > 0 ? o.speed * ease.glide(clamp(u / o.rampIn, 0, 1)) : o.speed;
1144
+ if (u > cruiseEnd) {
1145
+ // The ramp down mirrors a ramp up, run backwards from the stop.
1146
+ const left = o.run - u;
1147
+ dist += rampDistance(o.rampOut, o.rampOut) - rampDistance(left, o.rampOut);
1148
+ speed = o.rampOut > 0 ? o.speed * ease.glide(clamp(left / o.rampOut, 0, 1)) : 0;
1149
+ }
1150
+ if (t <= 0 || t >= o.run) speed = 0;
1151
+ const offset = dist * o.speed;
1152
+ return { offset, speed, clock: offset / o.speed };
1153
+ }
1154
+
1155
+ /**
1156
+ * A carousel: `items` sit `spacing` px apart along `axis` and travel at
1157
+ * constant `speed` (direction -1 leftward/up), easing up from rest at `at`
1158
+ * and easing to a stop exactly at `stop` (timeline time). Items wrap round
1159
+ * a loop of `wrap` px starting at `origin`. Owns each item's transform.
1160
+ * Returns { stop, at(t), clock(t) } in timeline time.
1161
+ */
1162
+ function belt(tl, items, at, opts = {}) {
1163
+ const list = Array.from(items);
1164
+ if (!list.length) throw new Error("belt: needs at least one item");
1165
+ const stop = opts.stop ?? at + 4;
1166
+ if (!(stop > at)) throw new Error("belt: stop must be after the start");
1167
+ const local = { ...opts, stop: stop - at };
1168
+ beltOpts(local);
1169
+ const spacing = opts.spacing ?? 360;
1170
+ const wrap = opts.wrap ?? spacing * list.length;
1171
+ if (!(spacing > 0 && wrap > 0)) throw new Error("belt: spacing and wrap must be > 0");
1172
+ const origin = opts.origin ?? -spacing;
1173
+ const dir = opts.direction ?? -1;
1174
+ const axis = opts.axis === "y" ? "y" : "x";
1175
+ drive(tl, at, stop - at, (t) => {
1176
+ const { offset } = beltAt(t, local);
1177
+ list.forEach((el, i) => {
1178
+ const raw = i * spacing + dir * offset - origin;
1179
+ const pos = origin + (((raw % wrap) + wrap) % wrap);
1180
+ el.style.transform = axis === "x" ? `translate(${pos}px, 0px)` : `translate(0px, ${pos}px)`;
1181
+ });
1182
+ });
1183
+ return {
1184
+ stop,
1185
+ at: (t) => beltAt(t - at, local),
1186
+ clock: (t) => beltAt(t - at, local).clock,
1187
+ };
1188
+ }
1189
+
1190
+ // --------------------------------------------------------------- brush
1191
+
1192
+ /**
1193
+ * Points along an SVG path string (M L H V C Q Z, absolute or relative),
1194
+ * curves sampled every ~`step` px. Pure.
1195
+ */
1196
+ function pathPoints(d, step = 6) {
1197
+ const tokens = String(d).match(/[MLHVCQZmlhvcqz]|-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/g);
1198
+ if (!tokens || !/[Mm]/.test(tokens[0])) throw new Error("brushStroke: path must start with M");
1199
+ const pts = [];
1200
+ let cmd = "";
1201
+ let i = 0;
1202
+ let cx = 0;
1203
+ let cy = 0;
1204
+ let sx = 0;
1205
+ let sy = 0;
1206
+ const num = () => {
1207
+ const v = Number(tokens[i++]);
1208
+ if (!Number.isFinite(v)) throw new Error(`brushStroke: bad path near "${tokens[i - 1]}"`);
1209
+ return v;
1210
+ };
1211
+ const curve = (fn, len) => {
1212
+ const n = Math.max(2, Math.ceil(len / step));
1213
+ for (let k = 1; k <= n; k++) pts.push(fn(k / n));
1214
+ };
1215
+ while (i < tokens.length) {
1216
+ if (/[A-Za-z]/.test(tokens[i])) cmd = tokens[i++];
1217
+ const rel = cmd === cmd.toLowerCase();
1218
+ const ox = rel ? cx : 0;
1219
+ const oy = rel ? cy : 0;
1220
+ switch (cmd.toUpperCase()) {
1221
+ case "M":
1222
+ cx = ox + num();
1223
+ cy = oy + num();
1224
+ sx = cx;
1225
+ sy = cy;
1226
+ pts.push({ x: cx, y: cy });
1227
+ cmd = rel ? "l" : "L";
1228
+ break;
1229
+ case "L":
1230
+ cx = ox + num();
1231
+ cy = oy + num();
1232
+ pts.push({ x: cx, y: cy });
1233
+ break;
1234
+ case "H":
1235
+ cx = ox + num();
1236
+ pts.push({ x: cx, y: cy });
1237
+ break;
1238
+ case "V":
1239
+ cy = oy + num();
1240
+ pts.push({ x: cx, y: cy });
1241
+ break;
1242
+ case "C": {
1243
+ const a = { x: cx, y: cy };
1244
+ const b = { x: ox + num(), y: oy + num() };
1245
+ const c = { x: ox + num(), y: oy + num() };
1246
+ const e = { x: ox + num(), y: oy + num() };
1247
+ const len =
1248
+ Math.hypot(b.x - a.x, b.y - a.y) +
1249
+ Math.hypot(c.x - b.x, c.y - b.y) +
1250
+ Math.hypot(e.x - c.x, e.y - c.y);
1251
+ curve((p) => {
1252
+ const u = 1 - p;
1253
+ return {
1254
+ x: u * u * u * a.x + 3 * u * u * p * b.x + 3 * u * p * p * c.x + p * p * p * e.x,
1255
+ y: u * u * u * a.y + 3 * u * u * p * b.y + 3 * u * p * p * c.y + p * p * p * e.y,
1256
+ };
1257
+ }, len);
1258
+ cx = e.x;
1259
+ cy = e.y;
1260
+ break;
1261
+ }
1262
+ case "Q": {
1263
+ const a = { x: cx, y: cy };
1264
+ const b = { x: ox + num(), y: oy + num() };
1265
+ const e = { x: ox + num(), y: oy + num() };
1266
+ const len = Math.hypot(b.x - a.x, b.y - a.y) + Math.hypot(e.x - b.x, e.y - b.y);
1267
+ curve((p) => {
1268
+ const u = 1 - p;
1269
+ return {
1270
+ x: u * u * a.x + 2 * u * p * b.x + p * p * e.x,
1271
+ y: u * u * a.y + 2 * u * p * b.y + p * p * e.y,
1272
+ };
1273
+ }, len);
1274
+ cx = e.x;
1275
+ cy = e.y;
1276
+ break;
1277
+ }
1278
+ case "Z":
1279
+ cx = sx;
1280
+ cy = sy;
1281
+ pts.push({ x: cx, y: cy });
1282
+ break;
1283
+ default:
1284
+ throw new Error(`brushStroke: unsupported path command "${cmd}"`);
1285
+ }
1286
+ }
1287
+ return pts;
1288
+ }
1289
+
1290
+ /**
1291
+ * Prepares a brush stroke: the path resampled evenly (`spacing` px) with
1292
+ * arc position s 0..1, direction normals and seeded bristles. Pure.
1293
+ */
1294
+ function brushPlan(opts = {}) {
1295
+ const raw = opts.path !== undefined ? pathPoints(opts.path) : opts.points;
1296
+ if (!Array.isArray(raw) || raw.length < 2)
1297
+ throw new Error("brushStroke: needs points (2 or more) or an SVG path");
1298
+ raw.forEach((p) => finitePoint(p, "brushStroke: each point"));
1299
+ const width = opts.width ?? 28;
1300
+ const count = opts.bristles ?? 48;
1301
+ if (!(width > 0)) throw new Error("brushStroke: width must be > 0");
1302
+ if (!(Number.isInteger(count) && count >= 1))
1303
+ throw new Error("brushStroke: bristles must be an integer >= 1");
1304
+ const spacing = opts.spacing ?? 3;
1305
+ const cum = [0];
1306
+ for (let i = 1; i < raw.length; i++)
1307
+ cum.push(cum[i - 1] + Math.hypot(raw[i].x - raw[i - 1].x, raw[i].y - raw[i - 1].y));
1308
+ const length = cum[cum.length - 1];
1309
+ if (!(length > 0)) throw new Error("brushStroke: path has no length");
1310
+ const n = Math.max(2, Math.ceil(length / spacing) + 1);
1311
+ const samples = [];
1312
+ let seg = 1;
1313
+ for (let k = 0; k < n; k++) {
1314
+ const d = (k / (n - 1)) * length;
1315
+ while (seg < raw.length - 1 && cum[seg] < d) seg++;
1316
+ const a = raw[seg - 1];
1317
+ const b = raw[seg];
1318
+ const span = cum[seg] - cum[seg - 1] || 1;
1319
+ const p = clamp((d - cum[seg - 1]) / span, 0, 1);
1320
+ const tx = b.x - a.x;
1321
+ const ty = b.y - a.y;
1322
+ const tn = Math.hypot(tx, ty) || 1;
1323
+ samples.push({
1324
+ x: lerp(a.x, b.x, p),
1325
+ y: lerp(a.y, b.y, p),
1326
+ nx: -ty / tn,
1327
+ ny: tx / tn,
1328
+ s: d / length,
1329
+ });
1330
+ }
1331
+ const seed = opts.seed ?? 3;
1332
+ const next = rng(seed);
1333
+ const bristles = Array.from({ length: count }, (_, i) => ({
1334
+ // Denser in the middle, like a loaded round brush.
1335
+ offset: (next() * 2 - 1) * (0.55 + 0.45 * next()),
1336
+ thick: 0.6 + next() * 0.9,
1337
+ alpha: 0.35 + next() * 0.5,
1338
+ hold: next(),
1339
+ seed: seed * 977 + i * 13,
1340
+ }));
1341
+ return {
1342
+ samples,
1343
+ bristles,
1344
+ width,
1345
+ length,
1346
+ dryness: opts.dryness ?? 0.65,
1347
+ color: opts.color ?? "#141414",
1348
+ };
1349
+ }
1350
+
1351
+ /** Brush width factor along the stroke: swells on touch-down, tapers off. Pure. */
1352
+ function brushWidth(s) {
1353
+ const touch =
1354
+ Math.sqrt(clamp(s / 0.07, 0, 1)) * (1 + 0.22 * Math.exp(-(((s - 0.08) / 0.06) ** 2)));
1355
+ const lift = clamp((1 - s) / 0.3, 0, 1) ** 0.7;
1356
+ return Math.max(0.12, touch * lift);
1357
+ }
1358
+
1359
+ /** Draws the stroke up to progress k (0..1) from scratch on `ctx`. */
1360
+ function drawBrush(ctx, plan, k, canvas) {
1361
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1362
+ if (k <= 0) return 0;
1363
+ const last = Math.min(plan.samples.length - 1, Math.floor(k * (plan.samples.length - 1)));
1364
+ if (last < 1) return 0;
1365
+ ctx.lineCap = "round";
1366
+ ctx.lineJoin = "round";
1367
+ ctx.strokeStyle = plan.color;
1368
+ let marks = 0;
1369
+ for (const b of plan.bristles) {
1370
+ ctx.globalAlpha = b.alpha;
1371
+ ctx.lineWidth = (plan.width / plan.bristles.length) * 2.2 * b.thick;
1372
+ ctx.beginPath();
1373
+ let down = false;
1374
+ for (let i = 0; i <= last; i++) {
1375
+ const p = plan.samples[i];
1376
+ // Dry brush: the further along, the more this bristle skips the paper.
1377
+ const dry = plan.dryness * p.s ** 1.6;
1378
+ const skip = 0.5 + 0.5 * noise(b.seed, p.s * 18) < dry * (0.6 + 0.8 * b.hold);
1379
+ if (skip) {
1380
+ down = false;
1381
+ continue;
1382
+ }
1383
+ const w = (plan.width / 2) * brushWidth(p.s) * b.offset;
1384
+ const x = p.x + p.nx * w;
1385
+ const y = p.y + p.ny * w;
1386
+ if (down) {
1387
+ ctx.lineTo(x, y);
1388
+ marks++;
1389
+ } else {
1390
+ ctx.moveTo(x, y);
1391
+ down = true;
1392
+ }
1393
+ }
1394
+ ctx.stroke();
1395
+ }
1396
+ ctx.globalAlpha = 1;
1397
+ return marks;
1398
+ }
1399
+
1400
+ /**
1401
+ * A natural-media brush stroke drawn along `points` or an SVG `path` on
1402
+ * `canvas` (in its pixel space) from `at` over `duration`, redrawn from
1403
+ * scratch each frame. Returns the time the brush lifts.
1404
+ */
1405
+ function brushStroke(tl, canvas, at, opts = {}) {
1406
+ const plan = brushPlan(opts);
1407
+ const duration = opts.duration ?? 0.8;
1408
+ if (!(duration > 0)) throw new Error("brushStroke: duration must be > 0");
1409
+ const curve = easeOf(opts.ease, "glide");
1410
+ const ctx = canvas.getContext("2d");
1411
+ return drive(tl, at, duration, (t) => {
1412
+ drawBrush(ctx, plan, curve(clamp(t / duration, 0, 1)), canvas);
1413
+ });
1414
+ }
1415
+
1416
+ // --------------------------------------------------------------- grain
1417
+
1418
+ /**
1419
+ * RGBA pixels of a seeded size x size grain tile: each pixel nudges
1420
+ * lighter or darker at alpha <= amount, enough to dither 8-bit bands. Pure.
1421
+ */
1422
+ function grainTile(opts = {}) {
1423
+ const size = opts.size ?? 128;
1424
+ const amount = opts.amount ?? 0.05;
1425
+ if (!(Number.isInteger(size) && size >= 2 && size <= 1024))
1426
+ throw new Error("grain: size must be an integer 2..1024");
1427
+ if (!(amount > 0 && amount <= 1)) throw new Error("grain: amount must be 0..1");
1428
+ const next = rng(opts.seed ?? 9);
1429
+ const out = new Uint8ClampedArray(size * size * 4);
1430
+ for (let i = 0; i < size * size; i++) {
1431
+ const v = next() * 2 - 1;
1432
+ const shade = v < 0 ? 0 : 255;
1433
+ out[i * 4] = shade;
1434
+ out[i * 4 + 1] = shade;
1435
+ out[i * 4 + 2] = shade;
1436
+ out[i * 4 + 3] = Math.round(Math.abs(v) * amount * 255);
1437
+ }
1438
+ return out;
1439
+ }
1440
+
1441
+ /**
1442
+ * Lays a fixed, seeded grain tile over `el` (a full-frame overlay above dark
1443
+ * gradients) as a repeated background. Static: identical on every frame.
1444
+ * Returns the tile's data URL.
1445
+ */
1446
+ function grain(el, opts = {}) {
1447
+ const size = opts.size ?? 128;
1448
+ const data = grainTile(opts);
1449
+ const doc = opts.document ?? el.ownerDocument ?? root.document;
1450
+ if (!doc || typeof doc.createElement !== "function")
1451
+ throw new Error("grain: needs a document to draw the tile");
1452
+ const canvas = doc.createElement("canvas");
1453
+ canvas.width = size;
1454
+ canvas.height = size;
1455
+ const ctx = canvas.getContext("2d");
1456
+ const image = ctx.createImageData(size, size);
1457
+ image.data.set(data);
1458
+ ctx.putImageData(image, 0, 0);
1459
+ const url = canvas.toDataURL("image/png");
1460
+ el.style.backgroundImage = `url(${url})`;
1461
+ el.style.backgroundRepeat = "repeat";
1462
+ el.style.backgroundSize = `${size}px ${size}px`;
1463
+ el.style.pointerEvents = "none";
1464
+ el.style.imageRendering = "pixelated";
1465
+ return url;
1466
+ }
1467
+
1468
+ // --------------------------------------------------------------- print
1469
+
1470
+ // A risograph / screen-print look: flat spot inks that overprint (multiply),
1471
+ // a halftone screen, a little misregistration, and paper underneath.
1472
+
1473
+ function screenOpts(opts) {
1474
+ const cell = opts.cell ?? 7;
1475
+ const angle = opts.angle ?? 15;
1476
+ const amount = opts.amount ?? 0.86;
1477
+ if (!(cell >= 2 && cell <= 64)) throw new Error("printInk: cell must be 2..64 px");
1478
+ if (!Number.isFinite(angle)) throw new Error("printInk: angle must be a number (degrees)");
1479
+ if (!(amount > 0 && amount <= 1)) throw new Error("printInk: amount must be 0..1");
1480
+ // A screen tiles seamlessly when its lattice step is a whole-pixel vector
1481
+ // (a, b): the square tile is then a*a + b*b pixels wide.
1482
+ const rad = (angle * Math.PI) / 180;
1483
+ let a = Math.round(cell * Math.cos(rad));
1484
+ let b = Math.round(cell * Math.sin(rad));
1485
+ if (a === 0 && b === 0) a = 1;
1486
+ const size = a * a + b * b;
1487
+ if (size > 2048) throw new Error("printInk: cell too large for a seamless tile");
1488
+ return { a, b, size, amount, rough: opts.rough ?? 0.18, seed: opts.seed ?? 3 };
1489
+ }
1490
+
1491
+ /**
1492
+ * RGBA pixels of a seamless halftone screen tile (`size` x `size`, returned
1493
+ * as { size, data }): black dots whose alpha covers about `amount` of the
1494
+ * area, at `angle` degrees with `cell` px pitch; `rough` frays dot edges
1495
+ * with seeded per-pixel noise. Alpha is the ink. Pure.
1496
+ */
1497
+ function halftoneTile(opts = {}) {
1498
+ const o = screenOpts(opts);
1499
+ const { a, b, size } = o;
1500
+ // Dot radius (in cells) for the asked coverage; past ~0.785 dots merge.
1501
+ const r = Math.min(0.75, Math.sqrt(o.amount / Math.PI));
1502
+ const phaseU = hash(1, o.seed);
1503
+ const phaseV = hash(2, o.seed);
1504
+ const out = new Uint8ClampedArray(size * size * 4);
1505
+ const ss = [0.25, 0.75];
1506
+ for (let y = 0; y < size; y++) {
1507
+ for (let x = 0; x < size; x++) {
1508
+ const edge = (hash(y * size + x, o.seed) * 2 - 1) * o.rough * 0.5;
1509
+ let ink = 0;
1510
+ for (const sy of ss)
1511
+ for (const sx of ss) {
1512
+ const px = x + sx;
1513
+ const py = y + sy;
1514
+ const u = (px * a + py * b) / size + phaseU;
1515
+ const v = (-px * b + py * a) / size + phaseV;
1516
+ const d = Math.hypot(u - Math.round(u), v - Math.round(v));
1517
+ if (d < r + edge) ink++;
1518
+ }
1519
+ const at = (y * size + x) * 4;
1520
+ out[at + 3] = Math.round((ink / 4) * 255);
1521
+ }
1522
+ }
1523
+ return { size, data: out };
1524
+ }
1525
+
1526
+ function tileURL(doc, size, data, who) {
1527
+ if (!doc || typeof doc.createElement !== "function")
1528
+ throw new Error(`${who}: needs a document to draw the tile`);
1529
+ const canvas = doc.createElement("canvas");
1530
+ canvas.width = size;
1531
+ canvas.height = size;
1532
+ const ctx = canvas.getContext("2d");
1533
+ const image = ctx.createImageData(size, size);
1534
+ image.data.set(data);
1535
+ ctx.putImageData(image, 0, 0);
1536
+ return canvas.toDataURL("image/png");
1537
+ }
1538
+
1539
+ /**
1540
+ * Makes `el` a printed spot-ink layer: multiply blending (overlapping inks
1541
+ * darken, like overprinting), a halftone screen as its mask, and a fixed
1542
+ * misregistration `offset` {x, y} via the CSS `translate` property (so it
1543
+ * stacks with `misregister`'s transforms). Static. Returns the tile URL.
1544
+ */
1545
+ function printInk(el, opts = {}) {
1546
+ const offset = opts.offset ?? { x: 0, y: 0 };
1547
+ if (!(Number.isFinite(offset.x) && Number.isFinite(offset.y)))
1548
+ throw new Error("printInk: offset must be { x, y } numbers");
1549
+ const { size, data } = halftoneTile(opts);
1550
+ const doc = opts.document ?? el.ownerDocument ?? root.document;
1551
+ const url = tileURL(doc, size, data, "printInk");
1552
+ const mask = `url(${url})`;
1553
+ el.style.mixBlendMode = "multiply";
1554
+ el.style.maskImage = mask;
1555
+ el.style.webkitMaskImage = mask;
1556
+ el.style.maskRepeat = "repeat";
1557
+ el.style.webkitMaskRepeat = "repeat";
1558
+ el.style.maskSize = `${size}px ${size}px`;
1559
+ el.style.webkitMaskSize = `${size}px ${size}px`;
1560
+ el.style.maskMode = "alpha";
1561
+ el.style.translate = `${offset.x}px ${offset.y}px`;
1562
+ return url;
1563
+ }
1564
+
1565
+ function misregisterOpts(count, opts) {
1566
+ if (!(Number.isInteger(count) && count >= 1))
1567
+ throw new Error("misregister: needs at least one layer");
1568
+ const distance = opts.distance ?? 26;
1569
+ if (!(distance >= 0)) throw new Error("misregister: distance must be >= 0");
1570
+ let from = opts.from;
1571
+ if (from !== undefined) {
1572
+ if (!Array.isArray(from) || from.length !== count)
1573
+ throw new Error("misregister: from must give one { x, y } per layer");
1574
+ if (!from.every((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)))
1575
+ throw new Error("misregister: from must give one { x, y } per layer");
1576
+ } else {
1577
+ // Seeded directions spread around the circle, so layers part ways.
1578
+ const next = rng(opts.seed ?? 11);
1579
+ const turn = next() * Math.PI * 2;
1580
+ from = Array.from({ length: count }, (_, i) => {
1581
+ const ang = turn + (i / count) * Math.PI * 2 + (next() - 0.5) * 0.6;
1582
+ const d = distance * (0.7 + next() * 0.3);
1583
+ return { x: Math.cos(ang) * d, y: Math.sin(ang) * d };
1584
+ });
1585
+ }
1586
+ const spec = opts.spring ?? (opts.ease ? null : "soft");
1587
+ const curve = spec ? springEase(spec) : easeOf(opts.ease);
1588
+ const duration = opts.duration ?? (spec ? settleTime(spec) : 0.8);
1589
+ if (!(duration > 0)) throw new Error("misregister: duration must be > 0");
1590
+ return { from, curve, duration, apart: opts.apart ?? false };
1591
+ }
1592
+
1593
+ /**
1594
+ * Offsets {x, y} of `count` layers t seconds into a misregister move: they
1595
+ * start at `from` and settle to 0 (or with `apart`, drift from 0 to `from`). Pure.
1596
+ */
1597
+ function misregisterAt(t, count, opts = {}) {
1598
+ const o = misregisterOpts(count, opts);
1599
+ const p = o.curve(clamp(t / o.duration, 0, 1));
1600
+ const k = o.apart ? p : 1 - p;
1601
+ return o.from.map((f) => ({ x: f.x * k + 0, y: f.y * k + 0 }));
1602
+ }
1603
+
1604
+ /**
1605
+ * Animates print registration of `layers` from `at`: apart by `distance`
1606
+ * (or explicit `from` offsets), settling together on a spring (default
1607
+ * "soft") or `ease` over `duration`. Returns the settle time.
1608
+ */
1609
+ function misregister(tl, layers, at, opts = {}) {
1610
+ const list = Array.from(layers ?? []);
1611
+ const o = misregisterOpts(list.length, opts);
1612
+ return drive(tl, at, o.duration, (t) => {
1613
+ misregisterAt(t, list.length, opts).forEach((p, i) => {
1614
+ list[i].style.transform = `translate(${p.x.toFixed(2)}px, ${p.y.toFixed(2)}px)`;
1615
+ });
1616
+ });
1617
+ }
1618
+
1619
+ /**
1620
+ * RGBA pixels of a seamless size x size paper tile: warm `tint` with soft
1621
+ * seeded mottling and short fibres, darkened by up to `amount`. Pure.
1622
+ */
1623
+ function paperTile(opts = {}) {
1624
+ const size = opts.size ?? 256;
1625
+ const amount = opts.amount ?? 0.06;
1626
+ if (!(Number.isInteger(size) && size >= 8 && size <= 1024))
1627
+ throw new Error("paperTexture: size must be an integer 8..1024");
1628
+ if (!(amount > 0 && amount <= 1)) throw new Error("paperTexture: amount must be 0..1");
1629
+ const tint = (() => {
1630
+ try {
1631
+ return parseColor(opts.tint ?? "#fdf8ee");
1632
+ } catch {
1633
+ throw new Error("paperTexture: tint must be hex like #fbf3e4");
1634
+ }
1635
+ })();
1636
+ const fibres = opts.fibres ?? 160;
1637
+ if (!(Number.isInteger(fibres) && fibres >= 0))
1638
+ throw new Error("paperTexture: fibres must be an integer >= 0");
1639
+ const seed = opts.seed ?? 5;
1640
+ const dark = new Float32Array(size * size);
1641
+ // Periodic mottling: noise sampled on a wrapped lattice.
1642
+ const cells = 8;
1643
+ const wrapNoise = (s, x, y, n) => {
1644
+ const xi = Math.floor(x);
1645
+ const yi = Math.floor(y);
1646
+ const f = (q) => q * q * (3 - 2 * q);
1647
+ const u = f(x - xi);
1648
+ const v = f(y - yi);
1649
+ const h = (i, j) => hash((((i % n) + n) % n) * 7919 + (((j % n) + n) % n), s);
1650
+ return lerp(lerp(h(xi, yi), h(xi + 1, yi), u), lerp(h(xi, yi + 1), h(xi + 1, yi + 1), u), v);
1651
+ };
1652
+ for (let y = 0; y < size; y++)
1653
+ for (let x = 0; x < size; x++) {
1654
+ const m =
1655
+ 0.6 * wrapNoise(seed, (x / size) * cells, (y / size) * cells, cells) +
1656
+ 0.4 * wrapNoise(seed + 1, (x / size) * cells * 4, (y / size) * cells * 4, cells * 4);
1657
+ dark[y * size + x] = 0.45 * m + 0.25 * hash(y * size + x, seed + 2);
1658
+ }
1659
+ const next = rng(seed + 3);
1660
+ for (let f = 0; f < fibres; f++) {
1661
+ let x = next() * size;
1662
+ let y = next() * size;
1663
+ let ang = next() * Math.PI * 2;
1664
+ const len = 6 + next() * 22;
1665
+ const w = (next() < 0.5 ? -0.5 : 0.7) * (0.4 + next() * 0.6);
1666
+ for (let s = 0; s < len; s++) {
1667
+ const i = ((Math.floor(y) % size) + size) % size;
1668
+ const j = ((Math.floor(x) % size) + size) % size;
1669
+ dark[i * size + j] += w;
1670
+ ang += (next() - 0.5) * 0.35;
1671
+ x += Math.cos(ang);
1672
+ y += Math.sin(ang);
1673
+ }
1674
+ }
1675
+ const out = new Uint8ClampedArray(size * size * 4);
1676
+ for (let i = 0; i < size * size; i++) {
1677
+ const k = 1 - amount * clamp(dark[i], 0, 1.5);
1678
+ out[i * 4] = tint[0] * k;
1679
+ out[i * 4 + 1] = tint[1] * k;
1680
+ out[i * 4 + 2] = tint[2] * k;
1681
+ out[i * 4 + 3] = 255;
1682
+ }
1683
+ return out;
1684
+ }
1685
+
1686
+ /**
1687
+ * Lays a fixed seeded paper tile over `el` (a full-frame overlay on top of
1688
+ * the inks) with multiply blending: warm stock, mottling and fibres.
1689
+ * Static: identical on every frame. Returns the tile's data URL.
1690
+ */
1691
+ function paperTexture(el, opts = {}) {
1692
+ const size = opts.size ?? 256;
1693
+ const data = paperTile(opts);
1694
+ const doc = opts.document ?? el.ownerDocument ?? root.document;
1695
+ const url = tileURL(doc, size, data, "paperTexture");
1696
+ el.style.backgroundImage = `url(${url})`;
1697
+ el.style.backgroundRepeat = "repeat";
1698
+ el.style.backgroundSize = `${size}px ${size}px`;
1699
+ el.style.mixBlendMode = "multiply";
1700
+ el.style.pointerEvents = "none";
1701
+ return url;
1702
+ }
1703
+
1704
+ // -------------------------------------------------------------- cursor
1705
+
1706
+ /**
1707
+ * Plans a cursor that travels on gentle arcs between stops, pressing where
1708
+ * `click` is set. Stops are { x, y } points or { el } elements (their
1709
+ * centre, in `space`), with optional `hold` (pause after arriving) and
1710
+ * `duration` (travel time). Pure; used by cursorPath.
1711
+ */
1712
+ function cursorPlan(stops, opts = {}) {
1713
+ const speed = opts.speed ?? 1500;
1714
+ const press = opts.press ?? 0.09;
1715
+ const release = opts.release ?? 0.26;
1716
+ const points = stops.map((s) =>
1717
+ s.el ? { ...centerOf(s.el, opts.space ?? s.el.offsetParent), ...s } : s,
1718
+ );
1719
+ const legs = [];
1720
+ const presses = [];
1721
+ let t = points[0].hold ?? 0;
1722
+ if (points[0].click) {
1723
+ presses.push(t);
1724
+ t += press + release;
1725
+ }
1726
+ for (let i = 1; i < points.length; i++) {
1727
+ const a = points[i - 1];
1728
+ const b = points[i];
1729
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
1730
+ const duration = b.duration ?? clamp(dist / speed, 0.35, 1.3);
1731
+ legs.push({ start: t, duration, a, b, bow: (opts.bow ?? 0.12) * (i % 2 ? 1 : -1) });
1732
+ t += duration;
1733
+ if (b.click) {
1734
+ presses.push(t + 0.06);
1735
+ t += 0.06 + press + release;
1736
+ }
1737
+ t += b.hold ?? 0.15;
1738
+ }
1739
+ return { points, legs, presses, press, release, duration: t };
1740
+ }
1741
+
1742
+ /** Cursor position and press scale at local time t. */
1743
+ function cursorAt(plan, t) {
1744
+ let pos = { x: plan.points[0].x, y: plan.points[0].y };
1745
+ for (const leg of plan.legs) {
1746
+ if (t < leg.start) break;
1747
+ const p = ease.travel(clamp((t - leg.start) / leg.duration, 0, 1));
1748
+ pos = arcPoint(leg.a, leg.b, leg.bow, p);
1749
+ }
1750
+ let scale = 1;
1751
+ for (const at of plan.presses) {
1752
+ const d = t - at;
1753
+ if (d < 0 || d > plan.press + plan.release) continue;
1754
+ scale =
1755
+ d < plan.press
1756
+ ? lerp(1, 0.8, d / plan.press)
1757
+ : lerp(0.8, 1, ease.bounce((d - plan.press) / plan.release));
1758
+ }
1759
+ return { x: pos.x, y: pos.y, scale };
1760
+ }
1761
+
1762
+ /**
1763
+ * Moves a cursor element (its hotspot at `tip`, default top-left + [6, 4])
1764
+ * through the stops from `at`. Returns { end, presses } in timeline time:
1765
+ * script each pressed state (and its sound) from the press times.
1766
+ */
1767
+ function cursorPath(tl, cursor, at, stops, opts = {}) {
1768
+ const plan = cursorPlan(stops, opts);
1769
+ const [tx, ty] = opts.tip ?? [6, 4];
1770
+ cursor.style.transformOrigin = `${tx}px ${ty}px`;
1771
+ const end = drive(tl, at, plan.duration, (t) => {
1772
+ const c = cursorAt(plan, t);
1773
+ cursor.style.left = `${c.x - tx}px`;
1774
+ cursor.style.top = `${c.y - ty}px`;
1775
+ cursor.style.transform = `scale(${c.scale})`;
1776
+ });
1777
+ return { end, presses: plan.presses.map((p) => at + p) };
1778
+ }
1779
+
1780
+ // ---------------------------------------------------------------- sound cues
1781
+
1782
+ // Sound comes from the picture: mark the moments that deserve a sound where
1783
+ // the animation creates them, and `cues.mjs` exports them for the score.
1784
+ const cueMarks = [];
1785
+ const airSources = [];
1786
+ let cueFlushQueued = false;
1787
+ const AIR_RATE = 50;
1788
+
1789
+ /**
1790
+ * How fast the keyed camera move carries the picture past the viewer, in
1791
+ * screen px/s: the pan at the current zoom plus the zoom's push at the frame
1792
+ * edge. Hand float and shakes are left out; the air follows the operated move.
1793
+ */
1794
+ function cameraSpeed(plan, t, width = 1920, height = 1080) {
1795
+ const dt = 1 / (AIR_RATE * 2);
1796
+ const a = posePlanned(plan, Math.max(0, t - dt));
1797
+ const b = posePlanned(plan, t + dt);
1798
+ const span = 2 * dt;
1799
+ const zoom = (a.zoom + b.zoom) / 2;
1800
+ const pan = (Math.hypot(b.x - a.x, b.y - a.y) * zoom) / span;
1801
+ const push = (Math.abs(Math.log(b.zoom / a.zoom)) * Math.hypot(width, height)) / 2 / span;
1802
+ return Math.hypot(pan, push);
1803
+ }
1804
+
1805
+ /** The camera's speed curve on the root timeline, or null without a camera. */
1806
+ function air() {
1807
+ if (!airSources.length) return null;
1808
+ const spans = airSources.map((s) => ({ ...s, start: rootTime(s.tl, 0) }));
1809
+ const start = Math.min(...spans.map((s) => s.start));
1810
+ const end = Math.max(...spans.map((s) => s.start + s.duration));
1811
+ const values = [];
1812
+ for (let i = 0; i <= Math.ceil((end - start) * AIR_RATE); i++) {
1813
+ const t = start + i / AIR_RATE;
1814
+ let v = 0;
1815
+ for (const s of spans) {
1816
+ if (t >= s.start && t <= s.start + s.duration)
1817
+ v = Math.max(v, cameraSpeed(s.plan, t - s.start, s.width, s.height));
1818
+ }
1819
+ values.push(Math.round(v));
1820
+ }
1821
+ return { rate: AIR_RATE, start: Math.round(start * 10000) / 10000, values };
1822
+ }
1823
+
1824
+ /** Time `at` on `tl`, mapped onto the root timeline through any nesting. */
1825
+ function rootTime(tl, at) {
1826
+ let t = at;
1827
+ for (let node = tl; node && node.parent && node.parent.parent; node = node.parent) {
1828
+ t = node.startTime() + t / node.timeScale();
1829
+ }
1830
+ return t;
1831
+ }
1832
+
1833
+ /** Every cue so far, in root-timeline seconds, sorted by time. */
1834
+ function cues() {
1835
+ return cueMarks
1836
+ .map(({ tl, at, sfx, gain, pan, send, name }) => ({
1837
+ t: Math.round(rootTime(tl, at) * 10000) / 10000,
1838
+ sfx,
1839
+ gain,
1840
+ pan,
1841
+ ...(send === undefined ? {} : { send }),
1842
+ ...(name ? { name } : {}),
1843
+ }))
1844
+ .sort((a, b) => a.t - b.t || (a.sfx < b.sfx ? -1 : a.sfx > b.sfx ? 1 : 0));
1845
+ }
1846
+
1847
+ function flushCues() {
1848
+ cueFlushQueued = false;
1849
+ const doc = root.document;
1850
+ if (!doc || !doc.documentElement) return;
1851
+ if (cueMarks.length) doc.documentElement.setAttribute("data-ez-cues", JSON.stringify(cues()));
1852
+ const curve = air();
1853
+ if (curve) doc.documentElement.setAttribute("data-ez-air", JSON.stringify(curve));
1854
+ }
1855
+
1856
+ // Written once the composition script has finished nesting its timelines.
1857
+ function queueFlush() {
1858
+ if (!cueFlushQueued && typeof root.queueMicrotask === "function") {
1859
+ cueFlushQueued = true;
1860
+ root.queueMicrotask(flushCues);
1861
+ }
1862
+ }
1863
+
1864
+ /**
1865
+ * Mark a sound at time `at` of `tl` ("click", "impact", ...; the score's
1866
+ * sound names). Adds no tween, so seeking is unaffected. Returns `at`.
1867
+ * Options: `gain` 0..2; `pan` -1..1, or `x` (screen px; pans with the
1868
+ * picture); `send` 0..1, how far away it sounds (default: the sound's own).
1869
+ */
1870
+ function cue(tl, at, sfx, opts = {}) {
1871
+ if (!(Number.isFinite(at) && at >= 0)) throw new Error(`cue time must be >= 0, got ${at}`);
1872
+ if (typeof sfx !== "string" || !/^[a-z][a-z-]*$/.test(sfx))
1873
+ throw new Error(`cue sound must be a sound name, got ${sfx}`);
1874
+ const gain = opts.gain ?? 1;
1875
+ if (opts.x !== undefined && !Number.isFinite(opts.x)) throw new Error("cue x must be a number");
1876
+ const width = opts.width ?? 1920;
1877
+ const fromX =
1878
+ opts.x === undefined ? 0 : Math.max(-1, Math.min(1, (opts.x / width) * 2 - 1)) * 0.7;
1879
+ const pan = opts.pan ?? Math.round(fromX * 1000) / 1000;
1880
+ if (!(gain >= 0 && gain <= 2)) throw new Error("cue gain must be 0..2");
1881
+ if (!(pan >= -1 && pan <= 1)) throw new Error("cue pan must be -1..1");
1882
+ if (opts.send !== undefined && !(opts.send >= 0 && opts.send <= 1))
1883
+ throw new Error("cue send must be 0..1");
1884
+ cueMarks.push({
1885
+ tl,
1886
+ at,
1887
+ sfx,
1888
+ gain,
1889
+ pan,
1890
+ send: opts.send,
1891
+ name: opts.name ? String(opts.name).slice(0, 60) : "",
1892
+ });
1893
+ queueFlush();
1894
+ return at;
1895
+ }
1896
+
1897
+ root.GGMotionKit = {
1898
+ version: 1,
1899
+ // timing
1900
+ springs,
1901
+ spring,
1902
+ settleTime,
1903
+ springEase,
1904
+ ring,
1905
+ ease,
1906
+ rng,
1907
+ noise,
1908
+ // elements
1909
+ centerOf,
1910
+ rectOf,
1911
+ split,
1912
+ drive,
1913
+ // arrivals
1914
+ liftWords,
1915
+ dropLetters,
1916
+ reveal,
1917
+ typePlan,
1918
+ typewrite,
1919
+ tick,
1920
+ pop,
1921
+ stamp,
1922
+ // handoffs
1923
+ reshape,
1924
+ coverRadius,
1925
+ openFrom,
1926
+ diveThrough,
1927
+ arcPoint,
1928
+ converge,
1929
+ // contact
1930
+ hopAt,
1931
+ hop,
1932
+ impact,
1933
+ knock,
1934
+ // camera
1935
+ cameraPlan,
1936
+ cameraAt,
1937
+ restEnvelope,
1938
+ handheld,
1939
+ shakeAt,
1940
+ atDepth,
1941
+ toScreen,
1942
+ camera,
1943
+ cameraSpeed,
1944
+ // deep zoom
1945
+ deepZoomAt,
1946
+ deepZoom,
1947
+ // light
1948
+ lightLevel,
1949
+ lightGround,
1950
+ ringsAt,
1951
+ rings,
1952
+ // fields
1953
+ silkField,
1954
+ silk,
1955
+ grainTile,
1956
+ grain,
1957
+ // print
1958
+ halftoneTile,
1959
+ printInk,
1960
+ misregisterAt,
1961
+ misregister,
1962
+ paperTile,
1963
+ paperTexture,
1964
+ // belt
1965
+ beltAt,
1966
+ belt,
1967
+ // brush
1968
+ pathPoints,
1969
+ brushPlan,
1970
+ brushWidth,
1971
+ brushStroke,
1972
+ // cursor
1973
+ cursorPlan,
1974
+ cursorAt,
1975
+ cursorPath,
1976
+ // sound
1977
+ cue,
1978
+ cues,
1979
+ air,
1980
+ };
1981
+ })(typeof window !== "undefined" ? window : globalThis);