@zakkster/lite-scratch-fx 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] - 2026-08-23
4
+
5
+ Reduced-motion support (N-4). Backward compatible: the new controller/stage option
6
+ defaults to `false`, the helper and metadata field are additive, and existing callers
7
+ are byte-unaffected. The per-frame render path is unchanged.
8
+
9
+ ### Added
10
+ - **`reducedMotion` option on `createScratchController` and `stage.createController`
11
+ (default `false`).** When `true`, every `reveal(recipe)` ignores the passed recipe and
12
+ runs an internal `count: 0` opacity fade instead -- zero particles, no motion beyond the
13
+ fade, `onDone` still fires once on completion. The library does not read
14
+ `prefers-reduced-motion` itself; the host passes the result of its own media query. The
15
+ fallback recipe is built once per controller (cold path) and reused across reveals; it is
16
+ stateless, so it is re-entrant across successive reveals on the same controller.
17
+ - **`reducedMotionRecipe(recipe, { scale = 0.25, duration } = {})`.** A pure wrapper that
18
+ keeps a recipe's look but spawns `Math.round(recipe.count * scale)` particles (a quarter
19
+ by default). It delegates `init`/`spawn`/`tick`/`destroy` to the inner recipe and reuses
20
+ its arrays, so it adds no hot-path allocation. `duration`, if given, caps the reveal early
21
+ (`elapsedMs >= duration`); it does not re-time the inner easing curve. It reduces particle
22
+ count only and cannot suppress a recipe's own per-particle glow, which is drawn inside the
23
+ inner `tick`. Throws `TypeError` on a non-recipe argument or a non-finite/negative `scale`.
24
+ - **`RECIPE_META[*].motionSafe` (boolean).** Marks inherently calm built-ins so a picker can
25
+ prefer them under reduced motion. Strict: `true` for `fade` only (a pure opacity fade);
26
+ every recipe that moves geometry -- particles, the peel wipe, the implosion -- is `false`.
27
+ Threaded through `registerRecipe` with the same prev-fallback rule as the other flags.
28
+
29
+ ### Changed
30
+ - **Torture gate extended.** Two tiers added to `test/torture.mjs`: T6 gates the blanket
31
+ `reducedMotion` reveal/tick/complete cycle and T7 gates a `reducedMotionRecipe`-wrapped
32
+ steady state, both under the existing 64 B/op ceiling (measured 41.6 and 6.9 B/op; their
33
+ broken-variant controls allocate ~1193 and ~1149 B/op). Test count 283 -> 322.
34
+
35
+ ## [1.5.1] - 2026-08-23
36
+
37
+ Cover-fade fidelity patch for `iceBreath`. Backward compatible; no API change.
38
+
39
+ ### Fixed
40
+ - **`iceBreath` brightness ramp is now eased, matching the game's tween (B-21).** The
41
+ `brightness()` filter followed a linear `1 + fadeT` while the opacity one line above
42
+ followed the cubic `1 - fadeT^3`. GSAP applies a tween's ease to its numeric filter
43
+ target too, so both should share the cubic `power2.in` curve. The tick now computes
44
+ `ez = easeIn(fadeT)` once and drives opacity as `1 - ez` and brightness as `1 + ez`;
45
+ binding both writes to the same value removes the drift that caused the bug. Observable
46
+ change: at the fade midpoint brightness is `1.125`, not `1.5` (the cover no longer blows
47
+ out to near-white by mid-reveal). No allocation added; the torture gate is unchanged.
48
+
49
+ ### Changed
50
+ - **Docs: `iceBreath`/`dragonBreath` `spread` is a jet half-angle (B-22).** The catalog
51
+ card now states `spread` is a half-angle in radians, matching `README.md` and `llms.txt`.
52
+ `IceBreathRecipe({ spread: FX_CONFIG.iceBreath.spread })` doubles the cone; pass
53
+ `spread / 2`. No code change.
54
+
3
55
  ## [1.5.0] - 2026-08-23
4
56
 
5
57
  Cover-fade fidelity, live image reveals, and additive recipe/stage knobs. Backward
package/README.md CHANGED
@@ -58,6 +58,7 @@ time.
58
58
  - [createScratchController](#createscratchcontroller)
59
59
  - [createScratchStage](#createscratchstage)
60
60
  - [Registry and palette helpers](#registry-and-palette-helpers)
61
+ - [Reduced motion](#reduced-motion)
61
62
  - [Constants](#constants)
62
63
  - [Composability](#composability)
63
64
  - [Zero-GC design notes](#zero-gc-design-notes)
@@ -270,11 +271,39 @@ current built-in look:
270
271
  - **`PeelRecipe`** / **`ShineWaveRecipe`** -- `ease` (`(t: number) => number`): the easing
271
272
  applied to progress; defaults to the recipe's built-in curve (`easeIn` / `easeInOut`).
272
273
 
274
+ ### Reduced motion
275
+
276
+ Reveals run in lottery / instant-win UIs, so honouring `prefers-reduced-motion` matters.
277
+ The library does not read the media query itself -- the host owns it -- but it gives you
278
+ two ways to respond, plus a flag for building a picker.
279
+
280
+ - **Blanket switch: `{ reducedMotion: true }`** on `createScratchController` or
281
+ `stage.createController`. Every `reveal(recipe)` then ignores the recipe it is handed and
282
+ runs a calm `count: 0` opacity fade instead -- zero particles, no spray, `onDone` still
283
+ fires once on completion. This is the honest "motion off" path.
284
+
285
+ ```js
286
+ const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
287
+ const ctrl = createScratchController(cover, fx, { reducedMotion: calm });
288
+ ctrl.reveal(RECIPES.dragonBreath()); // draws a plain fade when calm is true
289
+ ```
290
+
291
+ - **Toned-down variant: `reducedMotionRecipe(recipe, { scale = 0.25, duration? })`**. Wraps
292
+ any recipe and keeps its look but spawns `round(count * scale)` particles (a quarter, by
293
+ default); an optional `duration` caps the reveal early. It reuses the inner recipe's
294
+ arrays, so it adds no hot-path allocation. Note it can only reduce particle *count* -- it
295
+ cannot suppress a recipe's own per-particle glow, which is drawn inside the recipe's
296
+ `tick`. For a full "motion off", use the blanket switch above.
297
+
298
+ - **`RECIPE_META[*].motionSafe`** marks the inherently calm built-ins so a picker can prefer
299
+ them. It is strict: only `fade` (a pure opacity fade) is `motionSafe: true`; every recipe
300
+ that moves geometry -- particles, a peel wipe, an implosion -- is `false`.
301
+
273
302
  ### Constants
274
303
 
275
304
  | Constant | Value | Meaning |
276
305
  | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
277
- | `VERSION` | `'1.5.0'` | Package version string (synced to package.json). |
306
+ | `VERSION` | `'1.6.0'` | Package version string (synced to package.json). |
278
307
  | `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
279
308
  | `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
280
309
  | stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
package/index.d.ts CHANGED
@@ -108,6 +108,12 @@ export interface ScratchControllerOptions {
108
108
  * window.devicePixelRatio. Recipes still author in CSS pixels.
109
109
  */
110
110
  dpr?: number;
111
+ /**
112
+ * Honour prefers-reduced-motion. When true, every reveal runs a calm count:0
113
+ * opacity fade instead of the requested recipe (motion off, not a wrapped recipe).
114
+ * Default false. The host owns the media query and passes the result.
115
+ */
116
+ reducedMotion?: boolean;
111
117
  }
112
118
 
113
119
  export interface ScratchController {
@@ -170,6 +176,11 @@ export interface StageControllerOptions {
170
176
  * dpr). Pass window.devicePixelRatio. Recipes still author in CSS pixels.
171
177
  */
172
178
  dpr?: number;
179
+ /**
180
+ * Honour prefers-reduced-motion. When true, every reveal runs a calm count:0
181
+ * opacity fade instead of the requested recipe. Default false. The host owns the query.
182
+ */
183
+ reducedMotion?: boolean;
173
184
  }
174
185
 
175
186
  /** A stage-managed controller. The stage drives it, so it has no `tick` of its own. */
@@ -244,6 +255,18 @@ export function PeelRecipe(opts?: RecipeOptions & { ease?: (t: number) => number
244
255
  export function FadeRecipe(opts?: RecipeOptions): Recipe;
245
256
  export function ImplosionRecipe(opts?: RecipeOptions): Recipe;
246
257
 
258
+ /**
259
+ * Dial a recipe down for reduced motion: a pure delegating wrapper that reduces the
260
+ * inner recipe's particle COUNT (default a quarter) and, optionally, caps the reveal
261
+ * early via `duration`. Adds zero hot-path allocation (reuses the inner arrays). It
262
+ * cannot suppress the inner recipe's per-particle painted halo -- that lives inside
263
+ * its tick; `duration` caps the reveal early, it does not re-time the inner curve.
264
+ */
265
+ export function reducedMotionRecipe(
266
+ recipe: Recipe,
267
+ opts?: { scale?: number; duration?: number },
268
+ ): Recipe;
269
+
247
270
  export function GlitchRevealRecipe(opts?: RecipeOptions): Recipe;
248
271
  export function MatrixDecayRecipe(opts?: ThemeableRecipeOptions): Recipe;
249
272
  export function GoldDustRecipe(opts?: ThemeableRecipeOptions): Recipe;
@@ -282,6 +305,8 @@ export interface RecipeMeta {
282
305
  category: RecipeCategory;
283
306
  themeable: boolean;
284
307
  needsUntaintedCanvas: boolean;
308
+ /** Inherently-calm reveal a picker can prefer under prefers-reduced-motion; strict = pure-opacity fade only. */
309
+ motionSafe: boolean;
285
310
  }
286
311
 
287
312
  /** Live metadata array for every recipe; `registerRecipe` keeps it in sync. */
package/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  // Three-place version sync: this constant, package.json "version", and the top
15
15
  // CHANGELOG.md heading must always match. /release keeps them locked.
16
- export const VERSION = '1.5.0';
16
+ export const VERSION = '1.6.0';
17
17
 
18
18
  export { createScratchController } from './src/ScratchController.js';
19
19
  export { default as ScratchController } from './src/ScratchController.js';
@@ -27,6 +27,7 @@ export {
27
27
  ShatterRecipe, PixelShatterRecipe, GlitchRevealRecipe,
28
28
  ShineRecipe, ShineWaveRecipe, LaserScanRecipe, LightningCrawlRecipe, NeonPulseRecipe,
29
29
  PeelRecipe, FadeRecipe, ImplosionRecipe,
30
+ reducedMotionRecipe,
30
31
  } from './src/ScratchRecipes.js';
31
32
 
32
33
  import {
@@ -76,29 +77,31 @@ export const RECIPES = Object.assign(Object.create(null), {
76
77
  * back. The flag is retained so a third-party recipe that
77
78
  * does read the canvas (getImageData/toDataURL) can
78
79
  * advertise a same-origin requirement to pickers.
80
+ * motionSafe inherently-calm reveal a picker can prefer under
81
+ * prefers-reduced-motion; strict = pure-opacity fade only.
79
82
  */
80
83
  export const RECIPE_META = [
81
- { id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false },
82
- { id: 'dissolve', name: 'Dissolve', category: 'particle', themeable: true, needsUntaintedCanvas: false },
83
- { id: 'explode', name: 'Explode', category: 'particle', themeable: true, needsUntaintedCanvas: false },
84
- { id: 'dragonBreath', name: 'Dragon Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
85
- { id: 'iceBreath', name: 'Ice Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
86
- { id: 'goldDust', name: 'Gold Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
87
- { id: 'confettiBlast', name: 'Confetti Blast', category: 'particle', themeable: true, needsUntaintedCanvas: false },
88
- { id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
89
- { id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false },
90
- { id: 'liquidMelt', name: 'Liquid Melt', category: 'particle', themeable: true, needsUntaintedCanvas: false },
91
- { id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false },
92
- { id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false },
93
- { id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas: false },
94
- { id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false },
95
- { id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false },
96
- { id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false },
97
- { id: 'lightningCrawl', name: 'Lightning Crawl', category: 'beam', themeable: true, needsUntaintedCanvas: false },
98
- { id: 'neonPulse', name: 'Neon Pulse', category: 'beam', themeable: true, needsUntaintedCanvas: false },
99
- { id: 'peel', name: 'Peel', category: 'css', themeable: false, needsUntaintedCanvas: false },
100
- { id: 'fade', name: 'Fade', category: 'css', themeable: false, needsUntaintedCanvas: false },
101
- { id: 'implosion', name: 'Implosion', category: 'css', themeable: false, needsUntaintedCanvas: false },
84
+ { id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
85
+ { id: 'dissolve', name: 'Dissolve', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
86
+ { id: 'explode', name: 'Explode', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
87
+ { id: 'dragonBreath', name: 'Dragon Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
88
+ { id: 'iceBreath', name: 'Ice Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
89
+ { id: 'goldDust', name: 'Gold Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
90
+ { id: 'confettiBlast', name: 'Confetti Blast', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
91
+ { id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
92
+ { id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
93
+ { id: 'liquidMelt', name: 'Liquid Melt', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
94
+ { id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
95
+ { id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
96
+ { id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
97
+ { id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
98
+ { id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
99
+ { id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
100
+ { id: 'lightningCrawl', name: 'Lightning Crawl', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
101
+ { id: 'neonPulse', name: 'Neon Pulse', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
102
+ { id: 'peel', name: 'Peel', category: 'css', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
103
+ { id: 'fade', name: 'Fade', category: 'css', themeable: false, needsUntaintedCanvas: false, motionSafe: true },
104
+ { id: 'implosion', name: 'Implosion', category: 'css', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
102
105
  ];
103
106
 
104
107
  /** Names of every built-in recipe (the keys of RECIPES at load time). */
@@ -111,7 +114,7 @@ export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
111
114
  *
112
115
  * @param {string} id short name (the RECIPES key)
113
116
  * @param {Function} factory a recipe factory: (opts) => Recipe
114
- * @param {{ name?: string, category?: string, themeable?: boolean, needsUntaintedCanvas?: boolean }} [meta]
117
+ * @param {{ name?: string, category?: string, themeable?: boolean, needsUntaintedCanvas?: boolean, motionSafe?: boolean }} [meta]
115
118
  * Omitted fields fall back to the existing entry (when overriding), then to a
116
119
  * de-camelCased name, category 'custom', and false flags.
117
120
  * @returns {Function} the registered factory
@@ -135,6 +138,7 @@ export function registerRecipe(id, factory, meta) {
135
138
  themeable: meta && 'themeable' in meta ? !!meta.themeable : (prev ? prev.themeable : false),
136
139
  needsUntaintedCanvas: meta && 'needsUntaintedCanvas' in meta
137
140
  ? !!meta.needsUntaintedCanvas : (prev ? prev.needsUntaintedCanvas : false),
141
+ motionSafe: meta && 'motionSafe' in meta ? !!meta.motionSafe : (prev ? prev.motionSafe : false),
138
142
  };
139
143
  if (idx >= 0) RECIPE_META[idx] = entry; else RECIPE_META.push(entry);
140
144
  return factory;
package/llms.txt CHANGED
@@ -8,7 +8,8 @@
8
8
  ## Core model
9
9
 
10
10
  createScratchController(sourceCanvas, effectCanvas,
11
- { maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine, dpr = 1 })
11
+ { maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine, dpr = 1,
12
+ reducedMotion = false })
12
13
  -> { reveal(recipe, onDone?), tick(dt), cancel(), seed(s), destroy() }
13
14
 
14
15
  sourceCanvas = the scratch layer (what the user scratches off).
@@ -85,12 +86,14 @@ falls back to the recipe's default curve.
85
86
  VERSION: the package version string (kept in sync with package.json and CHANGELOG).
86
87
  RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ...).
87
88
  RECIPE_NAMES: the built-in keys at load time.
88
- RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for every
89
- recipe -- the source for building pickers without hardcoding. category is
89
+ RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas, motionSafe } for
90
+ every recipe -- the source for building pickers without hardcoding. category is
90
91
  'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is false for every built-in (image recipes
91
92
  draw the layer live via drawImage(src), never reading it back); the flag is retained so a
92
93
  third-party recipe that reads the canvas (getImageData/toDataURL) can advertise a same-origin
93
- requirement to pickers. themeable is true for the 14 recipes that take { colors, theme }.
94
+ requirement to pickers. themeable is true for the 14 recipes that take { colors, theme }. motionSafe
95
+ is an inherently-calm reveal a picker can prefer under prefers-reduced-motion; strict = true for
96
+ 'fade' ONLY (pure-opacity cross-fade), false for every other built-in (all animate or paint).
94
97
  registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
95
98
  RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
96
99
  Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
@@ -115,7 +118,7 @@ modes exist now:
115
118
  ## Concurrent reveals over one pool: createScratchStage
116
119
 
117
120
  createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
118
- -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr? }) -> stageController,
121
+ -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr?, reducedMotion = false }) -> stageController,
119
122
  tick(dt), destroy(), get remainingCapacity, remainingCapacityFor(capacity) }
120
123
  stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
121
124
 
@@ -130,6 +133,23 @@ later createController; cancel/completion hold the range (the controller stays r
130
133
  Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
131
134
  lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
132
135
 
136
+ ## Reduced motion
137
+
138
+ The host owns the media query; the library never touches matchMedia (DOM-query-free, SSR-safe).
139
+ Read prefers-reduced-motion and pass the boolean in. Two shapes:
140
+ Blanket switch: createScratchController(src, fx, { reducedMotion }) (and stage.createController).
141
+ When true, EVERY reveal honestly REPLACES the requested recipe with a calm count:0 opacity fade
142
+ (~300ms, motion off -- not a wrapped recipe): zero particles spawn, no halo painted. The calm
143
+ fallback is built once per controller (cold) -> no per-reveal garbage. Default false = full motion.
144
+ Helper: reducedMotionRecipe(recipe, { scale = 0.25, duration }) -> Recipe. A pure delegating wrapper:
145
+ count becomes round(inner.count * scale) (fewer particles; 0 stays 0), init/spawn/tick/destroy
146
+ delegate verbatim -> zero added hot-path alloc. Reduces particle COUNT only -- it CANNOT suppress
147
+ the inner recipe's per-particle painted halo (that lives in the inner tick; deferred). duration is
148
+ an early-complete CAP (tick returns innerDone || elapsedMs >= duration), NOT a re-timing of the curve.
149
+ Throws TypeError if recipe has no tick function or scale is not a finite number >= 0.
150
+ RECIPE_META.motionSafe lets a picker prefer 'fade' (the only strict motion-safe reveal) under the query.
151
+ See decisions/0003-reduced-motion.md.
152
+
133
153
  ## Fixed in 1.0.0 (was broken pre-package)
134
154
 
135
155
  - Reveals never completed (double-driven engine loop -> corrupted dt). Fixed: engine self-drives.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-scratch-fx",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "1.5.0",
4
+ "version": "1.6.0",
5
5
  "description": "One-shot scratch-card reveal effects on canvas. A controller scans the still-covered pixels of a scratch layer, spawns particles from them, and delegates physics and rendering to a recipe. 21 themeable recipes (burn, shatter, dissolve, glitch, gold dust, cosmic dust, and more), a registry with a register hook, zero GSAP, a zero-GC hot path, deterministic seeded RNG, and a stage that runs a grid of simultaneous reveals over one shared pool.",
6
6
  "type": "module",
7
7
  "main": "./index.js",
@@ -44,6 +44,7 @@ import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
44
44
  import { Random } from '@zakkster/lite-random';
45
45
  import { createPixelScanner } from './PixelScan.js';
46
46
  import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
47
+ import { reducedMotionFallbackRecipe } from './ScratchRecipes.js';
47
48
 
48
49
  // Shared engines currently driving a reveal. A shared engine has one render slot and one
49
50
  // particle pool, so only one controller may reveal on it at a time; a second reveal is
@@ -62,6 +63,9 @@ const busySharedEngines = new WeakSet();
62
63
  * @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
63
64
  * @param {SoaParticleEngine} [options.engine] Share a caller-supplied engine + lane pool
64
65
  * @param {number} [options.dpr=1] Device-pixel-ratio for sharp high-DPI output
66
+ * @param {boolean} [options.reducedMotion=false] Honour prefers-reduced-motion: every reveal
67
+ * runs a calm count:0 opacity fade instead of
68
+ * the requested recipe (motion off, not wrapped)
65
69
  */
66
70
  export function createScratchController(sourceCanvas, effectCanvas, {
67
71
  maxParticles = 2000,
@@ -70,6 +74,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
70
74
  driven = false,
71
75
  engine: externalEngine = null,
72
76
  dpr = 1,
77
+ reducedMotion = false,
73
78
  } = {}) {
74
79
  if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
75
80
  throw new TypeError('createScratchController: dpr must be a positive finite number');
@@ -98,6 +103,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
98
103
  const scanner = createPixelScanner(sourceCanvas);
99
104
  const spot = { x: 0.5, y: 0.5 }; // the single shared, reused spawn point
100
105
 
106
+ // Reduced-motion blanket fallback: built ONCE here (cold) when the option is
107
+ // set, reused across every reveal so no per-reveal garbage. Stateless -- the
108
+ // controller tracks elapsed and the recipe only writes src.style.opacity.
109
+ const reducedFallback = reducedMotion ? reducedMotionFallbackRecipe() : null;
110
+
101
111
  // The particle-view object handed to recipe.tick every frame. Built ONCE and
102
112
  // reused: the engine's SoA arrays are stable references for the life of the engine,
103
113
  // so a fresh `{ x, y, ... }` literal per frame would be a pure zero-GC violation on
@@ -167,6 +177,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
167
177
  if (destroyed || activeRecipe) return;
168
178
  if (!ownEngine && busySharedEngines.has(engine)) return;
169
179
 
180
+ // Reduced-motion blanket: honestly REPLACE the requested recipe with the
181
+ // calm count:0 opacity fade (never a wrap). Cold path, once per reveal;
182
+ // the count read + spawn loop below run against the swapped-in recipe.
183
+ if (reducedMotion) recipe = reducedFallback;
184
+
170
185
  activeRecipe = recipe;
171
186
  onComplete = onDone || null;
172
187
  elapsed = 0;
@@ -275,8 +275,9 @@ export function IceBreathRecipe({count = 450, duration = 1500, spread = 0.225, s
275
275
  const dv = Math.pow(0.96, ds);
276
276
  let alive = 0;
277
277
  const fadeT = clamp((elapsed - 100) / 500, 0, 1);
278
- src.style.opacity = 1 - easeIn(fadeT);
279
- src.style.filter = `brightness(${1 + fadeT})`;
278
+ const ez = easeIn(fadeT);
279
+ src.style.opacity = 1 - ez;
280
+ src.style.filter = `brightness(${1 + ez})`;
280
281
 
281
282
  for (let i = 0; i < max; i++) {
282
283
  if (life[i] <= 0) continue;
@@ -1237,6 +1238,88 @@ export function ImplosionRecipe({duration = 800} = {}) {
1237
1238
  }
1238
1239
 
1239
1240
 
1241
+ // ===========================================================================
1242
+ // REDUCED MOTION
1243
+ // ===========================================================================
1244
+
1245
+ // Fixed fade duration for the reduced-motion blanket fallback (ms). A short,
1246
+ // calm pure-opacity dissolve -- honest "motion off", not a wrapped recipe.
1247
+ const REDUCED_MOTION_FADE_MS = 300;
1248
+
1249
+ /**
1250
+ * The calm fallback a controller/stage swaps in when created with
1251
+ * `{ reducedMotion: true }`. A count:0 recipe: zero particles spawn, and the
1252
+ * scratch layer fades its opacity 1 -> 0 over a short fixed duration. Shared by
1253
+ * ScratchController and ScratchStage so the calm body lives in exactly one place.
1254
+ * Satisfies the full recipe contract (init/spawn/tick/destroy).
1255
+ * @returns {Object} a fresh reduced-motion fallback recipe
1256
+ */
1257
+ export function reducedMotionFallbackRecipe() {
1258
+ return {
1259
+ count: 0,
1260
+ init() {
1261
+ },
1262
+ // count:0 means the controller never calls spawn; kept for contract safety.
1263
+ spawn() {
1264
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
1265
+ },
1266
+ tick(dt, elapsedMs, engine, ctx, src) {
1267
+ src.style.opacity = 1 - clamp(elapsedMs / REDUCED_MOTION_FADE_MS, 0, 1);
1268
+ return elapsedMs >= REDUCED_MOTION_FADE_MS;
1269
+ },
1270
+ destroy() {
1271
+ },
1272
+ };
1273
+ }
1274
+
1275
+ /**
1276
+ * A pure delegating wrapper that dials an existing recipe down for reduced
1277
+ * motion by reducing its particle COUNT (default a quarter) and, optionally,
1278
+ * capping the reveal early. It reuses the inner recipe's arrays and delegates
1279
+ * init/spawn/tick/destroy verbatim, so it adds ZERO hot-path allocation.
1280
+ *
1281
+ * Honest limits, on purpose:
1282
+ * - It reduces particle COUNT only. It CANNOT suppress the per-particle painted
1283
+ * halo/glow -- that lives inside the inner recipe's own tick. Suppressing it
1284
+ * needs a recipe-interface change and is deferred.
1285
+ * - `duration` is an early-complete CAP, not a re-timing. The inner curve's own
1286
+ * duration is baked into its closure and cannot be shortened from outside, so
1287
+ * tick returns `innerDone || (duration != null && elapsedMs >= duration)`.
1288
+ * The reveal ends sooner; the inner animation is not re-scaled onto it.
1289
+ *
1290
+ * @param {Object} recipe the inner recipe to dial down
1291
+ * @param {{ scale?: number, duration?: number }} [opts]
1292
+ * scale particle-count multiplier (default 0.25); finite, >= 0
1293
+ * duration optional early-complete cap in ms
1294
+ * @returns {Object} the wrapped recipe
1295
+ */
1296
+ export function reducedMotionRecipe(recipe, {scale = 0.25, duration} = {}) {
1297
+ if (!recipe || typeof recipe !== 'object' || typeof recipe.tick !== 'function') {
1298
+ throw new TypeError('reducedMotionRecipe: recipe must be an object with a tick function');
1299
+ }
1300
+ if (!(typeof scale === 'number' && Number.isFinite(scale) && scale >= 0)) {
1301
+ throw new TypeError('reducedMotionRecipe: scale must be a finite number >= 0');
1302
+ }
1303
+ return {
1304
+ // Fewer particles. A count:0 inner recipe stays 0.
1305
+ count: Math.round((recipe.count || 0) * scale),
1306
+ init(ctx, capacity, w, h, rng) {
1307
+ return recipe.init(ctx, capacity, w, h, rng);
1308
+ },
1309
+ spawn(idx, rng, w, h, spot) {
1310
+ return recipe.spawn(idx, rng, w, h, spot);
1311
+ },
1312
+ tick(dt, elapsedMs, engine, ctx, src, w, h) {
1313
+ const innerDone = recipe.tick(dt, elapsedMs, engine, ctx, src, w, h);
1314
+ return innerDone || (duration != null && elapsedMs >= duration);
1315
+ },
1316
+ destroy() {
1317
+ if (recipe.destroy) recipe.destroy();
1318
+ },
1319
+ };
1320
+ }
1321
+
1322
+
1240
1323
  // ===========================================================================
1241
1324
  // RECIPE MAP
1242
1325
  // ===========================================================================
@@ -24,6 +24,7 @@ import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
24
24
  import { Random } from '@zakkster/lite-random';
25
25
  import { createPixelScanner } from './PixelScan.js';
26
26
  import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
27
+ import { reducedMotionFallbackRecipe } from './ScratchRecipes.js';
27
28
 
28
29
  /**
29
30
  * @param {Object} [options]
@@ -86,8 +87,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
86
87
  * @param {number} [opts.seed] RNG seed; defaults to base seed + index.
87
88
  * @param {number} [opts.scanPrecision=32] Pixel-scan resolution.
88
89
  * @param {number} [opts.dpr] Per-controller device-pixel-ratio (default: stage dpr).
90
+ * @param {boolean} [opts.reducedMotion=false] Honour prefers-reduced-motion: every reveal runs
91
+ * a calm count:0 opacity fade instead of the recipe.
89
92
  */
90
- function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32, dpr = stageDpr } = {}) {
93
+ function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32, dpr = stageDpr, reducedMotion = false } = {}) {
91
94
  if (destroyed) throw new Error('createScratchStage: stage is destroyed');
92
95
  if (!(Number.isInteger(capacity) && capacity >= 0)) {
93
96
  throw new TypeError('createController: capacity must be a non-negative integer');
@@ -142,6 +145,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
142
145
  };
143
146
  const spot = { x: 0.5, y: 0.5 };
144
147
 
148
+ // Reduced-motion blanket fallback: built ONCE here (cold) when the option is
149
+ // set, reused across every reveal so no per-reveal garbage. Stateless.
150
+ const reducedFallback = reducedMotion ? reducedMotionFallbackRecipe() : null;
151
+
145
152
  let activeRecipe = null;
146
153
  let onComplete = null;
147
154
  let elapsed = 0;
@@ -180,6 +187,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
180
187
  /** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
181
188
  reveal(recipe, onDone) {
182
189
  if (ctlDestroyed || activeRecipe) return;
190
+ // Reduced-motion blanket: honestly REPLACE the requested recipe with the
191
+ // calm count:0 opacity fade (never a wrap). Cold path; the count read +
192
+ // spawn loop below run against the swapped-in recipe.
193
+ if (reducedMotion) recipe = reducedFallback;
183
194
  activeRecipe = recipe;
184
195
  onComplete = onDone || null;
185
196
  elapsed = 0;