@zakkster/lite-scratch-fx 1.5.1 → 1.7.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,53 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0] - 2026-08-24
4
+
5
+ Optional per-frame glow cap for the two haloed particle recipes (B-20). Additive and
6
+ backward compatible: the knob defaults to `Infinity` (unlimited), so default output is
7
+ byte-identical to 1.6.0. The per-frame render path gains one counter and one guard.
8
+
9
+ ### Added
10
+ - **`glowBudget` option on `BurnRecipe` and `DragonBreathRecipe` (default `Infinity` =
11
+ unlimited).** Caps how many particles paint their outer glow halo per frame. Once the
12
+ budget is spent that tick, further particles draw only their brighter core arc; the
13
+ counter resets every frame. The core arc, physics, `alive` accounting, and the
14
+ offscreen cull are never capped. Uses a strict `g < glowBudget` compare (never a falsy
15
+ coercion), so `glowBudget: 0` means exactly zero halos (cores only) and the unset
16
+ default `Infinity` keeps every halo. `glowBudget: 80` matches the game's `GLOW_BUDGET`
17
+ parity. Zero new hot-path allocation.
18
+
19
+ ## [1.6.0] - 2026-08-23
20
+
21
+ Reduced-motion support (N-4). Backward compatible: the new controller/stage option
22
+ defaults to `false`, the helper and metadata field are additive, and existing callers
23
+ are byte-unaffected. The per-frame render path is unchanged.
24
+
25
+ ### Added
26
+ - **`reducedMotion` option on `createScratchController` and `stage.createController`
27
+ (default `false`).** When `true`, every `reveal(recipe)` ignores the passed recipe and
28
+ runs an internal `count: 0` opacity fade instead -- zero particles, no motion beyond the
29
+ fade, `onDone` still fires once on completion. The library does not read
30
+ `prefers-reduced-motion` itself; the host passes the result of its own media query. The
31
+ fallback recipe is built once per controller (cold path) and reused across reveals; it is
32
+ stateless, so it is re-entrant across successive reveals on the same controller.
33
+ - **`reducedMotionRecipe(recipe, { scale = 0.25, duration } = {})`.** A pure wrapper that
34
+ keeps a recipe's look but spawns `Math.round(recipe.count * scale)` particles (a quarter
35
+ by default). It delegates `init`/`spawn`/`tick`/`destroy` to the inner recipe and reuses
36
+ its arrays, so it adds no hot-path allocation. `duration`, if given, caps the reveal early
37
+ (`elapsedMs >= duration`); it does not re-time the inner easing curve. It reduces particle
38
+ count only and cannot suppress a recipe's own per-particle glow, which is drawn inside the
39
+ inner `tick`. Throws `TypeError` on a non-recipe argument or a non-finite/negative `scale`.
40
+ - **`RECIPE_META[*].motionSafe` (boolean).** Marks inherently calm built-ins so a picker can
41
+ prefer them under reduced motion. Strict: `true` for `fade` only (a pure opacity fade);
42
+ every recipe that moves geometry -- particles, the peel wipe, the implosion -- is `false`.
43
+ Threaded through `registerRecipe` with the same prev-fallback rule as the other flags.
44
+
45
+ ### Changed
46
+ - **Torture gate extended.** Two tiers added to `test/torture.mjs`: T6 gates the blanket
47
+ `reducedMotion` reveal/tick/complete cycle and T7 gates a `reducedMotionRecipe`-wrapped
48
+ steady state, both under the existing 64 B/op ceiling (measured 41.6 and 6.9 B/op; their
49
+ broken-variant controls allocate ~1193 and ~1149 B/op). Test count 283 -> 322.
50
+
3
51
  ## [1.5.1] - 2026-08-23
4
52
 
5
53
  Cover-fade fidelity patch for `iceBreath`. Backward compatible; no API change.
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)
@@ -267,14 +268,46 @@ current built-in look:
267
268
  - **`DragonBreathRecipe`** / **`IceBreathRecipe`** -- `spread` (jet half-angle in radians,
268
269
  defaults `0.3` / `0.225`), `speedMin`/`speedMax` (spawn speed range, defaults `12`-`25`
269
270
  / `15`-`28`).
270
- - **`PeelRecipe`** / **`ShineWaveRecipe`** -- `ease` (`(t: number) => number`): the easing
271
- applied to progress; defaults to the recipe's built-in curve (`easeIn` / `easeInOut`).
271
+ - **`BurnRecipe`** / **`DragonBreathRecipe`** -- `glowBudget` (default `Infinity` =
272
+ unlimited): a per-frame cap on how many particles paint their outer glow halo. The
273
+ brighter core arc, physics, and offscreen cull are never capped -- only the halo is
274
+ skipped once the budget is spent that frame; the counter resets every tick. `0` means
275
+ zero halos (cores only); `80` matches the game's `GLOW_BUDGET` parity. Default output is
276
+ byte-identical to earlier versions.
277
+
278
+ ### Reduced motion
279
+
280
+ Reveals run in lottery / instant-win UIs, so honouring `prefers-reduced-motion` matters.
281
+ The library does not read the media query itself -- the host owns it -- but it gives you
282
+ two ways to respond, plus a flag for building a picker.
283
+
284
+ - **Blanket switch: `{ reducedMotion: true }`** on `createScratchController` or
285
+ `stage.createController`. Every `reveal(recipe)` then ignores the recipe it is handed and
286
+ runs a calm `count: 0` opacity fade instead -- zero particles, no spray, `onDone` still
287
+ fires once on completion. This is the honest "motion off" path.
288
+
289
+ ```js
290
+ const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
291
+ const ctrl = createScratchController(cover, fx, { reducedMotion: calm });
292
+ ctrl.reveal(RECIPES.dragonBreath()); // draws a plain fade when calm is true
293
+ ```
294
+
295
+ - **Toned-down variant: `reducedMotionRecipe(recipe, { scale = 0.25, duration? })`**. Wraps
296
+ any recipe and keeps its look but spawns `round(count * scale)` particles (a quarter, by
297
+ default); an optional `duration` caps the reveal early. It reuses the inner recipe's
298
+ arrays, so it adds no hot-path allocation. Note it can only reduce particle *count* -- it
299
+ cannot suppress a recipe's own per-particle glow, which is drawn inside the recipe's
300
+ `tick`. For a full "motion off", use the blanket switch above.
301
+
302
+ - **`RECIPE_META[*].motionSafe`** marks the inherently calm built-ins so a picker can prefer
303
+ them. It is strict: only `fade` (a pure opacity fade) is `motionSafe: true`; every recipe
304
+ that moves geometry -- particles, a peel wipe, an implosion -- is `false`.
272
305
 
273
306
  ### Constants
274
307
 
275
308
  | Constant | Value | Meaning |
276
309
  | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
277
- | `VERSION` | `'1.5.1'` | Package version string (synced to package.json). |
310
+ | `VERSION` | `'1.7.0'` | Package version string (synced to package.json). |
278
311
  | `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
279
312
  | `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
280
313
  | 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. */
@@ -231,11 +242,11 @@ export interface ThemeableRecipeOptions extends RecipeOptions {
231
242
  theme?: Theme;
232
243
  }
233
244
 
234
- export function BurnRecipe(opts?: ThemeableRecipeOptions): Recipe;
245
+ export function BurnRecipe(opts?: ThemeableRecipeOptions & { glowBudget?: number }): Recipe;
235
246
  export function ShatterRecipe(opts?: RecipeOptions & { gravity?: number }): Recipe;
236
247
  export function DissolveRecipe(opts?: ThemeableRecipeOptions & { fadeSpeed?: number }): Recipe;
237
248
  export function ExplodeRecipe(opts?: ThemeableRecipeOptions & { force?: number }): Recipe;
238
- export function DragonBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
249
+ export function DragonBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number; glowBudget?: number }): Recipe;
239
250
  export function IceBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
240
251
  export function ShineWaveRecipe(opts?: ThemeableRecipeOptions & { beamWidth?: number; particleCount?: number; ease?: (t: number) => number }): Recipe;
241
252
  export function LightningCrawlRecipe(opts?: ThemeableRecipeOptions & { boltCount?: number }): Recipe;
@@ -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.1';
16
+ export const VERSION = '1.7.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).
@@ -77,20 +78,25 @@ Every factory takes an options bag; all options default to the shipped tuning, s
77
78
  no args reproduces the stock effect. Beyond { colors, theme } (the 14 themeable recipes) and
78
79
  count/duration, the FX_CONFIG-style knobs are: dissolve fadeSpeed (fade window as a fraction of
79
80
  duration); dragonBreath and iceBreath spread / speedMin / speedMax (spawn cone half-angle in
80
- radians and speed range); peel and shineWave ease (a t -> t easing function). A non-function ease
81
- falls back to the recipe's default curve.
81
+ radians and speed range); burn and dragonBreath glowBudget (per-frame cap on how many particles
82
+ paint their outer glow halo, default Infinity = unlimited; 0 = zero halos, cores only; 80 =
83
+ game GLOW_BUDGET parity -- the core arc, physics and offscreen cull are never capped and the
84
+ counter resets each tick, so the default output is byte-identical to earlier versions); peel and
85
+ shineWave ease (a t -> t easing function). A non-function ease falls back to the recipe's default curve.
82
86
 
83
87
  ## Registry, metadata, extension
84
88
 
85
89
  VERSION: the package version string (kept in sync with package.json and CHANGELOG).
86
90
  RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ...).
87
91
  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
92
+ RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas, motionSafe } for
93
+ every recipe -- the source for building pickers without hardcoding. category is
90
94
  'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is false for every built-in (image recipes
91
95
  draw the layer live via drawImage(src), never reading it back); the flag is retained so a
92
96
  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 }.
97
+ requirement to pickers. themeable is true for the 14 recipes that take { colors, theme }. motionSafe
98
+ is an inherently-calm reveal a picker can prefer under prefers-reduced-motion; strict = true for
99
+ 'fade' ONLY (pure-opacity cross-fade), false for every other built-in (all animate or paint).
94
100
  registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
95
101
  RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
96
102
  Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
@@ -115,7 +121,7 @@ modes exist now:
115
121
  ## Concurrent reveals over one pool: createScratchStage
116
122
 
117
123
  createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
118
- -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr? }) -> stageController,
124
+ -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr?, reducedMotion = false }) -> stageController,
119
125
  tick(dt), destroy(), get remainingCapacity, remainingCapacityFor(capacity) }
120
126
  stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
121
127
 
@@ -130,6 +136,23 @@ later createController; cancel/completion hold the range (the controller stays r
130
136
  Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
131
137
  lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
132
138
 
139
+ ## Reduced motion
140
+
141
+ The host owns the media query; the library never touches matchMedia (DOM-query-free, SSR-safe).
142
+ Read prefers-reduced-motion and pass the boolean in. Two shapes:
143
+ Blanket switch: createScratchController(src, fx, { reducedMotion }) (and stage.createController).
144
+ When true, EVERY reveal honestly REPLACES the requested recipe with a calm count:0 opacity fade
145
+ (~300ms, motion off -- not a wrapped recipe): zero particles spawn, no halo painted. The calm
146
+ fallback is built once per controller (cold) -> no per-reveal garbage. Default false = full motion.
147
+ Helper: reducedMotionRecipe(recipe, { scale = 0.25, duration }) -> Recipe. A pure delegating wrapper:
148
+ count becomes round(inner.count * scale) (fewer particles; 0 stays 0), init/spawn/tick/destroy
149
+ delegate verbatim -> zero added hot-path alloc. Reduces particle COUNT only -- it CANNOT suppress
150
+ the inner recipe's per-particle painted halo (that lives in the inner tick; deferred). duration is
151
+ an early-complete CAP (tick returns innerDone || elapsedMs >= duration), NOT a re-timing of the curve.
152
+ Throws TypeError if recipe has no tick function or scale is not a finite number >= 0.
153
+ RECIPE_META.motionSafe lets a picker prefer 'fade' (the only strict motion-safe reveal) under the query.
154
+ See decisions/0003-reduced-motion.md.
155
+
133
156
  ## Fixed in 1.0.0 (was broken pre-package)
134
157
 
135
158
  - 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.1",
4
+ "version": "1.7.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;
@@ -28,7 +28,7 @@ import {resolvePalette} from './Palette.js';
28
28
  // Spawn particles from the scratch layer's still-covered pixels and run their own physics.
29
29
  // ===========================================================================
30
30
 
31
- export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
31
+ export function BurnRecipe({count = 100, duration = 200, glowBudget = Infinity, colors, theme} = {}) {
32
32
  let pSize, pDecay, pColorIdx;
33
33
  const palette = resolvePalette(colors, theme, ['#ff6b00', '#ff8c00', '#ffaa00', '#ff4500', '#8B0000']);
34
34
 
@@ -49,6 +49,7 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
49
49
  const {x, y, vx, vy, life, data, max} = engine;
50
50
  const ds = dt * 60;
51
51
  let alive = 0;
52
+ let g = 0;
52
53
  const progress = clamp(elapsed / duration, 0, 1);
53
54
  src.style.opacity = 1 - easeIn(progress);
54
55
 
@@ -63,10 +64,13 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
63
64
  if (y[i] < -50) continue;
64
65
  ctx.globalCompositeOperation = 'lighter';
65
66
  ctx.fillStyle = palette[pColorIdx[p]];
66
- ctx.globalAlpha = life[i] * 0.3;
67
- ctx.beginPath();
68
- ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
69
- ctx.fill();
67
+ if (g < glowBudget) {
68
+ ctx.globalAlpha = life[i] * 0.3;
69
+ ctx.beginPath();
70
+ ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
71
+ ctx.fill();
72
+ g++;
73
+ }
70
74
  ctx.globalAlpha = life[i];
71
75
  ctx.beginPath();
72
76
  ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
@@ -171,7 +175,7 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
171
175
  }
172
176
 
173
177
 
174
- export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, colors, theme} = {}) {
178
+ export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, glowBudget = Infinity, colors, theme} = {}) {
175
179
  let pSize, pDecay, pColorIdx, r;
176
180
  const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
177
181
 
@@ -203,6 +207,7 @@ export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3,
203
207
  const dvy = Math.pow(0.98, ds);
204
208
  const dsz = Math.pow(1.04, ds);
205
209
  let alive = 0;
210
+ let g = 0;
206
211
  const fadeT = clamp((elapsed - 100) / 400, 0, 1);
207
212
  src.style.opacity = 1 - fadeT * fadeT;
208
213
 
@@ -220,10 +225,13 @@ export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3,
220
225
  if (y[i] < -50) continue;
221
226
  ctx.globalCompositeOperation = 'lighter';
222
227
  ctx.fillStyle = palette[pColorIdx[p]];
223
- ctx.globalAlpha = life[i] * 0.3;
224
- ctx.beginPath();
225
- ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
226
- ctx.fill();
228
+ if (g < glowBudget) {
229
+ ctx.globalAlpha = life[i] * 0.3;
230
+ ctx.beginPath();
231
+ ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
232
+ ctx.fill();
233
+ g++;
234
+ }
227
235
  ctx.globalAlpha = life[i];
228
236
  ctx.beginPath();
229
237
  ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
@@ -1238,6 +1246,88 @@ export function ImplosionRecipe({duration = 800} = {}) {
1238
1246
  }
1239
1247
 
1240
1248
 
1249
+ // ===========================================================================
1250
+ // REDUCED MOTION
1251
+ // ===========================================================================
1252
+
1253
+ // Fixed fade duration for the reduced-motion blanket fallback (ms). A short,
1254
+ // calm pure-opacity dissolve -- honest "motion off", not a wrapped recipe.
1255
+ const REDUCED_MOTION_FADE_MS = 300;
1256
+
1257
+ /**
1258
+ * The calm fallback a controller/stage swaps in when created with
1259
+ * `{ reducedMotion: true }`. A count:0 recipe: zero particles spawn, and the
1260
+ * scratch layer fades its opacity 1 -> 0 over a short fixed duration. Shared by
1261
+ * ScratchController and ScratchStage so the calm body lives in exactly one place.
1262
+ * Satisfies the full recipe contract (init/spawn/tick/destroy).
1263
+ * @returns {Object} a fresh reduced-motion fallback recipe
1264
+ */
1265
+ export function reducedMotionFallbackRecipe() {
1266
+ return {
1267
+ count: 0,
1268
+ init() {
1269
+ },
1270
+ // count:0 means the controller never calls spawn; kept for contract safety.
1271
+ spawn() {
1272
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
1273
+ },
1274
+ tick(dt, elapsedMs, engine, ctx, src) {
1275
+ src.style.opacity = 1 - clamp(elapsedMs / REDUCED_MOTION_FADE_MS, 0, 1);
1276
+ return elapsedMs >= REDUCED_MOTION_FADE_MS;
1277
+ },
1278
+ destroy() {
1279
+ },
1280
+ };
1281
+ }
1282
+
1283
+ /**
1284
+ * A pure delegating wrapper that dials an existing recipe down for reduced
1285
+ * motion by reducing its particle COUNT (default a quarter) and, optionally,
1286
+ * capping the reveal early. It reuses the inner recipe's arrays and delegates
1287
+ * init/spawn/tick/destroy verbatim, so it adds ZERO hot-path allocation.
1288
+ *
1289
+ * Honest limits, on purpose:
1290
+ * - It reduces particle COUNT only. It CANNOT suppress the per-particle painted
1291
+ * halo/glow -- that lives inside the inner recipe's own tick. Suppressing it
1292
+ * needs a recipe-interface change and is deferred.
1293
+ * - `duration` is an early-complete CAP, not a re-timing. The inner curve's own
1294
+ * duration is baked into its closure and cannot be shortened from outside, so
1295
+ * tick returns `innerDone || (duration != null && elapsedMs >= duration)`.
1296
+ * The reveal ends sooner; the inner animation is not re-scaled onto it.
1297
+ *
1298
+ * @param {Object} recipe the inner recipe to dial down
1299
+ * @param {{ scale?: number, duration?: number }} [opts]
1300
+ * scale particle-count multiplier (default 0.25); finite, >= 0
1301
+ * duration optional early-complete cap in ms
1302
+ * @returns {Object} the wrapped recipe
1303
+ */
1304
+ export function reducedMotionRecipe(recipe, {scale = 0.25, duration} = {}) {
1305
+ if (!recipe || typeof recipe !== 'object' || typeof recipe.tick !== 'function') {
1306
+ throw new TypeError('reducedMotionRecipe: recipe must be an object with a tick function');
1307
+ }
1308
+ if (!(typeof scale === 'number' && Number.isFinite(scale) && scale >= 0)) {
1309
+ throw new TypeError('reducedMotionRecipe: scale must be a finite number >= 0');
1310
+ }
1311
+ return {
1312
+ // Fewer particles. A count:0 inner recipe stays 0.
1313
+ count: Math.round((recipe.count || 0) * scale),
1314
+ init(ctx, capacity, w, h, rng) {
1315
+ return recipe.init(ctx, capacity, w, h, rng);
1316
+ },
1317
+ spawn(idx, rng, w, h, spot) {
1318
+ return recipe.spawn(idx, rng, w, h, spot);
1319
+ },
1320
+ tick(dt, elapsedMs, engine, ctx, src, w, h) {
1321
+ const innerDone = recipe.tick(dt, elapsedMs, engine, ctx, src, w, h);
1322
+ return innerDone || (duration != null && elapsedMs >= duration);
1323
+ },
1324
+ destroy() {
1325
+ if (recipe.destroy) recipe.destroy();
1326
+ },
1327
+ };
1328
+ }
1329
+
1330
+
1241
1331
  // ===========================================================================
1242
1332
  // RECIPE MAP
1243
1333
  // ===========================================================================
@@ -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;