@zakkster/lite-scratch-fx 1.2.0 → 1.3.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/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @zakkster/lite-scratch-fx type declarations.
2
+ * @zakkster/lite-scratch-fx -- type declarations.
3
3
  */
4
4
 
5
5
  /** The package version. Kept in sync with package.json and CHANGELOG. */
@@ -40,7 +40,7 @@ export interface SpawnState {
40
40
 
41
41
  /**
42
42
  * A normalized spawn point (0..1 in each axis) taken from the scratch layer.
43
- * NOTE: this object is shared and reused across every `spawn` call in a reveal
43
+ * NOTE: this object is shared and reused across every `spawn` call in a reveal --
44
44
  * read `x`/`y` synchronously and never retain the reference.
45
45
  */
46
46
  export interface SpawnSpot {
@@ -102,6 +102,11 @@ export interface ScratchControllerOptions {
102
102
  * at a time; `destroy()` will not tear a shared engine down.
103
103
  */
104
104
  engine?: SoaParticleEngine;
105
+ /**
106
+ * Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1. Pass
107
+ * window.devicePixelRatio. Recipes still author in CSS pixels.
108
+ */
109
+ dpr?: number;
105
110
  }
106
111
 
107
112
  export interface ScratchController {
@@ -117,6 +122,12 @@ export interface ScratchController {
117
122
  tick(dt: number): void;
118
123
  /** Re-seed the RNG. */
119
124
  seed(s: number): void;
125
+ /**
126
+ * Abort an in-flight reveal: stop the effect and restore the host canvas's inline
127
+ * style to what it was at reveal start. Does NOT fire `onDone`; the controller is
128
+ * revealable again afterwards. A no-op when idle or destroyed.
129
+ */
130
+ cancel(): void;
120
131
  /** Stop, clean up the active recipe, and release the engine (owned engines only). */
121
132
  destroy(): void;
122
133
  }
@@ -132,13 +143,18 @@ export function createScratchController(
132
143
 
133
144
  export { createScratchController as ScratchController };
134
145
 
135
- // ── Concurrent reveals over one shared pool (createScratchStage) ──
146
+ // -- Concurrent reveals over one shared pool (createScratchStage) --
136
147
 
137
148
  export interface ScratchStageOptions {
138
149
  /** Total shared pool capacity, split across controllers. Default 2000. */
139
150
  maxParticles?: number;
140
151
  /** Base seed; each controller derives its own (base + index) unless it passes one. */
141
152
  seed?: number;
153
+ /**
154
+ * Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1. Pass
155
+ * window.devicePixelRatio. Recipes still author in CSS pixels.
156
+ */
157
+ dpr?: number;
142
158
  }
143
159
 
144
160
  export interface StageControllerOptions {
@@ -148,6 +164,11 @@ export interface StageControllerOptions {
148
164
  seed?: number;
149
165
  /** Horizontal resolution of the spawn-point pixel scan. Default 32. */
150
166
  scanPrecision?: number;
167
+ /**
168
+ * Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1 (the stage
169
+ * dpr). Pass window.devicePixelRatio. Recipes still author in CSS pixels.
170
+ */
171
+ dpr?: number;
151
172
  }
152
173
 
153
174
  /** A stage-managed controller. The stage drives it, so it has no `tick` of its own. */
@@ -156,6 +177,12 @@ export interface StageController {
156
177
  reveal(recipe: Recipe, onDone?: () => void): void;
157
178
  /** Re-seed this controller's RNG. */
158
179
  seed(s: number): void;
180
+ /**
181
+ * Abort this controller's in-flight reveal: stop the effect and restore the host
182
+ * canvas's inline style. Does NOT fire `onDone`; the controller is revealable again.
183
+ * A no-op when idle or destroyed. Does not touch other controllers' reveals.
184
+ */
185
+ cancel(): void;
159
186
  /** Remove this controller from the stage (its sub-range is not reclaimed). */
160
187
  destroy(): void;
161
188
  }
@@ -182,7 +209,7 @@ export interface ScratchStage {
182
209
  /** Create a stage for concurrent reveals over one shared particle pool. */
183
210
  export function createScratchStage(options?: ScratchStageOptions): ScratchStage;
184
211
 
185
- // ── Recipe factories. Each returns a Recipe; all options are optional. ──
212
+ // -- Recipe factories. Each returns a Recipe; all options are optional. --
186
213
 
187
214
  export interface RecipeOptions {
188
215
  count?: number;
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.2.0';
16
+ export const VERSION = '1.3.0';
17
17
 
18
18
  export { createScratchController } from './src/ScratchController.js';
19
19
  export { default as ScratchController } from './src/ScratchController.js';
@@ -37,7 +37,7 @@ import {
37
37
  PeelRecipe, FadeRecipe, ImplosionRecipe,
38
38
  } from './src/ScratchRecipes.js';
39
39
 
40
- // ── Recipe registry (extensible: registerRecipe adds to it) ──
40
+ // -- Recipe registry (extensible: registerRecipe adds to it) --
41
41
  // Keyed by short name, for data-driven pickers (demo dropdowns, random selection,
42
42
  // config files). A null-prototype object so keys never collide with Object.prototype.
43
43
  export const RECIPES = Object.assign(Object.create(null), {
package/llms.txt CHANGED
@@ -8,8 +8,8 @@
8
8
  ## Core model
9
9
 
10
10
  createScratchController(sourceCanvas, effectCanvas,
11
- { maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine })
12
- -> { reveal(recipe, onDone?), tick(dt), seed(s), destroy() }
11
+ { maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine, dpr = 1 })
12
+ -> { reveal(recipe, onDone?), tick(dt), cancel(), seed(s), destroy() }
13
13
 
14
14
  sourceCanvas = the scratch layer (what the user scratches off).
15
15
  effectCanvas = an overlay canvas the particles render onto.
@@ -76,7 +76,7 @@ All 21 live in src/ScratchRecipes.js, grouped by family with section banners:
76
76
  ## Registry, metadata, extension
77
77
 
78
78
  VERSION: the package version string (kept in sync with package.json and CHANGELOG).
79
- RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ).
79
+ RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ...).
80
80
  RECIPE_NAMES: the built-in keys at load time.
81
81
  RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for every
82
82
  recipe -- the source for building pickers without hardcoding. category is
@@ -105,10 +105,10 @@ modes exist now:
105
105
 
106
106
  ## Concurrent reveals over one pool: createScratchStage
107
107
 
108
- createScratchStage({ maxParticles = 2000, seed = Date.now() })
109
- -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32 }) -> stageController,
108
+ createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
109
+ -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr? }) -> stageController,
110
110
  tick(dt), destroy(), get remainingCapacity }
111
- stageController -> { reveal(recipe, onDone?), seed(s), destroy() } // NO tick -- the stage drives.
111
+ stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
112
112
 
113
113
  When { engine } (one reveal at a time) is not enough and you need MANY boxes revealing at once
114
114
  over one pool: a stage owns one raw engine and gives each controller a fixed sub-range [start,
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.2.0",
4
+ "version": "1.3.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",
@@ -21,6 +21,7 @@
21
21
  "src/ScratchController.js",
22
22
  "src/ScratchRecipes.js",
23
23
  "src/ScratchStage.js",
24
+ "src/HostStyle.js",
24
25
  "src/PixelScan.js",
25
26
  "src/Palette.js",
26
27
  "README.md",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * HostStyle -- snapshot and restore the host layer's inline style across a reveal.
3
+ *
4
+ * lite-scratch-fx BORROWS the host scratch layer's inline style for the duration of a
5
+ * reveal (recipes write these 5 props during tick) and returns it to exactly what it
6
+ * found on every terminal transition -- completion, cancel, and destroy. The host owns
7
+ * the final revealed state via onDone; the library leaves no fingerprints.
8
+ *
9
+ * The snapshot is one small plain object per reveal. This is a COLD path -- capture at
10
+ * reveal start, restore at reveal end. Never call these per frame.
11
+ *
12
+ * See decisions/0002-host-style-and-cancel.md for the contract.
13
+ *
14
+ * Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
15
+ * MIT License.
16
+ */
17
+
18
+ // The 5 inline style props the built-in recipes write during tick (particle/beam/css/
19
+ // image families combined). Capturing every element's CURRENT inline value (usually '')
20
+ // means restore returns the host to pristine regardless of what a custom recipe touched
21
+ // among these props.
22
+ export function captureHostStyle(el) {
23
+ if (!el || !el.style) return null; // fail closed: nothing to borrow, nothing to restore
24
+ const s = el.style;
25
+ return {
26
+ opacity: s.opacity,
27
+ transform: s.transform,
28
+ filter: s.filter,
29
+ clipPath: s.clipPath,
30
+ transformOrigin: s.transformOrigin,
31
+ };
32
+ }
33
+
34
+ export function restoreHostStyle(el, snap) {
35
+ if (!el || !el.style || !snap) return; // fail closed
36
+ const s = el.style;
37
+ s.opacity = snap.opacity;
38
+ s.transform = snap.transform;
39
+ s.filter = snap.filter;
40
+ s.clipPath = snap.clipPath;
41
+ s.transformOrigin = snap.transformOrigin;
42
+ }
@@ -43,6 +43,7 @@
43
43
  import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
44
44
  import { Random } from '@zakkster/lite-random';
45
45
  import { createPixelScanner } from './PixelScan.js';
46
+ import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
46
47
 
47
48
  // Shared engines currently driving a reveal. A shared engine has one render slot and one
48
49
  // particle pool, so only one controller may reveal on it at a time; a second reveal is
@@ -58,6 +59,7 @@ const busySharedEngines = new WeakSet();
58
59
  * @param {number} [options.scanPrecision=32] Horizontal resolution of the pixel scan
59
60
  * @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
60
61
  * @param {SoaParticleEngine} [options.engine] Share a caller-supplied engine + lane pool
62
+ * @param {number} [options.dpr=1] Device-pixel-ratio for sharp high-DPI output
61
63
  */
62
64
  export function createScratchController(sourceCanvas, effectCanvas, {
63
65
  maxParticles = 2000,
@@ -65,7 +67,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
65
67
  scanPrecision = 32,
66
68
  driven = false,
67
69
  engine: externalEngine = null,
70
+ dpr = 1,
68
71
  } = {}) {
72
+ if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
73
+ throw new TypeError('createScratchController: dpr must be a positive finite number');
74
+ }
69
75
  const ctx = effectCanvas.getContext('2d');
70
76
  const ownEngine = !externalEngine;
71
77
  const engine = externalEngine || new SoaParticleEngine(maxParticles);
@@ -75,6 +81,15 @@ export function createScratchController(sourceCanvas, effectCanvas, {
75
81
  let destroyed = false;
76
82
  let elapsed = 0;
77
83
  let onComplete = null;
84
+ // The host layer's inline style as it was at reveal START -- one small object per
85
+ // reveal (cold path), restored on every terminal transition so recipes leave no
86
+ // surviving inline opacity/transform/filter/clipPath/transformOrigin.
87
+ let hostSnapshot = null;
88
+ // Logical (CSS-pixel) reveal dimensions, cached at reveal start. Recipes author and
89
+ // draw in these; the device-pixel canvas is dpr x larger and the 2D transform maps
90
+ // logical -> device. Every per-frame clearRect and recipe call uses these, never the
91
+ // device-px effectCanvas.width/height.
92
+ let logicalW = 0, logicalH = 0;
78
93
 
79
94
  // The pixel scanner owns the offscreen scan canvas + spot arrays, all allocated once
80
95
  // and reused across reveals -- zero per-pixel allocation on the reveal-start frame.
@@ -87,13 +102,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
87
102
  // the hottest path in the package. Reassign the fields each frame (free) instead.
88
103
  let pView = null;
89
104
 
90
- // ── Render callback (the engine calls this every frame with raw SoA arrays) ──
105
+ // -- Render callback (the engine calls this every frame with raw SoA arrays) --
91
106
  function renderTick(dt, x, y, vx, vy, life, invLife, data, max) {
92
107
  if (destroyed || !activeRecipe) return;
93
108
  elapsed += dt;
94
- const w = effectCanvas.width;
95
- const h = effectCanvas.height;
96
- ctx.clearRect(0, 0, w, h);
109
+ ctx.clearRect(0, 0, logicalW, logicalH);
97
110
 
98
111
  if (pView === null) {
99
112
  pView = { x, y, vx, vy, life, invLife, data, max };
@@ -104,19 +117,28 @@ export function createScratchController(sourceCanvas, effectCanvas, {
104
117
 
105
118
  // Delegate everything to the recipe. Returns true when done.
106
119
  const isFinished = activeRecipe.tick(
107
- dt, elapsed * 1000, pView, ctx, sourceCanvas, w, h,
120
+ dt, elapsed * 1000, pView, ctx, sourceCanvas, logicalW, logicalH,
108
121
  );
109
122
 
110
- if (isFinished) {
111
- if (!driven) engine.stop();
112
- releaseEngine();
113
- if (activeRecipe.destroy) activeRecipe.destroy();
114
- activeRecipe = null;
115
- ctx.clearRect(0, 0, w, h);
116
- const done = onComplete;
117
- onComplete = null;
118
- if (done) done();
119
- }
123
+ if (isFinished) endReveal(true);
124
+ }
125
+
126
+ // The single terminator for a reveal. Every terminal transition -- natural completion
127
+ // (fireDone=true), cancel() (false), and destroy() (false) -- routes through here, so
128
+ // the engine release, recipe teardown, host-style restore, and canvas clear happen in
129
+ // exactly one place and can never diverge. Cold path: called once per reveal, never
130
+ // per frame. onDone fires ONLY on natural completion (fireDone).
131
+ function endReveal(fireDone) {
132
+ if (!activeRecipe) return;
133
+ if (ownEngine && !driven) engine.stop();
134
+ releaseEngine();
135
+ if (activeRecipe.destroy) activeRecipe.destroy();
136
+ if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
137
+ ctx.clearRect(0, 0, logicalW, logicalH);
138
+ const done = onComplete;
139
+ activeRecipe = null;
140
+ onComplete = null;
141
+ if (fireDone && done) done();
120
142
  }
121
143
 
122
144
  // Release our hold on a shared engine's single render slot so the next controller can
@@ -153,15 +175,26 @@ export function createScratchController(sourceCanvas, effectCanvas, {
153
175
  }
154
176
  engine.clear();
155
177
 
156
- // Size the effect canvas to match the scratch layer
157
- const w = effectCanvas.width = sourceCanvas.offsetWidth || sourceCanvas.width;
158
- const h = effectCanvas.height = sourceCanvas.offsetHeight || sourceCanvas.height;
178
+ // Logical (CSS-pixel) size of the scratch layer -- recipes author in these.
179
+ logicalW = sourceCanvas.offsetWidth || sourceCanvas.width;
180
+ logicalH = sourceCanvas.offsetHeight || sourceCanvas.height;
181
+ // Device-pixel backing store: dpr x larger so output is sharp on high-DPI
182
+ // screens. Assigning width/height also resets the 2D transform, so map logical
183
+ // -> device with setTransform. At dpr === 1 skip it: behaviour is then
184
+ // byte-identical to the pre-DPR path (no setTransform call, no scaled dims).
185
+ effectCanvas.width = Math.round(logicalW * dpr);
186
+ effectCanvas.height = Math.round(logicalH * dpr);
187
+ if (dpr !== 1) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
188
+
189
+ // Borrow the host layer's inline style: snapshot it now (cold path, one small
190
+ // object) so endReveal can return it pristine on completion/cancel/destroy.
191
+ hostSnapshot = captureHostStyle(sourceCanvas);
159
192
 
160
193
  // Scan spawn points from visible pixels.
161
194
  const spots = scanner.scan(scanPrecision);
162
195
 
163
- // Init recipe
164
- recipe.init(ctx, maxParticles, w, h);
196
+ // Init recipe with LOGICAL size (recipes stay in CSS px).
197
+ recipe.init(ctx, maxParticles, logicalW, logicalH);
165
198
 
166
199
  // Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
167
200
  // like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
@@ -178,7 +211,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
178
211
  spot.x = 0.5;
179
212
  spot.y = 0.5;
180
213
  }
181
- const state = recipe.spawn(idx, rng, w, h, spot);
214
+ const state = recipe.spawn(idx, rng, logicalW, logicalH, spot);
182
215
  engine.emit(state.x, state.y, state.vx, state.vy, state.life, idx);
183
216
  }
184
217
 
@@ -199,17 +232,25 @@ export function createScratchController(sourceCanvas, effectCanvas, {
199
232
  engine.tick(dt);
200
233
  },
201
234
 
235
+ /**
236
+ * Abort an in-flight reveal. Stops the effect, restores the host canvas's inline
237
+ * style to exactly what it was at reveal start, and does NOT fire onDone. The
238
+ * controller is revealable again afterwards. A no-op when idle or destroyed.
239
+ */
240
+ cancel() {
241
+ if (destroyed || !activeRecipe) return;
242
+ endReveal(false);
243
+ },
244
+
202
245
  /** Re-seed the RNG. */
203
246
  seed(s) { rng.reset(s); },
204
247
 
205
248
  destroy() {
206
249
  if (destroyed) return;
207
250
  destroyed = true;
208
- if (!driven) engine.stop();
209
- releaseEngine();
210
- if (activeRecipe && activeRecipe.destroy) activeRecipe.destroy();
211
- activeRecipe = null;
212
- onComplete = null;
251
+ // Route any in-flight reveal through the single terminator (stops/releases the
252
+ // engine, restores host style, no onDone) before the controller's own teardown.
253
+ endReveal(false);
213
254
  scanner.destroy();
214
255
  // Only tear down an engine we created. A caller-supplied engine is theirs.
215
256
  if (ownEngine) engine.destroy();
@@ -359,7 +359,7 @@ export function GoldDustRecipe({count = 150, duration = 2000, colors, theme} = {
359
359
  ctx.fill();
360
360
  }
361
361
 
362
- // FIX: Reset composite operation every frame
362
+ // <- FIX: Reset composite operation every frame
363
363
  ctx.globalCompositeOperation = 'source-over';
364
364
  ctx.globalAlpha = 1;
365
365
  return alive === 0 && elapsed >= duration;
@@ -372,7 +372,7 @@ export function GoldDustRecipe({count = 150, duration = 2000, colors, theme} = {
372
372
 
373
373
 
374
374
  export function ConfettiBlastRecipe({count = 80, duration = 1500, colors, theme} = {}) {
375
- // FIX: Dedicated parallel arrays instead of repurposing size/decay
375
+ // <- FIX: Dedicated parallel arrays instead of repurposing size/decay
376
376
  let pWobblePhase, pWobbleSpeed, pColorIdx;
377
377
  const palette = resolvePalette(colors, theme, ['#ff0055', '#00ffcc', '#ffcc00', '#aa00ff', '#ff6600', '#00aaff']);
378
378
 
@@ -480,7 +480,7 @@ export function CosmicDustRecipe({count = 200, duration = 1800, colors, theme} =
480
480
  ctx.fill();
481
481
  }
482
482
 
483
- // FIX: Reset composite operation every frame
483
+ // <- FIX: Reset composite operation every frame
484
484
  ctx.globalCompositeOperation = 'source-over';
485
485
  ctx.globalAlpha = 1;
486
486
  return alive === 0 && elapsed >= duration;
@@ -522,7 +522,7 @@ export function MatrixDecayRecipe({count = 200, duration = 1500, colors, theme}
522
522
 
523
523
  // NOTE: No trail fillRect here. The ScratchController clears the canvas
524
524
  // every frame before calling tick(). Crisp falling text over the fading
525
- // scratch layer is cleaner for a reveal effect no persistent black box
525
+ // scratch layer is cleaner for a reveal effect -- no persistent black box
526
526
  // obscuring the prize underneath.
527
527
 
528
528
  ctx.font = '14px monospace';
@@ -598,7 +598,7 @@ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} =
598
598
 
599
599
  ctx.globalAlpha = life[i];
600
600
  ctx.fillStyle = palette[0];
601
- // FIX: Use fillRect instead of roundRect for Safari < 17.4 compat
601
+ // <- FIX: Use fillRect instead of roundRect for Safari < 17.4 compat
602
602
  ctx.fillRect(x[i] - sz / 2, pOriginY[p], sz, streakHeight);
603
603
  // Rounded tip at bottom
604
604
  ctx.beginPath();
@@ -685,7 +685,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
685
685
 
686
686
 
687
687
  export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
688
- let pRot, pRotSpd, pSz, pOrigX, pOrigY; // FIX: Store ORIGINAL positions
688
+ let pRot, pRotSpd, pSz, pOrigX, pOrigY; // <- FIX: Store ORIGINAL positions
689
689
  let img = null, imgReady = false;
690
690
 
691
691
  return {
@@ -702,7 +702,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
702
702
  pRot[idx] = rng.range(0, Math.PI);
703
703
  pRotSpd[idx] = rng.range(-0.3, 0.3);
704
704
  pSz[idx] = rng.range(5, 15);
705
- pOrigX[idx] = sx; // FIX: Store spawn position for source crop
705
+ pOrigX[idx] = sx; // <- FIX: Store spawn position for source crop
706
706
  pOrigY[idx] = sy;
707
707
 
708
708
  const angle = rng.range(0, Math.PI * 2);
@@ -744,7 +744,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
744
744
  ctx.globalAlpha = life[i];
745
745
  ctx.translate(x[i], y[i]);
746
746
  ctx.rotate(pRot[p]);
747
- // FIX: Source crop uses ORIGINAL position, not current animated position
747
+ // <- FIX: Source crop uses ORIGINAL position, not current animated position
748
748
  ctx.drawImage(img, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
749
749
  ctx.restore();
750
750
  }
@@ -862,7 +862,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
862
862
  let py, pvy, pox, psz, pcol;
863
863
 
864
864
  return {
865
- count: 0, // No SoA particles managed internally
865
+ count: 0, // No SoA particles -- managed internally
866
866
  init(ctx, capacity, w, h) {
867
867
  py = new Float32Array(particleCount);
868
868
  pvy = new Float32Array(particleCount);
@@ -966,14 +966,14 @@ export function LaserScanRecipe({duration = 1000, colors, theme} = {}) {
966
966
  ctx.fill();
967
967
  }
968
968
 
969
- // FIX: Reset shadow + composite every frame
969
+ // <- FIX: Reset shadow + composite every frame
970
970
  ctx.shadowBlur = 0;
971
971
  ctx.shadowColor = 'transparent';
972
972
  ctx.globalCompositeOperation = 'source-over';
973
973
  }
974
974
 
975
975
  if (raw >= 1) {
976
- // FIX: Clean up clipPath on completion
976
+ // <- FIX: Clean up clipPath on completion
977
977
  src.style.clipPath = '';
978
978
  return true;
979
979
  }
@@ -1110,7 +1110,7 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
1110
1110
  const cx = w / 2, cy = h / 2;
1111
1111
 
1112
1112
  src.style.opacity = 1 - t;
1113
- // FIX: Clamp scale to prevent overflow; clean up on completion
1113
+ // <- FIX: Clamp scale to prevent overflow; clean up on completion
1114
1114
  src.style.transform = `scale(${1 + t * 0.3})`;
1115
1115
  src.style.transformOrigin = 'center';
1116
1116
 
@@ -1127,7 +1127,7 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
1127
1127
  ctx.stroke();
1128
1128
  }
1129
1129
 
1130
- // FIX: Reset composite operation
1130
+ // <- FIX: Reset composite operation
1131
1131
  ctx.globalCompositeOperation = 'source-over';
1132
1132
  ctx.globalAlpha = 1;
1133
1133
 
@@ -23,13 +23,18 @@
23
23
  import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
24
24
  import { Random } from '@zakkster/lite-random';
25
25
  import { createPixelScanner } from './PixelScan.js';
26
+ import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
26
27
 
27
28
  /**
28
29
  * @param {Object} [options]
29
30
  * @param {number} [options.maxParticles=2000] Total shared pool capacity.
30
31
  * @param {number} [options.seed=Date.now()] Base seed; each controller derives its own.
32
+ * @param {number} [options.dpr=1] Default device-pixel-ratio for controllers.
31
33
  */
32
- export function createScratchStage({ maxParticles = 2000, seed = Date.now() } = {}) {
34
+ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr: stageDpr = 1 } = {}) {
35
+ if (!(typeof stageDpr === 'number' && Number.isFinite(stageDpr) && stageDpr > 0)) {
36
+ throw new TypeError('createScratchStage: dpr must be a positive finite number');
37
+ }
33
38
  const engine = new SoaParticleEngine(maxParticles);
34
39
  const active = new Set();
35
40
  let nextStart = 0;
@@ -54,12 +59,16 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
54
59
  * @param {number} [opts.capacity=300] Slots reserved for this controller's particles.
55
60
  * @param {number} [opts.seed] RNG seed; defaults to base seed + index.
56
61
  * @param {number} [opts.scanPrecision=32] Pixel-scan resolution.
62
+ * @param {number} [opts.dpr] Per-controller device-pixel-ratio (default: stage dpr).
57
63
  */
58
- function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32 } = {}) {
64
+ function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32, dpr = stageDpr } = {}) {
59
65
  if (destroyed) throw new Error('createScratchStage: stage is destroyed');
60
66
  if (!(Number.isInteger(capacity) && capacity >= 0)) {
61
67
  throw new TypeError('createController: capacity must be a non-negative integer');
62
68
  }
69
+ if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
70
+ throw new TypeError('createController: dpr must be a positive finite number');
71
+ }
63
72
  if (nextStart + capacity > maxParticles) {
64
73
  throw new RangeError(
65
74
  `createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
@@ -91,8 +100,31 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
91
100
  let activeRecipe = null;
92
101
  let onComplete = null;
93
102
  let elapsed = 0;
103
+ // Logical (CSS-pixel) reveal size. Recipes author and draw in these; the device-px
104
+ // effect canvas is dpr x larger and the 2D transform maps logical -> device. Every
105
+ // read of cw/ch (the _frame clearRect, recipe init/spawn/tick, endReveal clearRect)
106
+ // wants logical, so cw/ch are logical throughout.
94
107
  let cw = 0, ch = 0;
95
108
  let ctlDestroyed = false;
109
+ // Host layer inline style at reveal START -- one small object per reveal (cold
110
+ // path), restored on every terminal transition (completion/cancel/destroy).
111
+ let hostSnapshot = null;
112
+
113
+ // The single terminator for this controller's reveal. Completion (fireDone=true),
114
+ // cancel() (false), and destroy() (false) all route here so the recipe teardown,
115
+ // host-style restore, canvas clear, and active-set removal happen in one place.
116
+ // Cold path: once per reveal, never per frame. Sub-range stays reserved (B-8).
117
+ function endReveal(fireDone) {
118
+ if (!activeRecipe) return;
119
+ if (activeRecipe.destroy) activeRecipe.destroy();
120
+ if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
121
+ ctx.clearRect(0, 0, cw, ch);
122
+ active.delete(controller);
123
+ const done = onComplete;
124
+ activeRecipe = null;
125
+ onComplete = null;
126
+ if (fireDone && done) done();
127
+ }
96
128
 
97
129
  const controller = {
98
130
  /** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
@@ -102,8 +134,17 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
102
134
  onComplete = onDone || null;
103
135
  elapsed = 0;
104
136
 
105
- cw = effectCanvas.width = sourceCanvas.offsetWidth || sourceCanvas.width;
106
- ch = effectCanvas.height = sourceCanvas.offsetHeight || sourceCanvas.height;
137
+ cw = sourceCanvas.offsetWidth || sourceCanvas.width;
138
+ ch = sourceCanvas.offsetHeight || sourceCanvas.height;
139
+ // Device-pixel backing store: dpr x larger for sharp high-DPI output.
140
+ // Assigning width/height resets the 2D transform, so map logical -> device
141
+ // with setTransform. At dpr === 1 skip it for byte-identical behaviour.
142
+ effectCanvas.width = Math.round(cw * dpr);
143
+ effectCanvas.height = Math.round(ch * dpr);
144
+ if (dpr !== 1) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
145
+
146
+ // Borrow the host layer's inline style; endReveal restores it pristine.
147
+ hostSnapshot = captureHostStyle(sourceCanvas);
107
148
 
108
149
  const spots = scanner.scan(scanPrecision);
109
150
  recipe.init(ctx, capacity, cw, ch);
@@ -139,15 +180,18 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
139
180
  elapsed += dt;
140
181
  ctx.clearRect(0, 0, cw, ch);
141
182
  const isFinished = activeRecipe.tick(dt, elapsed * 1000, pView, ctx, sourceCanvas, cw, ch);
142
- if (isFinished) {
143
- if (activeRecipe.destroy) activeRecipe.destroy();
144
- activeRecipe = null;
145
- ctx.clearRect(0, 0, cw, ch);
146
- active.delete(controller);
147
- const done = onComplete;
148
- onComplete = null;
149
- if (done) done();
150
- }
183
+ if (isFinished) endReveal(true);
184
+ },
185
+
186
+ /**
187
+ * Abort this controller's in-flight reveal: stop the effect, restore the host
188
+ * canvas's inline style, and do NOT fire onDone; the controller is revealable
189
+ * again. A no-op when idle or destroyed. Does not reclaim the sub-range (B-8)
190
+ * and never touches other controllers' in-flight reveals.
191
+ */
192
+ cancel() {
193
+ if (ctlDestroyed || !activeRecipe) return;
194
+ endReveal(false);
151
195
  },
152
196
 
153
197
  /** Re-seed this controller's RNG. */
@@ -157,10 +201,9 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
157
201
  destroy() {
158
202
  if (ctlDestroyed) return;
159
203
  ctlDestroyed = true;
160
- if (activeRecipe && activeRecipe.destroy) activeRecipe.destroy();
161
- activeRecipe = null;
162
- onComplete = null;
163
- active.delete(controller);
204
+ // Route any in-flight reveal through the single terminator (recipe teardown,
205
+ // host-style restore, active-set removal, no onDone) before scanner teardown.
206
+ endReveal(false);
164
207
  scanner.destroy();
165
208
  },
166
209
  };