From cc7c9ddf2776e3c773bad7e239473d8ecaf0487d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 21:58:03 +0000 Subject: [PATCH 01/21] Add windy particle motion mode as default for Thanos disintegrate Introduce particleMotionMode with splat (radial burst) and windy (flow-field turbulence) physics. Windy mode uses Perlin-style noise to steer particles along curved gust paths, with lighter gravity and stronger horizontal carry. Defaults to windy; splat remains available via particleMotionMode: "splat". --- .../__tests__/applyWaveOrigin.test.ts | 1 + .../assignParticleReleaseTimes.test.ts | 1 + .../buildChunkMaskSequenceAsync.test.ts | 1 + .../__tests__/createChunkMaskSequence.test.ts | 1 + .../__tests__/createThanosParticle.test.ts | 57 ++++++++ .../__tests__/drawDisintegrationFrame.test.ts | 1 + .../getParticleCanvasPadding.test.ts | 1 + .../getWaveDisintegrationProgress.test.ts | 1 + .../normalizeCaptureToDisplay.test.ts | 1 + .../__tests__/runThanosDisintegrate.test.ts | 1 + .../__tests__/stepParticles.test.ts | 64 ++++++++- .../__tests__/windTurbulence.test.ts | 40 ++++++ .../effects/thanosDisintegrate/constants.ts | 1 + .../createThanosParticle.ts | 72 ++++++++-- .../getParticleCanvasPadding.ts | 11 +- .../ui/effects/thanosDisintegrate/index.ts | 1 + .../thanosDisintegrate/stepParticles.ts | 78 +++++++++-- .../ui/effects/thanosDisintegrate/types.ts | 7 + .../thanosDisintegrate/windTurbulence.ts | 127 ++++++++++++++++++ 19 files changed, 439 insertions(+), 28 deletions(-) create mode 100644 src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts create mode 100644 src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts create mode 100644 src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts index 7a507fa9..cc8248a9 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts @@ -20,6 +20,7 @@ const createParticle = (x: number, y: number): ThanosParticle => ({ fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, + turbulenceSeed: 0, }); describe("applyWaveOrigin", () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts index bbdc5ba0..1e877a23 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts @@ -25,6 +25,7 @@ const createParticle = ( fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, + turbulenceSeed: 0, ...overrides, }); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts index 985940d2..f4e7ddc7 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts @@ -73,6 +73,7 @@ const createParticle = ( fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, + turbulenceSeed: 0, ...overrides, }); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts index 48116cc8..f4c9db49 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts @@ -28,6 +28,7 @@ const createParticle = ( fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, + turbulenceSeed: 0, ...overrides, }); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts new file mode 100644 index 00000000..efb577bb --- /dev/null +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { createThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/createThanosParticle"; + +const baseInput = { + x: 50, + y: 25, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 100, + surfaceHeight: 50, +}; + +describe("createThanosParticle", () => { + it("biases splat particles away from the surface center", () => { + const particle = createThanosParticle({ + ...baseInput, + x: 80, + y: 10, + options: { + particleMotionMode: "splat", + maxVelocity: 100, + windX: 0, + windY: 0, + }, + }); + + expect(particle.vx).toBeGreaterThan(20); + expect(particle.turbulenceSeed).toBeGreaterThanOrEqual(0); + }); + + it("biases windy particles along the wind direction instead of radial burst", () => { + const particles = Array.from({ length: 24 }, () => + createThanosParticle({ + ...baseInput, + options: { + particleMotionMode: "windy", + maxVelocity: 100, + windX: 80, + windY: -10, + }, + }), + ); + + const averageVelocityX = + particles.reduce((sum, particle) => sum + particle.vx, 0) / + particles.length; + const averageSpeed = + particles.reduce( + (sum, particle) => sum + Math.hypot(particle.vx, particle.vy), + 0, + ) / particles.length; + + expect(averageVelocityX).toBeGreaterThan(10); + expect(averageSpeed).toBeLessThan(80); + }); +}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts index 34633b6f..da3581ae 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts @@ -22,6 +22,7 @@ const createParticle = ( fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, + turbulenceSeed: 0, ...overrides, }); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts index aae506db..651ed4fb 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts @@ -16,6 +16,7 @@ describe("getParticleCanvasPadding", () => { it("returns a smaller margin when motion is reduced", () => { const padding = getParticleCanvasPadding({ + particleMotionMode: "splat", maxVelocity: 40, gravity: 40, maxDuration: 0.5, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts index 05054476..8df96871 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts @@ -20,6 +20,7 @@ const createParticle = (releaseTime: number): ThanosParticle => ({ fadeStart: 0.5, fadeDuration: 0.4, releaseTime, + turbulenceSeed: 0, }); describe("getWaveDisintegrationProgress", () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts index 4067b6b3..5e1b9ec4 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts @@ -36,6 +36,7 @@ describe("scaleParticleCoordinates", () => { fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, + turbulenceSeed: 0, }; scaleParticleCoordinates([particle], 0.5, 0.5); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts index 6708f7a6..b1a4573f 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts @@ -41,6 +41,7 @@ const createParticle = () => ({ fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, + turbulenceSeed: 0, }); const createElement = () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts index f2d22960..ff96d7ca 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts @@ -13,6 +13,7 @@ describe("stepParticles", () => { surfaceWidth: 100, surfaceHeight: 40, options: { + particleMotionMode: "splat", gravity: 0, windX: 0, windY: 0, @@ -24,7 +25,12 @@ describe("stepParticles", () => { particle.vy = 0; particle.drag = 1; - stepParticles([particle], 1 / 60, 1 / 60); + stepParticles([particle], 1 / 60, 1 / 60, { + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + }); const positionAfterOneFrame = particle.x; const secondParticle = { @@ -34,9 +40,61 @@ describe("stepParticles", () => { vx: 60, vy: 0, }; - stepParticles([secondParticle], 1 / 120, 1 / 120); - stepParticles([secondParticle], 1 / 120, 1 / 60); + stepParticles([secondParticle], 1 / 120, 1 / 120, { + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + }); + stepParticles([secondParticle], 1 / 120, 1 / 60, { + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + }); expect(secondParticle.x).toBeCloseTo(positionAfterOneFrame, 2); }); + + it("curves windy particles with turbulent flow instead of a straight drift", () => { + const particle = createThanosParticle({ + x: 20, + y: 20, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 120, + surfaceHeight: 60, + options: { + particleMotionMode: "windy", + maxVelocity: 40, + windX: 60, + windY: -8, + gravity: 40, + }, + }); + particle.releaseTime = 0; + particle.vx = 10; + particle.vy = 0; + particle.drag = 1; + + const positions: Array<{ x: number; y: number }> = []; + for (let frame = 1; frame <= 30; frame += 1) { + stepParticles([particle], 1 / 60, frame / 60, { + particleMotionMode: "windy", + windX: 60, + windY: -8, + gravity: 40, + }); + positions.push({ x: particle.x, y: particle.y }); + } + + const start = positions[0]; + const end = positions[positions.length - 1]; + const midpoint = positions[Math.floor(positions.length / 2)]; + + expect(end?.x).toBeGreaterThan((start?.x ?? 0) + 8); + expect( + Math.abs((midpoint?.y ?? 0) - ((start?.y ?? 0) + (end?.y ?? 0)) / 2), + ).toBeGreaterThan(0.5); + }); }); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts new file mode 100644 index 00000000..0d02fe39 --- /dev/null +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + sampleWindFlow, + sampleWindNoise2D, +} from "#/shared/ui/effects/thanosDisintegrate/windTurbulence"; + +describe("windTurbulence", () => { + it("returns smooth, bounded noise values", () => { + const samples = Array.from({ length: 20 }, (_, index) => + sampleWindNoise2D(index * 0.25, index * 0.1), + ); + + for (const value of samples) { + expect(value).toBeGreaterThanOrEqual(-1.5); + expect(value).toBeLessThanOrEqual(1.5); + } + + expect(samples[1]).not.toBe(samples[0]); + }); + + it("produces nearby flow samples that are correlated but not identical", () => { + const first = sampleWindFlow(40, 60, 0.2, 12); + const nearby = sampleWindFlow(42, 61, 0.2, 12); + const distant = sampleWindFlow(240, 180, 0.2, 12); + + expect(Math.hypot(first.forceX, first.forceY)).toBeGreaterThan(0.2); + expect(Math.hypot(nearby.forceX, nearby.forceY)).toBeGreaterThan(0.2); + expect(first.forceX).not.toBeCloseTo(distant.forceX, 1); + expect(first.forceY).not.toBeCloseTo(distant.forceY, 1); + }); + + it("evolves the flow field over time", () => { + const early = sampleWindFlow(80, 40, 0.1, 5); + const later = sampleWindFlow(80, 40, 0.8, 5); + + expect(early.forceX).not.toBeCloseTo(later.forceX, 2); + expect(early.forceY).not.toBeCloseTo(later.forceY, 2); + }); +}); diff --git a/src/shared/ui/effects/thanosDisintegrate/constants.ts b/src/shared/ui/effects/thanosDisintegrate/constants.ts index e8bd93d1..1e83d69e 100644 --- a/src/shared/ui/effects/thanosDisintegrate/constants.ts +++ b/src/shared/ui/effects/thanosDisintegrate/constants.ts @@ -15,4 +15,5 @@ export const THANOS_DISINTEGRATE_DEFAULTS = { maskSpreadDuration: 0.6, particleRenderMode: "color", useChunkMaskWorker: true, + particleMotionMode: "windy", } as const satisfies ResolvedThanosDisintegrateOptions; diff --git a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts b/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts index 46638fb1..b1d62a04 100644 --- a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts +++ b/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts @@ -14,17 +14,13 @@ type CreateParticleInput = { options?: ThanosDisintegrateOptions; }; -/** Creates one particle with outward-biased velocity, drag, rotation, and fade timing. */ -export const createThanosParticle = ({ - x, - y, - color, - alpha, - surfaceWidth, - surfaceHeight, - options, -}: CreateParticleInput): ThanosParticle => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); +const createSplatVelocity = ( + x: number, + y: number, + surfaceWidth: number, + surfaceHeight: number, + resolvedOptions: ReturnType, +): { vx: number; vy: number } => { const centerX = surfaceWidth / 2; const centerY = surfaceHeight / 2; const deltaX = x - centerX; @@ -38,22 +34,70 @@ export const createThanosParticle = ({ const angle = baseAngle + spread; const speed = resolvedOptions.maxVelocity * (0.45 + Math.random() * 0.75); + return { + vx: Math.cos(angle) * speed + resolvedOptions.windX, + vy: Math.sin(angle) * speed + resolvedOptions.windY, + }; +}; + +const createWindyVelocity = ( + resolvedOptions: ReturnType, +): { vx: number; vy: number } => { + const windAngle = Math.atan2( + resolvedOptions.windY, + Math.max(12, Math.abs(resolvedOptions.windX)), + ); + const spread = (Math.random() - 0.5) * 1.1; + const angle = windAngle + spread; + const speed = resolvedOptions.maxVelocity * (0.12 + Math.random() * 0.38); + + return { + vx: + Math.cos(angle) * speed + + resolvedOptions.windX * (0.35 + Math.random() * 0.25), + vy: + Math.sin(angle) * speed + + resolvedOptions.windY * 0.35 - + resolvedOptions.maxVelocity * (0.04 + Math.random() * 0.08), + }; +}; + +/** Creates one particle with outward-biased velocity, drag, rotation, and fade timing. */ +export const createThanosParticle = ({ + x, + y, + color, + alpha, + surfaceWidth, + surfaceHeight, + options, +}: CreateParticleInput): ThanosParticle => { + const resolvedOptions = resolveThanosDisintegrateOptions(options); + const velocity = + resolvedOptions.particleMotionMode === "windy" + ? createWindyVelocity(resolvedOptions) + : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); + return { x, y, originX: x, originY: y, - vx: Math.cos(angle) * speed + resolvedOptions.windX, - vy: Math.sin(angle) * speed + resolvedOptions.windY, + vx: velocity.vx, + vy: velocity.vy, color, alpha, baseAlpha: alpha, size: resolvedOptions.particleSize * (0.75 + Math.random() * 0.45), rotation: Math.random() * Math.PI * 2, rotationSpeed: (Math.random() - 0.5) * 5, - drag: 0.955 + Math.random() * 0.03, + drag: + resolvedOptions.particleMotionMode === "windy" + ? 0.972 + Math.random() * 0.02 + : 0.955 + Math.random() * 0.03, fadeStart: 0.45 + Math.random() * 0.25, fadeDuration: 0.3 + Math.random() * 0.35, releaseTime: 0, + turbulenceSeed: Math.random() * 1000, }; }; diff --git a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts index 6f64f8b4..5b554367 100644 --- a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts @@ -7,12 +7,17 @@ export const getParticleMaxTravel = ( ): number => { const resolvedOptions = resolveThanosDisintegrateOptions(options); const duration = resolvedOptions.maxDuration; - return ( + const baseTravel = resolvedOptions.maxVelocity * duration + 0.5 * resolvedOptions.gravity * duration * duration + Math.abs(resolvedOptions.windX) * duration + - Math.abs(resolvedOptions.windY) * duration - ); + Math.abs(resolvedOptions.windY) * duration; + + if (resolvedOptions.particleMotionMode === "windy") { + return baseTravel * 1.65 + 48; + } + + return baseTravel; }; /** Margin around each chunk hole on the particle mask so flying pixels stay visible. */ diff --git a/src/shared/ui/effects/thanosDisintegrate/index.ts b/src/shared/ui/effects/thanosDisintegrate/index.ts index 2722edcc..d4fcab10 100644 --- a/src/shared/ui/effects/thanosDisintegrate/index.ts +++ b/src/shared/ui/effects/thanosDisintegrate/index.ts @@ -14,6 +14,7 @@ export type { ThanosDisintegrateOrigin, ThanosDisintegrateMaskMode, ThanosMaskStrategy, + ThanosParticleMotionMode, ThanosParticleRenderMode, ThanosParticle, } from "#/shared/ui/effects/thanosDisintegrate/types"; diff --git a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts b/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts index 958e014f..5f3ff075 100644 --- a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts @@ -3,6 +3,11 @@ import type { ThanosDisintegrateOptions, ThanosParticle, } from "#/shared/ui/effects/thanosDisintegrate/types"; +import { sampleWindFlow } from "#/shared/ui/effects/thanosDisintegrate/windTurbulence"; + +const WINDY_FLOW_STRENGTH = 320; +const WINDY_WIND_MULTIPLIER = 1.8; +const WINDY_GRAVITY_MULTIPLIER = 0.35; const applyDrag = (velocity: number, drag: number, deltaSeconds: number) => velocity * Math.pow(drag, deltaSeconds * 60); @@ -22,6 +27,51 @@ const updateFadeAlpha = ( particle.alpha = particle.baseAlpha * Math.max(0, 1 - fadeProgress); }; +const stepSplatParticle = ( + particle: ThanosParticle, + deltaSeconds: number, + windX: number, + windY: number, + gravity: number, +): void => { + particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); + particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); + particle.vx += windX * deltaSeconds; + particle.vy += gravity * deltaSeconds + windY * deltaSeconds; + particle.x += particle.vx * deltaSeconds; + particle.y += particle.vy * deltaSeconds; +}; + +const stepWindyParticle = ( + particle: ThanosParticle, + deltaSeconds: number, + elapsedSeconds: number, + windX: number, + windY: number, + gravity: number, +): void => { + particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); + particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); + + const flow = sampleWindFlow( + particle.x, + particle.y, + elapsedSeconds, + particle.turbulenceSeed, + ); + + particle.vx += + (flow.forceX * WINDY_FLOW_STRENGTH + windX * WINDY_WIND_MULTIPLIER) * + deltaSeconds; + particle.vy += + (flow.forceY * WINDY_FLOW_STRENGTH + + windY * WINDY_WIND_MULTIPLIER + + gravity * WINDY_GRAVITY_MULTIPLIER) * + deltaSeconds; + particle.x += particle.vx * deltaSeconds; + particle.y += particle.vy * deltaSeconds; +}; + /** Advances particle physics using delta time (stable on high-refresh displays). */ export const stepParticles = ( particles: ThanosParticle[], @@ -39,14 +89,26 @@ export const stepParticles = ( } const timeSinceRelease = elapsedSeconds - particle.releaseTime; - particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); - particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); - particle.vx += resolvedOptions.windX * deltaSeconds; - particle.vy += - resolvedOptions.gravity * deltaSeconds + - resolvedOptions.windY * deltaSeconds; - particle.x += particle.vx * deltaSeconds; - particle.y += particle.vy * deltaSeconds; + + if (resolvedOptions.particleMotionMode === "windy") { + stepWindyParticle( + particle, + deltaSeconds, + elapsedSeconds, + resolvedOptions.windX, + resolvedOptions.windY, + resolvedOptions.gravity, + ); + } else { + stepSplatParticle( + particle, + deltaSeconds, + resolvedOptions.windX, + resolvedOptions.windY, + resolvedOptions.gravity, + ); + } + particle.rotation += particle.rotationSpeed * deltaSeconds; updateFadeAlpha(particle, timeSinceRelease); diff --git a/src/shared/ui/effects/thanosDisintegrate/types.ts b/src/shared/ui/effects/thanosDisintegrate/types.ts index e3b964ef..a2c33368 100644 --- a/src/shared/ui/effects/thanosDisintegrate/types.ts +++ b/src/shared/ui/effects/thanosDisintegrate/types.ts @@ -20,6 +20,9 @@ export type ThanosMaskStrategy = export type ThanosParticleRenderMode = "color" | "sprite"; +/** Outward burst ("splat") or wind-carried turbulent drift ("windy"). */ +export type ThanosParticleMotionMode = "splat" | "windy"; + export type ThanosParticle = { x: number; y: number; @@ -42,6 +45,8 @@ export type ThanosParticle = { fadeDuration: number; /** Seconds from animation start when this particle begins to crumble. */ releaseTime: number; + /** Per-particle phase offset for windy flow-field sampling. */ + turbulenceSeed: number; }; export type ThanosDisintegrateOrigin = @@ -81,6 +86,8 @@ export type ThanosDisintegrateOptions = { particleRenderMode?: ThanosParticleRenderMode; /** Build chunk masks in a Web Worker when available. */ useChunkMaskWorker?: boolean; + /** Particle physics style after release (defaults to windy). */ + particleMotionMode?: ThanosParticleMotionMode; }; /** Options with defaults applied; excludes runtime-only `origin` and strategy defaulting. */ diff --git a/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts b/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts new file mode 100644 index 00000000..39c3aa3a --- /dev/null +++ b/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts @@ -0,0 +1,127 @@ +/** + * Lightweight 2D value noise for windy particle steering. + * Produces smooth, spatially continuous gusts without external dependencies. + */ + +const PERMUTATION_SIZE = 256; +const PERMUTATION_TABLE = new Uint8Array(PERMUTATION_SIZE * 2); + +let isPermutationInitialized = false; + +const initializePermutationTable = (): void => { + if (isPermutationInitialized) { + return; + } + + for (let index = 0; index < PERMUTATION_SIZE; index += 1) { + PERMUTATION_TABLE[index] = index; + } + + for (let index = PERMUTATION_SIZE - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + const current = PERMUTATION_TABLE[index] ?? 0; + PERMUTATION_TABLE[index] = PERMUTATION_TABLE[swapIndex] ?? 0; + PERMUTATION_TABLE[swapIndex] = current; + } + + for (let index = 0; index < PERMUTATION_SIZE; index += 1) { + PERMUTATION_TABLE[index + PERMUTATION_SIZE] = PERMUTATION_TABLE[index] ?? 0; + } + + isPermutationInitialized = true; +}; + +const fade = (value: number): number => + value * value * value * (value * (value * 6 - 15) + 10); + +const lerp = (start: number, end: number, amount: number): number => + start + amount * (end - start); + +const grad = (hash: number, x: number, y: number): number => { + const corner = hash & 3; + const axisU = corner < 2 ? x : y; + const axisV = corner < 2 ? y : x; + return ( + ((corner & 1) === 0 ? axisU : -axisU) + + ((corner & 2) === 0 ? axisV : -axisV) + ); +}; + +/** Classic Perlin-style 2D noise in approximately [-1, 1]. */ +export const sampleWindNoise2D = (x: number, y: number): number => { + initializePermutationTable(); + + const cellX = Math.floor(x) & 255; + const cellY = Math.floor(y) & 255; + const localX = x - Math.floor(x); + const localY = y - Math.floor(y); + const fadeX = fade(localX); + const fadeY = fade(localY); + + const aa = PERMUTATION_TABLE[cellX] ?? 0; + const ab = PERMUTATION_TABLE[cellX + 1] ?? 0; + const hashA = PERMUTATION_TABLE[(aa + cellY) & 255] ?? 0; + const hashB = PERMUTATION_TABLE[(ab + cellY) & 255] ?? 0; + const hashC = PERMUTATION_TABLE[(aa + cellY + 1) & 255] ?? 0; + const hashD = PERMUTATION_TABLE[(ab + cellY + 1) & 255] ?? 0; + + const x1 = lerp( + grad(hashA, localX, localY), + grad(hashB, localX - 1, localY), + fadeX, + ); + const x2 = lerp( + grad(hashC, localX, localY - 1), + grad(hashD, localX - 1, localY - 1), + fadeX, + ); + + return lerp(x1, x2, fadeY); +}; + +const WIND_NOISE_SCALE = 0.012; +const WIND_TIME_SCALE = 1.4; + +export type WindFlowSample = { + forceX: number; + forceY: number; +}; + +/** + * Samples a time-varying flow field at a particle position. + * Nearby particles receive similar forces; gusts evolve over the animation. + */ +export const sampleWindFlow = ( + x: number, + y: number, + elapsedSeconds: number, + turbulenceSeed: number, +): WindFlowSample => { + const timeOffset = elapsedSeconds * WIND_TIME_SCALE + turbulenceSeed; + const noiseX = x * WIND_NOISE_SCALE; + const noiseY = y * WIND_NOISE_SCALE; + + const flowAngle = + sampleWindNoise2D(noiseX + timeOffset, noiseY - timeOffset * 0.65) * + Math.PI * + 2.2 + + sampleWindNoise2D( + noiseX * 1.7 - timeOffset * 0.4, + noiseY * 1.3 + timeOffset * 0.55 + turbulenceSeed, + ) * + Math.PI * + 0.9; + + const gustStrength = + 0.55 + + 0.45 * + sampleWindNoise2D( + noiseX * 0.6 + turbulenceSeed * 0.2, + noiseY * 0.6 - timeOffset * 0.25, + ); + + return { + forceX: Math.cos(flowAngle) * gustStrength, + forceY: Math.sin(flowAngle) * gustStrength, + }; +}; From 9e08a0bea091c6bf36f9500a70fa9e388c57d73e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 21:59:03 +0000 Subject: [PATCH 02/21] Stabilize windy stepParticles test with fixed turbulence seed --- .../__tests__/stepParticles.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts index ff96d7ca..f23459d3 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts @@ -76,9 +76,12 @@ describe("stepParticles", () => { particle.vx = 10; particle.vy = 0; particle.drag = 1; + particle.turbulenceSeed = 42; - const positions: Array<{ x: number; y: number }> = []; - for (let frame = 1; frame <= 30; frame += 1) { + const positions: Array<{ x: number; y: number }> = [ + { x: particle.x, y: particle.y }, + ]; + for (let frame = 1; frame <= 90; frame += 1) { stepParticles([particle], 1 / 60, frame / 60, { particleMotionMode: "windy", windX: 60, @@ -91,10 +94,11 @@ describe("stepParticles", () => { const start = positions[0]; const end = positions[positions.length - 1]; const midpoint = positions[Math.floor(positions.length / 2)]; + const straightLineMidY = ((start?.y ?? 0) + (end?.y ?? 0)) / 2; - expect(end?.x).toBeGreaterThan((start?.x ?? 0) + 8); - expect( - Math.abs((midpoint?.y ?? 0) - ((start?.y ?? 0) + (end?.y ?? 0)) / 2), - ).toBeGreaterThan(0.5); + expect(end?.x).toBeGreaterThan((start?.x ?? 0) + 4); + expect(Math.abs((midpoint?.y ?? 0) - straightLineMidY)).toBeGreaterThan( + 0.25, + ); }); }); From bf9f10e34be4c9af236fc8701c0171fc8c692921 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:00:45 +0000 Subject: [PATCH 03/21] Retune windy mode to fire-spark motion with buoyant rise Replace horizontal flow-field gusts with upward spark launch, high-frequency flutter, decaying buoyancy lift, and a gravity arc. Sparks fade faster and wobble more like embers than wind-blown dust. --- .../__tests__/createThanosParticle.test.ts | 15 +++--- .../__tests__/stepParticles.test.ts | 42 +++++++-------- .../__tests__/windTurbulence.test.ts | 22 ++++---- .../effects/thanosDisintegrate/constants.ts | 6 +-- .../createThanosParticle.ts | 44 ++++++++------- .../getParticleCanvasPadding.ts | 3 +- .../thanosDisintegrate/stepParticles.ts | 32 ++++++----- .../ui/effects/thanosDisintegrate/types.ts | 2 +- .../thanosDisintegrate/windTurbulence.ts | 54 +++++++------------ 9 files changed, 100 insertions(+), 120 deletions(-) diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts index efb577bb..196d9409 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts @@ -29,21 +29,21 @@ describe("createThanosParticle", () => { expect(particle.turbulenceSeed).toBeGreaterThanOrEqual(0); }); - it("biases windy particles along the wind direction instead of radial burst", () => { + it("launches windy particles upward like fire sparks", () => { const particles = Array.from({ length: 24 }, () => createThanosParticle({ ...baseInput, options: { particleMotionMode: "windy", maxVelocity: 100, - windX: 80, - windY: -10, + windX: 14, + windY: -8, }, }), ); - const averageVelocityX = - particles.reduce((sum, particle) => sum + particle.vx, 0) / + const averageVelocityY = + particles.reduce((sum, particle) => sum + particle.vy, 0) / particles.length; const averageSpeed = particles.reduce( @@ -51,7 +51,8 @@ describe("createThanosParticle", () => { 0, ) / particles.length; - expect(averageVelocityX).toBeGreaterThan(10); - expect(averageSpeed).toBeLessThan(80); + expect(averageVelocityY).toBeLessThan(-8); + expect(averageSpeed).toBeGreaterThan(20); + expect(averageSpeed).toBeLessThan(90); }); }); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts index f23459d3..ea670847 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts @@ -56,49 +56,43 @@ describe("stepParticles", () => { expect(secondParticle.x).toBeCloseTo(positionAfterOneFrame, 2); }); - it("curves windy particles with turbulent flow instead of a straight drift", () => { + it("arcs windy particles upward before gravity pulls them down", () => { const particle = createThanosParticle({ x: 20, - y: 20, + y: 40, color: "rgb(255, 255, 255)", alpha: 1, surfaceWidth: 120, surfaceHeight: 60, options: { particleMotionMode: "windy", - maxVelocity: 40, - windX: 60, - windY: -8, - gravity: 40, + maxVelocity: 80, + windX: 10, + windY: -6, + gravity: 240, }, }); particle.releaseTime = 0; - particle.vx = 10; - particle.vy = 0; + particle.vx = 6; + particle.vy = -40; particle.drag = 1; particle.turbulenceSeed = 42; - const positions: Array<{ x: number; y: number }> = [ - { x: particle.x, y: particle.y }, - ]; - for (let frame = 1; frame <= 90; frame += 1) { + const yPositions: number[] = [particle.y]; + for (let frame = 1; frame <= 120; frame += 1) { stepParticles([particle], 1 / 60, frame / 60, { particleMotionMode: "windy", - windX: 60, - windY: -8, - gravity: 40, + windX: 10, + windY: -6, + gravity: 240, }); - positions.push({ x: particle.x, y: particle.y }); + yPositions.push(particle.y); } - const start = positions[0]; - const end = positions[positions.length - 1]; - const midpoint = positions[Math.floor(positions.length / 2)]; - const straightLineMidY = ((start?.y ?? 0) + (end?.y ?? 0)) / 2; + const lowestY = Math.min(...yPositions); + const highestY = Math.max(...yPositions); - expect(end?.x).toBeGreaterThan((start?.x ?? 0) + 4); - expect(Math.abs((midpoint?.y ?? 0) - straightLineMidY)).toBeGreaterThan( - 0.25, - ); + expect(lowestY).toBeLessThan(40); + expect(highestY).toBeGreaterThan(lowestY + 6); }); }); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts index 0d02fe39..9f1caa1e 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - sampleWindFlow, + sampleSparkFlutter, sampleWindNoise2D, } from "#/shared/ui/effects/thanosDisintegrate/windTurbulence"; @@ -19,20 +19,18 @@ describe("windTurbulence", () => { expect(samples[1]).not.toBe(samples[0]); }); - it("produces nearby flow samples that are correlated but not identical", () => { - const first = sampleWindFlow(40, 60, 0.2, 12); - const nearby = sampleWindFlow(42, 61, 0.2, 12); - const distant = sampleWindFlow(240, 180, 0.2, 12); + it("produces bounded flutter forces per particle", () => { + const first = sampleSparkFlutter(0.2, 12); + const second = sampleSparkFlutter(0.2, 84); - expect(Math.hypot(first.forceX, first.forceY)).toBeGreaterThan(0.2); - expect(Math.hypot(nearby.forceX, nearby.forceY)).toBeGreaterThan(0.2); - expect(first.forceX).not.toBeCloseTo(distant.forceX, 1); - expect(first.forceY).not.toBeCloseTo(distant.forceY, 1); + expect(Math.hypot(first.forceX, first.forceY)).toBeLessThan(1.2); + expect(Math.hypot(second.forceX, second.forceY)).toBeLessThan(1.2); + expect(first.forceX).not.toBeCloseTo(second.forceX, 2); }); - it("evolves the flow field over time", () => { - const early = sampleWindFlow(80, 40, 0.1, 5); - const later = sampleWindFlow(80, 40, 0.8, 5); + it("evolves flutter over time for wavering spark motion", () => { + const early = sampleSparkFlutter(0.1, 5); + const later = sampleSparkFlutter(0.8, 5); expect(early.forceX).not.toBeCloseTo(later.forceX, 2); expect(early.forceY).not.toBeCloseTo(later.forceY, 2); diff --git a/src/shared/ui/effects/thanosDisintegrate/constants.ts b/src/shared/ui/effects/thanosDisintegrate/constants.ts index 1e83d69e..e3da1384 100644 --- a/src/shared/ui/effects/thanosDisintegrate/constants.ts +++ b/src/shared/ui/effects/thanosDisintegrate/constants.ts @@ -4,9 +4,9 @@ export const THANOS_DISINTEGRATE_DEFAULTS = { particleStep: 3, particleSize: 2, maxVelocity: 165, - windX: 20, - windY: -14, - gravity: 180, + windX: 14, + windY: -8, + gravity: 240, maxDuration: 1.0, zIndex: 9999, waveSpeed: 700, diff --git a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts b/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts index b1d62a04..abae1049 100644 --- a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts +++ b/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts @@ -43,22 +43,17 @@ const createSplatVelocity = ( const createWindyVelocity = ( resolvedOptions: ReturnType, ): { vx: number; vy: number } => { - const windAngle = Math.atan2( - resolvedOptions.windY, - Math.max(12, Math.abs(resolvedOptions.windX)), - ); - const spread = (Math.random() - 0.5) * 1.1; - const angle = windAngle + spread; - const speed = resolvedOptions.maxVelocity * (0.12 + Math.random() * 0.38); + const launchAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.35; + const speed = resolvedOptions.maxVelocity * (0.32 + Math.random() * 0.58); return { vx: - Math.cos(angle) * speed + - resolvedOptions.windX * (0.35 + Math.random() * 0.25), + Math.cos(launchAngle) * speed * 0.42 + + resolvedOptions.windX * 0.25 + + (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.18, vy: - Math.sin(angle) * speed + - resolvedOptions.windY * 0.35 - - resolvedOptions.maxVelocity * (0.04 + Math.random() * 0.08), + Math.sin(launchAngle) * speed - + resolvedOptions.maxVelocity * (0.08 + Math.random() * 0.14), }; }; @@ -73,10 +68,10 @@ export const createThanosParticle = ({ options, }: CreateParticleInput): ThanosParticle => { const resolvedOptions = resolveThanosDisintegrateOptions(options); - const velocity = - resolvedOptions.particleMotionMode === "windy" - ? createWindyVelocity(resolvedOptions) - : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); + const isWindy = resolvedOptions.particleMotionMode === "windy"; + const velocity = isWindy + ? createWindyVelocity(resolvedOptions) + : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); return { x, @@ -90,13 +85,16 @@ export const createThanosParticle = ({ baseAlpha: alpha, size: resolvedOptions.particleSize * (0.75 + Math.random() * 0.45), rotation: Math.random() * Math.PI * 2, - rotationSpeed: (Math.random() - 0.5) * 5, - drag: - resolvedOptions.particleMotionMode === "windy" - ? 0.972 + Math.random() * 0.02 - : 0.955 + Math.random() * 0.03, - fadeStart: 0.45 + Math.random() * 0.25, - fadeDuration: 0.3 + Math.random() * 0.35, + rotationSpeed: (Math.random() - 0.5) * (isWindy ? 9 : 5), + drag: isWindy + ? 0.978 + Math.random() * 0.015 + : 0.955 + Math.random() * 0.03, + fadeStart: isWindy + ? 0.22 + Math.random() * 0.18 + : 0.45 + Math.random() * 0.25, + fadeDuration: isWindy + ? 0.22 + Math.random() * 0.24 + : 0.3 + Math.random() * 0.35, releaseTime: 0, turbulenceSeed: Math.random() * 1000, }; diff --git a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts index 5b554367..80a6b0ca 100644 --- a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts @@ -14,7 +14,8 @@ export const getParticleMaxTravel = ( Math.abs(resolvedOptions.windY) * duration; if (resolvedOptions.particleMotionMode === "windy") { - return baseTravel * 1.65 + 48; + const upwardTravel = resolvedOptions.maxVelocity * 0.75; + return baseTravel * 1.45 + upwardTravel + 36; } return baseTravel; diff --git a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts b/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts index 5f3ff075..94abf77f 100644 --- a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts @@ -3,11 +3,13 @@ import type { ThanosDisintegrateOptions, ThanosParticle, } from "#/shared/ui/effects/thanosDisintegrate/types"; -import { sampleWindFlow } from "#/shared/ui/effects/thanosDisintegrate/windTurbulence"; +import { sampleSparkFlutter } from "#/shared/ui/effects/thanosDisintegrate/windTurbulence"; -const WINDY_FLOW_STRENGTH = 320; -const WINDY_WIND_MULTIPLIER = 1.8; -const WINDY_GRAVITY_MULTIPLIER = 0.35; +const SPARK_FLUTTER_STRENGTH = 210; +const SPARK_BUOYANCY = -260; +const SPARK_BUOYANCY_DECAY = 5.2; +const SPARK_GRAVITY_RAMP_SECONDS = 0.18; +const SPARK_WIND_MULTIPLIER = 0.55; const applyDrag = (velocity: number, drag: number, deltaSeconds: number) => velocity * Math.pow(drag, deltaSeconds * 60); @@ -46,6 +48,7 @@ const stepWindyParticle = ( particle: ThanosParticle, deltaSeconds: number, elapsedSeconds: number, + timeSinceRelease: number, windX: number, windY: number, gravity: number, @@ -53,20 +56,22 @@ const stepWindyParticle = ( particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); - const flow = sampleWindFlow( - particle.x, - particle.y, - elapsedSeconds, - particle.turbulenceSeed, + const flutter = sampleSparkFlutter(elapsedSeconds, particle.turbulenceSeed); + const buoyancy = + SPARK_BUOYANCY * Math.exp(-timeSinceRelease * SPARK_BUOYANCY_DECAY); + const gravityRamp = Math.min( + 1, + timeSinceRelease / SPARK_GRAVITY_RAMP_SECONDS, ); particle.vx += - (flow.forceX * WINDY_FLOW_STRENGTH + windX * WINDY_WIND_MULTIPLIER) * + (flutter.forceX * SPARK_FLUTTER_STRENGTH + windX * SPARK_WIND_MULTIPLIER) * deltaSeconds; particle.vy += - (flow.forceY * WINDY_FLOW_STRENGTH + - windY * WINDY_WIND_MULTIPLIER + - gravity * WINDY_GRAVITY_MULTIPLIER) * + (flutter.forceY * SPARK_FLUTTER_STRENGTH + + buoyancy + + gravity * gravityRamp + + windY * SPARK_WIND_MULTIPLIER) * deltaSeconds; particle.x += particle.vx * deltaSeconds; particle.y += particle.vy * deltaSeconds; @@ -95,6 +100,7 @@ export const stepParticles = ( particle, deltaSeconds, elapsedSeconds, + timeSinceRelease, resolvedOptions.windX, resolvedOptions.windY, resolvedOptions.gravity, diff --git a/src/shared/ui/effects/thanosDisintegrate/types.ts b/src/shared/ui/effects/thanosDisintegrate/types.ts index a2c33368..cdfaf729 100644 --- a/src/shared/ui/effects/thanosDisintegrate/types.ts +++ b/src/shared/ui/effects/thanosDisintegrate/types.ts @@ -20,7 +20,7 @@ export type ThanosMaskStrategy = export type ThanosParticleRenderMode = "color" | "sprite"; -/** Outward burst ("splat") or wind-carried turbulent drift ("windy"). */ +/** Outward burst ("splat") or fire-spark flutter with buoyant rise ("windy"). */ export type ThanosParticleMotionMode = "splat" | "windy"; export type ThanosParticle = { diff --git a/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts b/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts index 39c3aa3a..dfe89fe1 100644 --- a/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts +++ b/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts @@ -1,6 +1,6 @@ /** - * Lightweight 2D value noise for windy particle steering. - * Produces smooth, spatially continuous gusts without external dependencies. + * Lightweight turbulence for windy (fire-spark) particle steering. + * Combines high-frequency flutter with smooth noise for organic wavering. */ const PERMUTATION_SIZE = 256; @@ -79,49 +79,31 @@ export const sampleWindNoise2D = (x: number, y: number): number => { return lerp(x1, x2, fadeY); }; -const WIND_NOISE_SCALE = 0.012; -const WIND_TIME_SCALE = 1.4; - -export type WindFlowSample = { +export type SparkFlutterSample = { forceX: number; forceY: number; }; /** - * Samples a time-varying flow field at a particle position. - * Nearby particles receive similar forces; gusts evolve over the animation. + * High-frequency flutter forces that make sparks waver and zig-zag in the air. + * Each particle uses its own phase via turbulenceSeed. */ -export const sampleWindFlow = ( - x: number, - y: number, +export const sampleSparkFlutter = ( elapsedSeconds: number, turbulenceSeed: number, -): WindFlowSample => { - const timeOffset = elapsedSeconds * WIND_TIME_SCALE + turbulenceSeed; - const noiseX = x * WIND_NOISE_SCALE; - const noiseY = y * WIND_NOISE_SCALE; - - const flowAngle = - sampleWindNoise2D(noiseX + timeOffset, noiseY - timeOffset * 0.65) * - Math.PI * - 2.2 + - sampleWindNoise2D( - noiseX * 1.7 - timeOffset * 0.4, - noiseY * 1.3 + timeOffset * 0.55 + turbulenceSeed, - ) * - Math.PI * - 0.9; - - const gustStrength = - 0.55 + - 0.45 * - sampleWindNoise2D( - noiseX * 0.6 + turbulenceSeed * 0.2, - noiseY * 0.6 - timeOffset * 0.25, - ); +): SparkFlutterSample => { + const phase = elapsedSeconds + turbulenceSeed * 0.017; + const drift = sampleWindNoise2D(phase * 0.75, turbulenceSeed * 0.04) * 0.18; return { - forceX: Math.cos(flowAngle) * gustStrength, - forceY: Math.sin(flowAngle) * gustStrength, + forceX: + Math.sin(phase * 19.3) * 0.42 + + Math.sin(phase * 33.7 + turbulenceSeed) * 0.28 + + Math.cos(phase * 47.1) * 0.14 + + drift, + forceY: + Math.cos(phase * 24.5 + turbulenceSeed * 0.5) * 0.16 + + Math.sin(phase * 38.9) * 0.1 + + drift * 0.35, }; }; From 5688d501287e249f042601a8a81490e2def53cfc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:02:17 +0000 Subject: [PATCH 04/21] Refactor spark motion: drop Perlin noise, consolidate physics - Remove 80-line windTurbulence.ts (Perlin only contributed tiny drift) - Add sparkFlutter.ts and sparkParticlePhysics.ts with clear naming - Centralize motion profiles and spark constants - Fix getParticleRevealMargin double-resolve - Drop noise implementation-detail test --- ...urbulence.test.ts => sparkFlutter.test.ts} | 22 +--- .../createThanosParticle.ts | 31 ++--- .../getParticleCanvasPadding.ts | 15 ++- .../thanosDisintegrate/sparkFlutter.ts | 24 ++++ .../sparkParticlePhysics.ts | 47 ++++++++ .../thanosDisintegrate/stepParticles.ts | 19 +-- .../thanosDisintegrate/windTurbulence.ts | 109 ------------------ 7 files changed, 111 insertions(+), 156 deletions(-) rename src/shared/ui/effects/thanosDisintegrate/__tests__/{windTurbulence.test.ts => sparkFlutter.test.ts} (52%) create mode 100644 src/shared/ui/effects/thanosDisintegrate/sparkFlutter.ts create mode 100644 src/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics.ts delete mode 100644 src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/sparkFlutter.test.ts similarity index 52% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts rename to src/shared/ui/effects/thanosDisintegrate/__tests__/sparkFlutter.test.ts index 9f1caa1e..7f99eba6 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/windTurbulence.test.ts +++ b/src/shared/ui/effects/thanosDisintegrate/__tests__/sparkFlutter.test.ts @@ -1,25 +1,9 @@ import { describe, expect, it } from "vitest"; -import { - sampleSparkFlutter, - sampleWindNoise2D, -} from "#/shared/ui/effects/thanosDisintegrate/windTurbulence"; +import { sampleSparkFlutter } from "#/shared/ui/effects/thanosDisintegrate/sparkFlutter"; -describe("windTurbulence", () => { - it("returns smooth, bounded noise values", () => { - const samples = Array.from({ length: 20 }, (_, index) => - sampleWindNoise2D(index * 0.25, index * 0.1), - ); - - for (const value of samples) { - expect(value).toBeGreaterThanOrEqual(-1.5); - expect(value).toBeLessThanOrEqual(1.5); - } - - expect(samples[1]).not.toBe(samples[0]); - }); - - it("produces bounded flutter forces per particle", () => { +describe("sampleSparkFlutter", () => { + it("produces bounded, per-particle flutter forces", () => { const first = sampleSparkFlutter(0.2, 12); const second = sampleSparkFlutter(0.2, 84); diff --git a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts b/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts index abae1049..86b77f26 100644 --- a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts +++ b/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts @@ -1,4 +1,5 @@ import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; +import { getParticleMotionProfile } from "#/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics"; import type { ThanosDisintegrateOptions, ThanosParticle, @@ -40,7 +41,7 @@ const createSplatVelocity = ( }; }; -const createWindyVelocity = ( +const createSparkVelocity = ( resolvedOptions: ReturnType, ): { vx: number; vy: number } => { const launchAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.35; @@ -68,10 +69,13 @@ export const createThanosParticle = ({ options, }: CreateParticleInput): ThanosParticle => { const resolvedOptions = resolveThanosDisintegrateOptions(options); - const isWindy = resolvedOptions.particleMotionMode === "windy"; - const velocity = isWindy - ? createWindyVelocity(resolvedOptions) - : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); + const motionProfile = getParticleMotionProfile( + resolvedOptions.particleMotionMode, + ); + const velocity = + resolvedOptions.particleMotionMode === "windy" + ? createSparkVelocity(resolvedOptions) + : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); return { x, @@ -85,16 +89,13 @@ export const createThanosParticle = ({ baseAlpha: alpha, size: resolvedOptions.particleSize * (0.75 + Math.random() * 0.45), rotation: Math.random() * Math.PI * 2, - rotationSpeed: (Math.random() - 0.5) * (isWindy ? 9 : 5), - drag: isWindy - ? 0.978 + Math.random() * 0.015 - : 0.955 + Math.random() * 0.03, - fadeStart: isWindy - ? 0.22 + Math.random() * 0.18 - : 0.45 + Math.random() * 0.25, - fadeDuration: isWindy - ? 0.22 + Math.random() * 0.24 - : 0.3 + Math.random() * 0.35, + rotationSpeed: (Math.random() - 0.5) * motionProfile.rotationSpeed, + drag: motionProfile.dragMin + Math.random() * motionProfile.dragRange, + fadeStart: + motionProfile.fadeStartMin + Math.random() * motionProfile.fadeStartRange, + fadeDuration: + motionProfile.fadeDurationMin + + Math.random() * motionProfile.fadeDurationRange, releaseTime: 0, turbulenceSeed: Math.random() * 1000, }; diff --git a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts index 80a6b0ca..bae820aa 100644 --- a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts @@ -1,4 +1,8 @@ import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; +import { + SPARK_TRAVEL_PADDING, + SPARK_UPWARD_TRAVEL_FACTOR, +} from "#/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics"; import type { ThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/types"; /** Max distance a particle can travel from its origin during the animation. */ @@ -14,8 +18,11 @@ export const getParticleMaxTravel = ( Math.abs(resolvedOptions.windY) * duration; if (resolvedOptions.particleMotionMode === "windy") { - const upwardTravel = resolvedOptions.maxVelocity * 0.75; - return baseTravel * 1.45 + upwardTravel + 36; + return ( + baseTravel * 1.45 + + resolvedOptions.maxVelocity * SPARK_UPWARD_TRAVEL_FACTOR + + SPARK_TRAVEL_PADDING + ); } return baseTravel; @@ -27,7 +34,7 @@ export const getParticleRevealMargin = ( ): number => { const resolvedOptions = resolveThanosDisintegrateOptions(options); return Math.ceil( - getParticleMaxTravel(resolvedOptions) + resolvedOptions.particleSize, + getParticleMaxTravel(options) + resolvedOptions.particleSize, ); }; @@ -37,6 +44,6 @@ export const getParticleCanvasPadding = ( ): number => { const resolvedOptions = resolveThanosDisintegrateOptions(options); return Math.ceil( - getParticleMaxTravel(resolvedOptions) + resolvedOptions.particleSize * 4, + getParticleMaxTravel(options) + resolvedOptions.particleSize * 4, ); }; diff --git a/src/shared/ui/effects/thanosDisintegrate/sparkFlutter.ts b/src/shared/ui/effects/thanosDisintegrate/sparkFlutter.ts new file mode 100644 index 00000000..cbaaad89 --- /dev/null +++ b/src/shared/ui/effects/thanosDisintegrate/sparkFlutter.ts @@ -0,0 +1,24 @@ +type SparkFlutterForces = { + forceX: number; + forceY: number; +}; + +/** High-frequency oscillation that makes sparks waver and zig-zag in the air. */ +export const sampleSparkFlutter = ( + elapsedSeconds: number, + turbulenceSeed: number, +): SparkFlutterForces => { + const phase = elapsedSeconds + turbulenceSeed * 0.017; + + return { + forceX: + Math.sin(phase * 19.3) * 0.42 + + Math.sin(phase * 33.7 + turbulenceSeed) * 0.28 + + Math.cos(phase * 47.1) * 0.14 + + Math.sin(phase * 11.2 + turbulenceSeed * 0.35) * 0.12, + forceY: + Math.cos(phase * 24.5 + turbulenceSeed * 0.5) * 0.16 + + Math.sin(phase * 38.9) * 0.1 + + Math.cos(phase * 15.8 + turbulenceSeed * 0.2) * 0.08, + }; +}; diff --git a/src/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics.ts new file mode 100644 index 00000000..963eb2bc --- /dev/null +++ b/src/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics.ts @@ -0,0 +1,47 @@ +import type { ThanosParticleMotionMode } from "#/shared/ui/effects/thanosDisintegrate/types"; + +export const SPARK_FLUTTER_STRENGTH = 210; +export const SPARK_BUOYANCY = -260; +export const SPARK_BUOYANCY_DECAY = 5.2; +export const SPARK_GRAVITY_RAMP_SECONDS = 0.18; +export const SPARK_WIND_MULTIPLIER = 0.55; +export const SPARK_UPWARD_TRAVEL_FACTOR = 0.75; +export const SPARK_TRAVEL_PADDING = 36; + +type ParticleMotionProfile = { + dragMin: number; + dragRange: number; + rotationSpeed: number; + fadeStartMin: number; + fadeStartRange: number; + fadeDurationMin: number; + fadeDurationRange: number; +}; + +const PARTICLE_MOTION_PROFILES: Record< + ThanosParticleMotionMode, + ParticleMotionProfile +> = { + splat: { + dragMin: 0.955, + dragRange: 0.03, + rotationSpeed: 5, + fadeStartMin: 0.45, + fadeStartRange: 0.25, + fadeDurationMin: 0.3, + fadeDurationRange: 0.35, + }, + windy: { + dragMin: 0.978, + dragRange: 0.015, + rotationSpeed: 9, + fadeStartMin: 0.22, + fadeStartRange: 0.18, + fadeDurationMin: 0.22, + fadeDurationRange: 0.24, + }, +}; + +export const getParticleMotionProfile = ( + motionMode: ThanosParticleMotionMode, +): ParticleMotionProfile => PARTICLE_MOTION_PROFILES[motionMode]; diff --git a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts b/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts index 94abf77f..78d7b925 100644 --- a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts @@ -1,15 +1,16 @@ import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; +import { sampleSparkFlutter } from "#/shared/ui/effects/thanosDisintegrate/sparkFlutter"; +import { + SPARK_BUOYANCY, + SPARK_BUOYANCY_DECAY, + SPARK_FLUTTER_STRENGTH, + SPARK_GRAVITY_RAMP_SECONDS, + SPARK_WIND_MULTIPLIER, +} from "#/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics"; import type { ThanosDisintegrateOptions, ThanosParticle, } from "#/shared/ui/effects/thanosDisintegrate/types"; -import { sampleSparkFlutter } from "#/shared/ui/effects/thanosDisintegrate/windTurbulence"; - -const SPARK_FLUTTER_STRENGTH = 210; -const SPARK_BUOYANCY = -260; -const SPARK_BUOYANCY_DECAY = 5.2; -const SPARK_GRAVITY_RAMP_SECONDS = 0.18; -const SPARK_WIND_MULTIPLIER = 0.55; const applyDrag = (velocity: number, drag: number, deltaSeconds: number) => velocity * Math.pow(drag, deltaSeconds * 60); @@ -44,7 +45,7 @@ const stepSplatParticle = ( particle.y += particle.vy * deltaSeconds; }; -const stepWindyParticle = ( +const stepSparkParticle = ( particle: ThanosParticle, deltaSeconds: number, elapsedSeconds: number, @@ -96,7 +97,7 @@ export const stepParticles = ( const timeSinceRelease = elapsedSeconds - particle.releaseTime; if (resolvedOptions.particleMotionMode === "windy") { - stepWindyParticle( + stepSparkParticle( particle, deltaSeconds, elapsedSeconds, diff --git a/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts b/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts deleted file mode 100644 index dfe89fe1..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/windTurbulence.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Lightweight turbulence for windy (fire-spark) particle steering. - * Combines high-frequency flutter with smooth noise for organic wavering. - */ - -const PERMUTATION_SIZE = 256; -const PERMUTATION_TABLE = new Uint8Array(PERMUTATION_SIZE * 2); - -let isPermutationInitialized = false; - -const initializePermutationTable = (): void => { - if (isPermutationInitialized) { - return; - } - - for (let index = 0; index < PERMUTATION_SIZE; index += 1) { - PERMUTATION_TABLE[index] = index; - } - - for (let index = PERMUTATION_SIZE - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(Math.random() * (index + 1)); - const current = PERMUTATION_TABLE[index] ?? 0; - PERMUTATION_TABLE[index] = PERMUTATION_TABLE[swapIndex] ?? 0; - PERMUTATION_TABLE[swapIndex] = current; - } - - for (let index = 0; index < PERMUTATION_SIZE; index += 1) { - PERMUTATION_TABLE[index + PERMUTATION_SIZE] = PERMUTATION_TABLE[index] ?? 0; - } - - isPermutationInitialized = true; -}; - -const fade = (value: number): number => - value * value * value * (value * (value * 6 - 15) + 10); - -const lerp = (start: number, end: number, amount: number): number => - start + amount * (end - start); - -const grad = (hash: number, x: number, y: number): number => { - const corner = hash & 3; - const axisU = corner < 2 ? x : y; - const axisV = corner < 2 ? y : x; - return ( - ((corner & 1) === 0 ? axisU : -axisU) + - ((corner & 2) === 0 ? axisV : -axisV) - ); -}; - -/** Classic Perlin-style 2D noise in approximately [-1, 1]. */ -export const sampleWindNoise2D = (x: number, y: number): number => { - initializePermutationTable(); - - const cellX = Math.floor(x) & 255; - const cellY = Math.floor(y) & 255; - const localX = x - Math.floor(x); - const localY = y - Math.floor(y); - const fadeX = fade(localX); - const fadeY = fade(localY); - - const aa = PERMUTATION_TABLE[cellX] ?? 0; - const ab = PERMUTATION_TABLE[cellX + 1] ?? 0; - const hashA = PERMUTATION_TABLE[(aa + cellY) & 255] ?? 0; - const hashB = PERMUTATION_TABLE[(ab + cellY) & 255] ?? 0; - const hashC = PERMUTATION_TABLE[(aa + cellY + 1) & 255] ?? 0; - const hashD = PERMUTATION_TABLE[(ab + cellY + 1) & 255] ?? 0; - - const x1 = lerp( - grad(hashA, localX, localY), - grad(hashB, localX - 1, localY), - fadeX, - ); - const x2 = lerp( - grad(hashC, localX, localY - 1), - grad(hashD, localX - 1, localY - 1), - fadeX, - ); - - return lerp(x1, x2, fadeY); -}; - -export type SparkFlutterSample = { - forceX: number; - forceY: number; -}; - -/** - * High-frequency flutter forces that make sparks waver and zig-zag in the air. - * Each particle uses its own phase via turbulenceSeed. - */ -export const sampleSparkFlutter = ( - elapsedSeconds: number, - turbulenceSeed: number, -): SparkFlutterSample => { - const phase = elapsedSeconds + turbulenceSeed * 0.017; - const drift = sampleWindNoise2D(phase * 0.75, turbulenceSeed * 0.04) * 0.18; - - return { - forceX: - Math.sin(phase * 19.3) * 0.42 + - Math.sin(phase * 33.7 + turbulenceSeed) * 0.28 + - Math.cos(phase * 47.1) * 0.14 + - drift, - forceY: - Math.cos(phase * 24.5 + turbulenceSeed * 0.5) * 0.16 + - Math.sin(phase * 38.9) * 0.1 + - drift * 0.35, - }; -}; From ac7d54b84e5a166c2ad684e65dd4186c6c93acd2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:03:29 +0000 Subject: [PATCH 05/21] Strengthen /deep-code-review: mandate fix-in-place for medium+ issues Require agents to implement important and medium fixes, and always remove dead or duplicated code in the same session. Document severity tiers, workflow, and output format. Cross-link from AGENTS.md and .cursorrules. --- .cursor/commands/deep-code-review.md | 59 ++++++++++++++++++++++++++-- .cursorrules | 3 +- AGENTS.md | 2 + 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.cursor/commands/deep-code-review.md b/.cursor/commands/deep-code-review.md index a0f1ac5f..68c7d4a5 100644 --- a/.cursor/commands/deep-code-review.md +++ b/.cursor/commands/deep-code-review.md @@ -1,3 +1,56 @@ -Now make a deep review of the code and make sure it follows best practices like DRY, KISS, SOLID, covers security principles, does not have memory leaks or possible race conditions. -In flows where something goes wrong, we must explicitly fail with a visible error, not continue silently. -If you found things to improve, try to cover them with unit tests to avoid regression. +# Deep code review + +Review the code in scope (the current branch, PR diff, or files the user pointed at) and **apply fixes in this same session**. A review that only lists issues without implementing fixes is incomplete. + +## Non-negotiable: fix immediately + +**Always implement in code before you finish** — do not defer to a follow-up unless the item is explicitly low-priority (see below): + +1. **Dead code** — unused exports, functions, types, constants, files, imports, commented-out blocks, unreachable branches, stale helpers left after a refactor. +2. **Improper duplication** — copy-pasted logic that should be shared; parallel implementations of the same behavior; magic numbers/strings repeated across files; test helpers duplicated instead of extracted when used in 2+ places in the same feature. +3. **Important issues** — bugs, incorrect behavior, security flaws, race conditions, memory/event leaks, silent failure paths, broken error handling, type-safety holes (`as any`, unchecked `!`), data-loss risks. +4. **Medium issues** — misleading names, poor module boundaries, over-engineered abstractions, missing validation on failure paths, tests that only assert implementation details, redundant double work (e.g. resolving options twice), constants scattered instead of centralized. + +If you find dead or duplicated code, **delete or consolidate it in this turn**. Do not leave it “for later” and do not only mention it in the summary. + +## Severity guide + +| Severity | Examples | Action | +|----------|----------|--------| +| **Important** | Bugs, security, leaks, races, silent failures | **Fix now** + add/regression test when behavior is non-trivial | +| **Medium** | DRY violations, misnamed modules, dead exports, weak structure, redundant logic | **Fix now** | +| **Low** | Subjective style, micro-naming, optional refactors with no clear win | Report only; skip unless trivial (<5 min) | + +When unsure between medium and low, **treat it as medium and fix it**. + +## Review checklist + +Evaluate against: + +- **DRY, KISS, SOLID** — prefer the smallest correct change; match existing repo patterns (see `.cursorrules`, `.cursor/rules/*.mdc`, `AGENTS.md`). +- **Security** — auth boundaries, input validation, secrets, injection, unsafe HTML/DOM. +- **Reliability** — no silent failures; error paths must fail visibly (throw, `TRPCError`, snackbar, error boundary — match the feature’s established pattern). +- **Concurrency** — race conditions, stale closures, missing cleanup in effects/subscriptions/timers/workers. +- **Resources** — event listeners, intervals, observers, workers, object URLs revoked on unmount/teardown. +- **Types** — no `as any` / unchecked `!`; fix root cause instead of suppressing. +- **Tests** — add or update tests when fixing behavior or preventing regression; skip tests that only duplicate TypeScript or constants. + +## Required workflow + +1. **Read** the changed files and their direct callers/tests. +2. **Identify** issues; classify as important / medium / low. +3. **Fix** all important and medium issues plus all dead/duplicated code **before** writing the final summary. +4. **Run** targeted tests and lint on touched files (`pnpm test`, `pnpm lint` as appropriate). +5. **Commit and push** if working on a branch with an open PR. + +Do **not** end with only a bullet list of recommendations when important/medium/dead-code fixes were possible in scope. + +## Output format + +Structure the final response as: + +1. **Fixes applied** — what you changed and why (group dead-code removal, deduplication, and behavioral fixes). +2. **Left unchanged** — only low-priority or out-of-scope items, with brief rationale. +3. **Verification** — tests/lint run and results. + +Keep prose concise; prefer code citations for non-obvious changes. diff --git a/.cursorrules b/.cursorrules index 6d26adc4..40c8686a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -2,8 +2,9 @@ How this fits together: -- **`AGENTS.md`** — Next.js doc lookup, pnpm, DB/env, and local dev commands. +- **`AGENTS.md`** — Next.js doc lookup, pnpm, DB/env, local dev commands, slash commands. - **`.cursor/rules/*.mdc`** — Always-on code style (hook imports, type imports, `useEffect` comments). +- **`.cursor/commands/`** — Slash-command playbooks (e.g. `/deep-code-review`). - **This file** — Architecture, stack, patterns, testing, and feature workflow. If **Next.js bundled docs** disagree with **`.cursor/rules/`** on style (hooks, types), follow **`.cursor/rules/`** for code in this repo. diff --git a/AGENTS.md b/AGENTS.md index 2ea0c33e..47d223e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ Cursor rules to apply (see each file for full wording): **Project rules:** See **`.cursorrules`** for stack, architecture, tRPC/Redux boundaries, styling (MUI + Emotion, no Tailwind), testing conventions, and feature workflow. **`.cursor/rules/*.mdc`** adds always-on style rules (React hook imports, type imports, `useEffect` comments). +**Slash commands:** `/deep-code-review` (see `.cursor/commands/deep-code-review.md`) — review in-scope code and **implement** important/medium fixes plus all dead or duplicated code in the same session; do not report-only. + ## Cursor Cloud specific instructions ### Services overview From aaf71f47dc7415a776e16e74b2cfaab257912b9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:04:46 +0000 Subject: [PATCH 06/21] Migrate deep-code-review command to Agent Skill format Move .cursor/commands/deep-code-review.md to .cursor/skills/deep-code-review/SKILL.md with disable-model-invocation. Update AGENTS.md and .cursorrules cross-links. Project rules stay as always-on .mdc files. --- .../deep-code-review/SKILL.md} | 6 ++++++ .cursorrules | 3 ++- AGENTS.md | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) rename .cursor/{commands/deep-code-review.md => skills/deep-code-review/SKILL.md} (95%) diff --git a/.cursor/commands/deep-code-review.md b/.cursor/skills/deep-code-review/SKILL.md similarity index 95% rename from .cursor/commands/deep-code-review.md rename to .cursor/skills/deep-code-review/SKILL.md index 68c7d4a5..19404865 100644 --- a/.cursor/commands/deep-code-review.md +++ b/.cursor/skills/deep-code-review/SKILL.md @@ -1,3 +1,9 @@ +--- +name: deep-code-review +description: Deep code review that applies important and medium fixes plus dead-code removal in the same session +disable-model-invocation: true +--- + # Deep code review Review the code in scope (the current branch, PR diff, or files the user pointed at) and **apply fixes in this same session**. A review that only lists issues without implementing fixes is incomplete. diff --git a/.cursorrules b/.cursorrules index 40c8686a..e9e4cf9b 100644 --- a/.cursorrules +++ b/.cursorrules @@ -4,7 +4,8 @@ How this fits together: - **`AGENTS.md`** — Next.js doc lookup, pnpm, DB/env, local dev commands, slash commands. - **`.cursor/rules/*.mdc`** — Always-on code style (hook imports, type imports, `useEffect` comments). -- **`.cursor/commands/`** — Slash-command playbooks (e.g. `/deep-code-review`). +- **`.cursor/commands/`** — Legacy slash-command location (migrating to `.cursor/skills/`). +- **`.cursor/skills/`** — Agent skills (e.g. `deep-code-review` for fix-in-place reviews). - **This file** — Architecture, stack, patterns, testing, and feature workflow. If **Next.js bundled docs** disagree with **`.cursor/rules/`** on style (hooks, types), follow **`.cursor/rules/`** for code in this repo. diff --git a/AGENTS.md b/AGENTS.md index 47d223e2..3e6c22df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Cursor rules to apply (see each file for full wording): **Project rules:** See **`.cursorrules`** for stack, architecture, tRPC/Redux boundaries, styling (MUI + Emotion, no Tailwind), testing conventions, and feature workflow. **`.cursor/rules/*.mdc`** adds always-on style rules (React hook imports, type imports, `useEffect` comments). -**Slash commands:** `/deep-code-review` (see `.cursor/commands/deep-code-review.md`) — review in-scope code and **implement** important/medium fixes plus all dead or duplicated code in the same session; do not report-only. +**Slash commands / skills:** `deep-code-review` skill (`.cursor/skills/deep-code-review/SKILL.md`) — review in-scope code and **implement** important/medium fixes plus all dead or duplicated code in the same session; do not report-only. ## Cursor Cloud specific instructions From 16cb506f5ee42e9bc28d7dee07b0900cc4cafdac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:05:48 +0000 Subject: [PATCH 07/21] Improve deep-code-review skill structure and discoverability Add trigger-rich description, workflow checklist, severity table, output template, and concrete example. Tighten prose per skill authoring best practices. --- .cursor/skills/deep-code-review/SKILL.md | 93 ++++++++++++++---------- AGENTS.md | 2 +- 2 files changed, 56 insertions(+), 39 deletions(-) diff --git a/.cursor/skills/deep-code-review/SKILL.md b/.cursor/skills/deep-code-review/SKILL.md index 19404865..ee2450af 100644 --- a/.cursor/skills/deep-code-review/SKILL.md +++ b/.cursor/skills/deep-code-review/SKILL.md @@ -1,62 +1,79 @@ --- name: deep-code-review -description: Deep code review that applies important and medium fixes plus dead-code removal in the same session +description: Reviews code in scope and applies important/medium fixes plus dead-code and duplication removal in the same session. Use when the user requests a deep code review, PR review, /deep-code-review, code audit, or asks to fix review findings immediately — not report-only. disable-model-invocation: true --- # Deep code review -Review the code in scope (the current branch, PR diff, or files the user pointed at) and **apply fixes in this same session**. A review that only lists issues without implementing fixes is incomplete. +**Fix in place.** Review scope, implement fixes, verify, then summarize. A report without code changes is incomplete when important/medium/dead-code issues were in scope. -## Non-negotiable: fix immediately +## Workflow -**Always implement in code before you finish** — do not defer to a follow-up unless the item is explicitly low-priority (see below): +Copy and track progress: -1. **Dead code** — unused exports, functions, types, constants, files, imports, commented-out blocks, unreachable branches, stale helpers left after a refactor. -2. **Improper duplication** — copy-pasted logic that should be shared; parallel implementations of the same behavior; magic numbers/strings repeated across files; test helpers duplicated instead of extracted when used in 2+ places in the same feature. -3. **Important issues** — bugs, incorrect behavior, security flaws, race conditions, memory/event leaks, silent failure paths, broken error handling, type-safety holes (`as any`, unchecked `!`), data-loss risks. -4. **Medium issues** — misleading names, poor module boundaries, over-engineered abstractions, missing validation on failure paths, tests that only assert implementation details, redundant double work (e.g. resolving options twice), constants scattered instead of centralized. +``` +Review progress: +- [ ] Read scope (branch diff, PR, or pointed files) and direct callers/tests +- [ ] Classify each finding: important / medium / low +- [ ] Fix all important, medium, dead code, and improper duplication +- [ ] Run targeted tests and lint on touched files +- [ ] Commit and push if on a PR branch +- [ ] Reply using the output template below +``` -If you find dead or duplicated code, **delete or consolidate it in this turn**. Do not leave it “for later” and do not only mention it in the summary. +**Scope** = current branch changes, open PR diff, or files the user named. Do not expand scope unless needed to fix a finding safely. -## Severity guide +## Fix immediately (non-negotiable) -| Severity | Examples | Action | -|----------|----------|--------| -| **Important** | Bugs, security, leaks, races, silent failures | **Fix now** + add/regression test when behavior is non-trivial | -| **Medium** | DRY violations, misnamed modules, dead exports, weak structure, redundant logic | **Fix now** | -| **Low** | Subjective style, micro-naming, optional refactors with no clear win | Report only; skip unless trivial (<5 min) | +Implement before finishing — never defer these to a follow-up: -When unsure between medium and low, **treat it as medium and fix it**. +| Category | Fix now | +|----------|---------| +| **Dead code** | Unused exports, functions, types, files, imports, unreachable branches, stale post-refactor helpers | +| **Duplication** | Copy-pasted logic, parallel implementations, repeated magic values, duplicate test helpers (extract when used 2+ times in one feature) | +| **Important** | Bugs, security flaws, races, leaks, silent failures, broken error handling, `as any` / unchecked `!`, data-loss risks | +| **Medium** | Misleading names, weak module boundaries, over-engineering, redundant work, scattered constants, tests that only guard implementation details | -## Review checklist +**Low** (subjective style, optional refactors): report only, unless trivial (<5 min). When unsure between medium and low → **medium, fix it**. -Evaluate against: +## Review focus -- **DRY, KISS, SOLID** — prefer the smallest correct change; match existing repo patterns (see `.cursorrules`, `.cursor/rules/*.mdc`, `AGENTS.md`). -- **Security** — auth boundaries, input validation, secrets, injection, unsafe HTML/DOM. -- **Reliability** — no silent failures; error paths must fail visibly (throw, `TRPCError`, snackbar, error boundary — match the feature’s established pattern). -- **Concurrency** — race conditions, stale closures, missing cleanup in effects/subscriptions/timers/workers. -- **Resources** — event listeners, intervals, observers, workers, object URLs revoked on unmount/teardown. -- **Types** — no `as any` / unchecked `!`; fix root cause instead of suppressing. -- **Tests** — add or update tests when fixing behavior or preventing regression; skip tests that only duplicate TypeScript or constants. +- **Patterns** — match `.cursorrules`, `.cursor/rules/*.mdc`, `AGENTS.md`; smallest correct diff (DRY, KISS) +- **Security** — auth boundaries, input validation, secrets, injection, unsafe HTML/DOM +- **Reliability** — failure paths fail visibly (`TRPCError`, snackbar, throw, error boundary — match the feature) +- **Concurrency & resources** — stale closures, missing effect/subscription/timer/worker cleanup +- **Tests** — add regression tests for non-trivial behavior fixes; skip tests that duplicate TypeScript or constants -## Required workflow +## Verification -1. **Read** the changed files and their direct callers/tests. -2. **Identify** issues; classify as important / medium / low. -3. **Fix** all important and medium issues plus all dead/duplicated code **before** writing the final summary. -4. **Run** targeted tests and lint on touched files (`pnpm test`, `pnpm lint` as appropriate). -5. **Commit and push** if working on a branch with an open PR. +- `pnpm exec vitest run ` or `pnpm test` when scope is wide +- `pnpm lint` or ESLint on touched files +- Do **not** ship a report-only response when fixes were possible in scope -Do **not** end with only a bullet list of recommendations when important/medium/dead-code fixes were possible in scope. +## Output template -## Output format +```markdown +## Fixes applied +- [Dead code / dedup / behavior] — what changed and why -Structure the final response as: +## Left unchanged +- [Low-priority or out-of-scope item] — brief rationale -1. **Fixes applied** — what you changed and why (group dead-code removal, deduplication, and behavioral fixes). -2. **Left unchanged** — only low-priority or out-of-scope items, with brief rationale. -3. **Verification** — tests/lint run and results. +## Verification +- Tests: … +- Lint: … +``` -Keep prose concise; prefer code citations for non-obvious changes. +Use code citations for non-obvious changes. Keep prose concise. + +## Example + +**Finding:** `windTurbulence.ts` exports Perlin noise used only for a tiny drift term on top of sine flutter. + +**Action:** Delete file; replace with `sparkFlutter.ts`; centralize constants in `sparkParticlePhysics.ts`; update tests. + +**Response excerpt:** +> **Fixes applied** — Removed 80-line Perlin module (dead weight). Consolidated spark constants and motion profiles. Fixed double-resolve in padding helper. +> +> **Verification** — 64 thanos tests pass; ESLint clean on touched files. diff --git a/AGENTS.md b/AGENTS.md index 3e6c22df..49ae9ef8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Cursor rules to apply (see each file for full wording): **Project rules:** See **`.cursorrules`** for stack, architecture, tRPC/Redux boundaries, styling (MUI + Emotion, no Tailwind), testing conventions, and feature workflow. **`.cursor/rules/*.mdc`** adds always-on style rules (React hook imports, type imports, `useEffect` comments). -**Slash commands / skills:** `deep-code-review` skill (`.cursor/skills/deep-code-review/SKILL.md`) — review in-scope code and **implement** important/medium fixes plus all dead or duplicated code in the same session; do not report-only. +**Skills:** `deep-code-review` (`.cursor/skills/deep-code-review/SKILL.md`) — fix-in-place review; implement important/medium fixes and remove dead or duplicated code in the same session. ## Cursor Cloud specific instructions From ee455e7231e7ca886310f1b3303a61f25a83f33f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:07:35 +0000 Subject: [PATCH 08/21] Enable deep-code-review skill on casual review requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove disable-model-invocation so agents auto-apply fix-in-place review rules when users ask to review changes, diffs, or PRs — not only on explicit /deep-code-review. Expand description triggers. --- .cursor/skills/deep-code-review/SKILL.md | 5 ++--- .cursorrules | 2 +- AGENTS.md | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.cursor/skills/deep-code-review/SKILL.md b/.cursor/skills/deep-code-review/SKILL.md index ee2450af..4eb8ffe6 100644 --- a/.cursor/skills/deep-code-review/SKILL.md +++ b/.cursor/skills/deep-code-review/SKILL.md @@ -1,12 +1,11 @@ --- name: deep-code-review -description: Reviews code in scope and applies important/medium fixes plus dead-code and duplication removal in the same session. Use when the user requests a deep code review, PR review, /deep-code-review, code audit, or asks to fix review findings immediately — not report-only. -disable-model-invocation: true +description: Reviews code in scope and applies important/medium fixes plus dead-code and duplication removal in the same session. Use when the user asks to review changes, review the diff/PR/branch, look over their code, requests a code review, /deep-code-review, deep review, code audit, or asks to fix review findings — not report-only. --- # Deep code review -**Fix in place.** Review scope, implement fixes, verify, then summarize. A report without code changes is incomplete when important/medium/dead-code issues were in scope. +**Fix in place.** Applies to explicit `/deep-code-review` **and** casual requests like “review the changes”, “look over this PR”, or “check my diff”. Review scope, implement fixes, verify, then summarize. A report without code changes is incomplete when important/medium/dead-code issues were in scope. ## Workflow diff --git a/.cursorrules b/.cursorrules index e9e4cf9b..cf781747 100644 --- a/.cursorrules +++ b/.cursorrules @@ -5,7 +5,7 @@ How this fits together: - **`AGENTS.md`** — Next.js doc lookup, pnpm, DB/env, local dev commands, slash commands. - **`.cursor/rules/*.mdc`** — Always-on code style (hook imports, type imports, `useEffect` comments). - **`.cursor/commands/`** — Legacy slash-command location (migrating to `.cursor/skills/`). -- **`.cursor/skills/`** — Agent skills (e.g. `deep-code-review` for fix-in-place reviews). +- **`.cursor/skills/`** — Agent skills; `deep-code-review` auto-applies on casual review requests (not only `/deep-code-review`). - **This file** — Architecture, stack, patterns, testing, and feature workflow. If **Next.js bundled docs** disagree with **`.cursor/rules/`** on style (hooks, types), follow **`.cursor/rules/`** for code in this repo. diff --git a/AGENTS.md b/AGENTS.md index 49ae9ef8..a0c0f82b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Cursor rules to apply (see each file for full wording): **Project rules:** See **`.cursorrules`** for stack, architecture, tRPC/Redux boundaries, styling (MUI + Emotion, no Tailwind), testing conventions, and feature workflow. **`.cursor/rules/*.mdc`** adds always-on style rules (React hook imports, type imports, `useEffect` comments). -**Skills:** `deep-code-review` (`.cursor/skills/deep-code-review/SKILL.md`) — fix-in-place review; implement important/medium fixes and remove dead or duplicated code in the same session. +**Skills:** `deep-code-review` — applies to `/deep-code-review` **and** casual “review the changes” / PR review requests; fix in place (implement important/medium fixes, remove dead or duplicated code in the same session). See `.cursor/skills/deep-code-review/SKILL.md`. ## Cursor Cloud specific instructions From 2dbdc6fb4c4cb8da8d20eea1a9d4ff517d3617a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:09:57 +0000 Subject: [PATCH 09/21] Rename Thanos disintegrate effect to domDisintegrate (trademark-safe) Replace thanosDisintegrate module, types, hooks, and docs with domDisintegrate naming. Planned public package: @dstruct/dom-disintegrate. All 67 effect tests pass. --- .cursor/skills/deep-code-review/SKILL.md | 2 +- .../cookieConsent/ui/CookieConsentBanner.tsx | 2 +- .../CookieConsentBannerWithDismissEffect.tsx | 7 +- ...kieConsentBannerWithDismissEffect.test.tsx | 4 +- .../__tests__/applyWaveOrigin.test.ts | 6 +- .../assignParticleReleaseTimes.test.ts | 8 +- .../buildChunkMaskSequenceAsync.test.ts | 8 +- .../__tests__/captureElementToCanvas.test.ts | 4 +- .../collectDocumentFontFaceCss.test.ts | 2 +- .../__tests__/createChunkMaskSequence.test.ts | 8 +- .../createDisintegrateParticle.test.ts} | 8 +- ...createFallbackParticlesFromElement.test.ts | 2 +- .../createParticlesFromImageData.test.ts | 2 +- .../__tests__/domDisintegrateError.test.ts | 21 +++++ .../__tests__/drawDisintegrationFrame.test.ts | 8 +- .../getParticleCanvasPadding.test.ts | 8 +- .../getWaveDisintegrationProgress.test.ts | 6 +- .../__tests__/maskStrategies.test.ts | 2 +- .../normalizeCaptureToDisplay.test.ts | 8 +- .../__tests__/overlayPosition.test.ts | 2 +- .../__tests__/parseCssColor.test.ts | 2 +- .../__tests__/prepareCaptureClone.test.ts | 2 +- .../__tests__/resolveRelativeOrigin.test.ts | 2 +- .../__tests__/runDomDisintegrate.test.ts} | 52 ++++++------- .../__tests__/sampleColorAtLocalPoint.test.ts | 2 +- .../__tests__/sparkFlutter.test.ts | 2 +- .../__tests__/stepParticles.test.ts | 8 +- .../__tests__/syncElementWaveMask.test.ts | 2 +- .../__tests__/warmDisintegrateCapture.test.ts | 31 ++++++++ .../applyWaveOrigin.ts | 4 +- .../assignParticleReleaseTimes.ts | 22 +++--- .../buildChunkMaskSequenceAsync.ts | 8 +- .../buildDisintegrateCapture.ts} | 28 +++---- .../canvasToDataUrl.ts | 0 .../captureElementToCanvas.ts | 2 +- .../captureElementViaSnapdom.ts | 2 +- .../captureElementViaSvgForeignObject.ts | 6 +- .../chunkMaskWorker.ts | 4 +- .../chunkMaskWorker.types.ts | 2 +- .../collectDocumentFontFaceCss.ts | 0 .../constants.ts | 6 +- .../copyInlineStyles.ts | 0 .../createChunkMaskSequence.ts | 9 ++- .../createDisintegrateParticle.ts} | 22 +++--- .../createFallbackParticlesFromElement.ts | 24 +++--- .../createParticlesFromImageData.ts | 20 ++--- .../disintegrateCaptureSnapshot.ts} | 28 +++---- .../domDisintegrate/domDisintegrateError.ts | 15 ++++ .../drawDisintegrationFrame.ts | 8 +- .../getParticleCanvasPadding.ts | 18 ++--- .../getWaveDisintegrationProgress.ts | 4 +- .../ui/effects/domDisintegrate/index.ts | 20 +++++ .../maskStrategies.ts | 6 +- .../normalizeCaptureToDisplay.ts | 0 .../overlayPosition.ts | 2 +- .../parseCssColor.ts | 0 .../prepareCaptureClone.ts | 2 +- .../resolveDomDisintegrateOptions.ts | 12 +++ .../resolveRelativeOrigin.ts | 4 +- .../runDomDisintegrate.ts} | 77 ++++++++++--------- .../sampleColorAtLocalPoint.ts | 2 +- .../scaleParticleCoordinates.ts | 4 +- .../sparkFlutter.ts | 0 .../sparkParticlePhysics.ts | 6 +- .../stepParticles.ts | 24 +++--- .../syncChunkMaskSequence.ts | 6 +- .../syncElementWaveMask.ts | 0 .../types.ts | 28 +++---- .../useDomDisintegrate.ts} | 30 ++++---- .../waitForDocumentFonts.ts | 0 .../warmDisintegrateCapture.ts} | 18 ++--- .../__tests__/thanosDisintegrateError.test.ts | 21 ----- .../__tests__/warmThanosCapture.test.ts | 31 -------- .../ui/effects/thanosDisintegrate/index.ts | 20 ----- .../resolveThanosDisintegrateOptions.ts | 12 --- .../thanosDisintegrateError.ts | 15 ---- ...n.md => DomDisintegrate-Library-Design.md} | 46 +++++------ ...ODO.md => DomDisintegrate-Library-TODO.md} | 10 +-- 78 files changed, 426 insertions(+), 423 deletions(-) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/applyWaveOrigin.test.ts (76%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/assignParticleReleaseTimes.test.ts (89%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/buildChunkMaskSequenceAsync.test.ts (91%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/captureElementToCanvas.test.ts (94%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/collectDocumentFontFaceCss.test.ts (91%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/createChunkMaskSequence.test.ts (92%) rename src/shared/ui/effects/{thanosDisintegrate/__tests__/createThanosParticle.test.ts => domDisintegrate/__tests__/createDisintegrateParticle.test.ts} (83%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/createFallbackParticlesFromElement.test.ts (96%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/createParticlesFromImageData.test.ts (95%) create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateError.test.ts rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/drawDisintegrationFrame.test.ts (91%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/getParticleCanvasPadding.test.ts (75%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/getWaveDisintegrationProgress.test.ts (76%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/maskStrategies.test.ts (91%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/normalizeCaptureToDisplay.test.ts (75%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/overlayPosition.test.ts (97%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/parseCssColor.test.ts (93%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/prepareCaptureClone.test.ts (94%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/resolveRelativeOrigin.test.ts (85%) rename src/shared/ui/effects/{thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts => domDisintegrate/__tests__/runDomDisintegrate.test.ts} (81%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/sampleColorAtLocalPoint.test.ts (92%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/sparkFlutter.test.ts (89%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/stepParticles.test.ts (88%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/__tests__/syncElementWaveMask.test.ts (97%) create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/warmDisintegrateCapture.test.ts rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/applyWaveOrigin.ts (83%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/assignParticleReleaseTimes.ts (77%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/buildChunkMaskSequenceAsync.ts (93%) rename src/shared/ui/effects/{thanosDisintegrate/buildThanosCapture.ts => domDisintegrate/buildDisintegrateCapture.ts} (63%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/canvasToDataUrl.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/captureElementToCanvas.ts (78%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/captureElementViaSnapdom.ts (90%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/captureElementViaSvgForeignObject.ts (89%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/chunkMaskWorker.ts (90%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/chunkMaskWorker.types.ts (92%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/collectDocumentFontFaceCss.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/constants.ts (60%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/copyInlineStyles.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/createChunkMaskSequence.ts (97%) rename src/shared/ui/effects/{thanosDisintegrate/createThanosParticle.ts => domDisintegrate/createDisintegrateParticle.ts} (79%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/createFallbackParticlesFromElement.ts (64%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/createParticlesFromImageData.ts (60%) rename src/shared/ui/effects/{thanosDisintegrate/thanosCaptureSnapshot.ts => domDisintegrate/disintegrateCaptureSnapshot.ts} (54%) create mode 100644 src/shared/ui/effects/domDisintegrate/domDisintegrateError.ts rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/drawDisintegrationFrame.ts (85%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/getParticleCanvasPadding.ts (65%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/getWaveDisintegrationProgress.ts (86%) create mode 100644 src/shared/ui/effects/domDisintegrate/index.ts rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/maskStrategies.ts (97%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/normalizeCaptureToDisplay.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/overlayPosition.ts (96%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/parseCssColor.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/prepareCaptureClone.ts (94%) create mode 100644 src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/resolveRelativeOrigin.ts (75%) rename src/shared/ui/effects/{thanosDisintegrate/runThanosDisintegrate.ts => domDisintegrate/runDomDisintegrate.ts} (80%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/sampleColorAtLocalPoint.ts (97%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/scaleParticleCoordinates.ts (74%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/sparkFlutter.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/sparkParticlePhysics.ts (85%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/stepParticles.ts (83%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/syncChunkMaskSequence.ts (83%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/syncElementWaveMask.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/types.ts (79%) rename src/shared/ui/effects/{thanosDisintegrate/useThanosDisintegrate.ts => domDisintegrate/useDomDisintegrate.ts} (67%) rename src/shared/ui/effects/{thanosDisintegrate => domDisintegrate}/waitForDocumentFonts.ts (100%) rename src/shared/ui/effects/{thanosDisintegrate/warmThanosCapture.ts => domDisintegrate/warmDisintegrateCapture.ts} (63%) delete mode 100644 src/shared/ui/effects/thanosDisintegrate/__tests__/thanosDisintegrateError.test.ts delete mode 100644 src/shared/ui/effects/thanosDisintegrate/__tests__/warmThanosCapture.test.ts delete mode 100644 src/shared/ui/effects/thanosDisintegrate/index.ts delete mode 100644 src/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions.ts delete mode 100644 src/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError.ts rename vibe-docs/{ThanosDisintegrate-Library-Design.md => DomDisintegrate-Library-Design.md} (75%) rename vibe-docs/{ThanosDisintegrate-Library-TODO.md => DomDisintegrate-Library-TODO.md} (76%) diff --git a/.cursor/skills/deep-code-review/SKILL.md b/.cursor/skills/deep-code-review/SKILL.md index 4eb8ffe6..aa3605b2 100644 --- a/.cursor/skills/deep-code-review/SKILL.md +++ b/.cursor/skills/deep-code-review/SKILL.md @@ -75,4 +75,4 @@ Use code citations for non-obvious changes. Keep prose concise. **Response excerpt:** > **Fixes applied** — Removed 80-line Perlin module (dead weight). Consolidated spark constants and motion profiles. Fixed double-resolve in padding helper. > -> **Verification** — 64 thanos tests pass; ESLint clean on touched files. +> **Verification** — 64 domDisintegrate tests pass; ESLint clean on touched files. diff --git a/src/features/cookieConsent/ui/CookieConsentBanner.tsx b/src/features/cookieConsent/ui/CookieConsentBanner.tsx index 68cd5d02..bc60112b 100644 --- a/src/features/cookieConsent/ui/CookieConsentBanner.tsx +++ b/src/features/cookieConsent/ui/CookieConsentBanner.tsx @@ -25,7 +25,7 @@ type CookieConsentBannerProps = { onAcceptAll: (event: React.MouseEvent) => void; onRejectNonEssential: (event: React.MouseEvent) => void; onClose: (event: React.MouseEvent) => void; - /** Visual surface used by optional dismiss effects (e.g. Thanos disintegrate). */ + /** Visual surface used by optional dismiss effects (e.g. DOM disintegrate). */ surfaceRef?: React.Ref; }; diff --git a/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx b/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx index 5fd76f0c..5a75ed37 100644 --- a/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx +++ b/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { CookieConsentBanner } from "#/features/cookieConsent/ui/CookieConsentBanner"; import { useI18nContext } from "#/shared/hooks"; -import { useThanosDisintegrate } from "#/shared/ui/effects/thanosDisintegrate"; +import { useDomDisintegrate } from "#/shared/ui/effects/domDisintegrate"; type CookieConsentBannerWithDismissEffectProps = { isSettingsView: boolean; @@ -17,7 +17,7 @@ type CookieConsentBannerWithDismissEffectProps = { }; /** - * Presentation wrapper: persists consent immediately, then plays the Thanos + * Presentation wrapper: persists consent immediately, then plays the DOM * disintegrate effect while the banner stays mounted for the animation. */ export const CookieConsentBannerWithDismissEffect: React.FC< @@ -32,8 +32,7 @@ export const CookieConsentBannerWithDismissEffect: React.FC< }) => { const { LL } = useI18nContext(); const { enqueueSnackbar } = useSnackbar(); - const { targetRef, disintegrate, invalidateCapture } = - useThanosDisintegrate(); + const { targetRef, disintegrate, invalidateCapture } = useDomDisintegrate(); const isDismissingRef = useRef(false); const [frozenSettingsView, setFrozenSettingsView] = useState( null, diff --git a/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx b/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx index cb9f1c4f..e9e569a1 100644 --- a/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx +++ b/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx @@ -32,8 +32,8 @@ vi.mock("#/shared/hooks", async (importOriginal) => { }; }); -vi.mock("#/shared/ui/effects/thanosDisintegrate", () => ({ - useThanosDisintegrate: () => ({ +vi.mock("#/shared/ui/effects/domDisintegrate", () => ({ + useDomDisintegrate: () => ({ targetRef: { current: null }, disintegrate: disintegrateMock, invalidateCapture: vi.fn(), diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts similarity index 76% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts index cc8248a9..3badd4a3 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { applyWaveOrigin } from "#/shared/ui/effects/thanosDisintegrate/applyWaveOrigin"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import { applyWaveOrigin } from "#/shared/ui/effects/domDisintegrate/applyWaveOrigin"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; -const createParticle = (x: number, y: number): ThanosParticle => ({ +const createParticle = (x: number, y: number): DisintegrateParticle => ({ x, y, originX: x, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts similarity index 89% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts index 1e877a23..2af5c9b6 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { assignParticleReleaseTimes, resolveEffectiveMaskStrategy, -} from "#/shared/ui/effects/thanosDisintegrate/assignParticleReleaseTimes"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +} from "#/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ + overrides: Partial = {}, +): DisintegrateParticle => ({ x: 0, y: 0, originX: 0, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts similarity index 91% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts index f4e7ddc7..6072cb6e 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; const workerInstances: MockWorker[] = []; @@ -52,11 +52,11 @@ class MockWorker { vi.stubGlobal("Worker", MockWorker as unknown as typeof Worker); const { buildChunkMaskSequenceAsync, terminateChunkMaskWorker } = - await import("#/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync"); + await import("#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"); const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ + overrides: Partial = {}, +): DisintegrateParticle => ({ x: 0, y: 0, originX: 0, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/captureElementToCanvas.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/captureElementToCanvas.test.ts similarity index 94% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/captureElementToCanvas.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/captureElementToCanvas.test.ts index 9ae1e761..33281243 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/captureElementToCanvas.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/captureElementToCanvas.test.ts @@ -15,7 +15,7 @@ describe("captureElementToCanvas", () => { it("returns a sized canvas when SVG rasterization succeeds", async () => { const { captureElementToCanvas } = - await import("#/shared/ui/effects/thanosDisintegrate/captureElementToCanvas"); + await import("#/shared/ui/effects/domDisintegrate/captureElementToCanvas"); const element = document.createElement("div"); element.style.backgroundColor = "rgba(255, 255, 255, 0.55)"; @@ -67,7 +67,7 @@ describe("captureElementToCanvas", () => { it("throws when rasterization fails", async () => { const { captureElementToCanvas } = - await import("#/shared/ui/effects/thanosDisintegrate/captureElementToCanvas"); + await import("#/shared/ui/effects/domDisintegrate/captureElementToCanvas"); const element = document.createElement("div"); element.getBoundingClientRect = () => diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/collectDocumentFontFaceCss.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/collectDocumentFontFaceCss.test.ts similarity index 91% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/collectDocumentFontFaceCss.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/collectDocumentFontFaceCss.test.ts index 8569db79..779746ad 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/collectDocumentFontFaceCss.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/collectDocumentFontFaceCss.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { collectDocumentFontFaceCss, sanitizeEmbeddedStyleCss, -} from "#/shared/ui/effects/thanosDisintegrate/collectDocumentFontFaceCss"; +} from "#/shared/ui/effects/domDisintegrate/collectDocumentFontFaceCss"; describe("collectDocumentFontFaceCss", () => { afterEach(() => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts similarity index 92% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts index f4c9db49..64235afd 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts @@ -6,12 +6,12 @@ import { getChunkMaskIndex, revealParticleChunk, revealParticleMaskBleedZone, -} from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +} from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ + overrides: Partial = {}, +): DisintegrateParticle => ({ x: 0, y: 0, originX: 0, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts similarity index 83% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts index 196d9409..f0130436 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/createThanosParticle.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/createThanosParticle"; +import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; const baseInput = { x: 50, @@ -11,9 +11,9 @@ const baseInput = { surfaceHeight: 50, }; -describe("createThanosParticle", () => { +describe("createDisintegrateParticle", () => { it("biases splat particles away from the surface center", () => { - const particle = createThanosParticle({ + const particle = createDisintegrateParticle({ ...baseInput, x: 80, y: 10, @@ -31,7 +31,7 @@ describe("createThanosParticle", () => { it("launches windy particles upward like fire sparks", () => { const particles = Array.from({ length: 24 }, () => - createThanosParticle({ + createDisintegrateParticle({ ...baseInput, options: { particleMotionMode: "windy", diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/createFallbackParticlesFromElement.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createFallbackParticlesFromElement.test.ts similarity index 96% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/createFallbackParticlesFromElement.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/createFallbackParticlesFromElement.test.ts index fccf75e7..ff92cd95 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/createFallbackParticlesFromElement.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createFallbackParticlesFromElement.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createFallbackParticlesFromElement } from "#/shared/ui/effects/thanosDisintegrate/createFallbackParticlesFromElement"; +import { createFallbackParticlesFromElement } from "#/shared/ui/effects/domDisintegrate/createFallbackParticlesFromElement"; describe("createFallbackParticlesFromElement", () => { afterEach(() => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/createParticlesFromImageData.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts similarity index 95% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/createParticlesFromImageData.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts index cea72154..be523bc5 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/createParticlesFromImageData.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createParticlesFromImageData } from "#/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData"; +import { createParticlesFromImageData } from "#/shared/ui/effects/domDisintegrate/createParticlesFromImageData"; describe("createParticlesFromImageData", () => { it("skips transparent pixels and samples opaque ones", () => { diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateError.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateError.test.ts new file mode 100644 index 00000000..77ce0349 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateError.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { + DomDisintegrateError, + type DomDisintegrateErrorCode, +} from "#/shared/ui/effects/domDisintegrate/domDisintegrateError"; + +describe("DomDisintegrateError", () => { + it("exposes a stable error code for callers", () => { + const code: DomDisintegrateErrorCode = "canvas_unavailable"; + const error = new DomDisintegrateError( + code, + "2D canvas context is unavailable.", + ); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("DomDisintegrateError"); + expect(error.code).toBe("canvas_unavailable"); + expect(error.message).toBe("2D canvas context is unavailable."); + }); +}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts similarity index 91% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts index da3581ae..0e1fdf8c 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { drawDisintegrationFrame } from "#/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import { drawDisintegrationFrame } from "#/shared/ui/effects/domDisintegrate/drawDisintegrationFrame"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ + overrides: Partial = {}, +): DisintegrateParticle => ({ x: 0, y: 0, originX: 0, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts similarity index 75% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts index 651ed4fb..b8f1de6d 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "vitest"; -import { THANOS_DISINTEGRATE_DEFAULTS } from "#/shared/ui/effects/thanosDisintegrate/constants"; +import { DOM_DISINTEGRATE_DEFAULTS } from "#/shared/ui/effects/domDisintegrate/constants"; import { getParticleCanvasPadding, getParticleMaxTravel, getParticleRevealMargin, -} from "#/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding"; +} from "#/shared/ui/effects/domDisintegrate/getParticleCanvasPadding"; describe("getParticleCanvasPadding", () => { it("scales with physics options so particles can travel outside the surface", () => { - const padding = getParticleCanvasPadding(THANOS_DISINTEGRATE_DEFAULTS); + const padding = getParticleCanvasPadding(DOM_DISINTEGRATE_DEFAULTS); expect(padding).toBeGreaterThan(200); }); @@ -28,7 +28,7 @@ describe("getParticleCanvasPadding", () => { }); it("keeps reveal margin within canvas padding", () => { - const options = THANOS_DISINTEGRATE_DEFAULTS; + const options = DOM_DISINTEGRATE_DEFAULTS; const padding = getParticleCanvasPadding(options); const revealMargin = getParticleRevealMargin(options); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts similarity index 76% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts index 8df96871..504e2afe 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getWaveDisintegrationProgress } from "#/shared/ui/effects/thanosDisintegrate/getWaveDisintegrationProgress"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import { getWaveDisintegrationProgress } from "#/shared/ui/effects/domDisintegrate/getWaveDisintegrationProgress"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; -const createParticle = (releaseTime: number): ThanosParticle => ({ +const createParticle = (releaseTime: number): DisintegrateParticle => ({ x: 0, y: 0, originX: 0, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/maskStrategies.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/maskStrategies.test.ts similarity index 91% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/maskStrategies.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/maskStrategies.test.ts index 7403c53c..3ee251a6 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/maskStrategies.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/maskStrategies.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createMaskStrategyGrid } from "#/shared/ui/effects/thanosDisintegrate/maskStrategies"; +import { createMaskStrategyGrid } from "#/shared/ui/effects/domDisintegrate/maskStrategies"; describe("maskStrategies", () => { it("builds a centerOut grid with increasing distance from the center", () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts similarity index 75% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts index 5e1b9ec4..46677d3a 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { normalizeCaptureToDisplay } from "#/shared/ui/effects/thanosDisintegrate/normalizeCaptureToDisplay"; -import { scaleParticleCoordinates } from "#/shared/ui/effects/thanosDisintegrate/scaleParticleCoordinates"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import { normalizeCaptureToDisplay } from "#/shared/ui/effects/domDisintegrate/normalizeCaptureToDisplay"; +import { scaleParticleCoordinates } from "#/shared/ui/effects/domDisintegrate/scaleParticleCoordinates"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; describe("normalizeCaptureToDisplay", () => { it("downscales hi-dpi captures to css pixel dimensions", () => { @@ -19,7 +19,7 @@ describe("normalizeCaptureToDisplay", () => { describe("scaleParticleCoordinates", () => { it("scales origin and velocity fields used for hole punching", () => { - const particle: ThanosParticle = { + const particle: DisintegrateParticle = { x: 10, y: 20, originX: 10, diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/overlayPosition.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/overlayPosition.test.ts similarity index 97% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/overlayPosition.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/overlayPosition.test.ts index ab6b9468..f118aa48 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/overlayPosition.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/overlayPosition.test.ts @@ -4,7 +4,7 @@ import { collectViewportChangeTargets, prepareElementForDisintegrate, syncFixedOverlayToElement, -} from "#/shared/ui/effects/thanosDisintegrate/overlayPosition"; +} from "#/shared/ui/effects/domDisintegrate/overlayPosition"; describe("overlayPosition", () => { afterEach(() => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/parseCssColor.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/parseCssColor.test.ts similarity index 93% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/parseCssColor.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/parseCssColor.test.ts index 0d3b3c7d..6ee86d80 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/parseCssColor.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/parseCssColor.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { blendColors, parseCssColor, -} from "#/shared/ui/effects/thanosDisintegrate/parseCssColor"; +} from "#/shared/ui/effects/domDisintegrate/parseCssColor"; describe("parseCssColor", () => { it("parses rgb and rgba values", () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/prepareCaptureClone.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/prepareCaptureClone.test.ts similarity index 94% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/prepareCaptureClone.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/prepareCaptureClone.test.ts index 39ae21c4..daffaa5e 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/prepareCaptureClone.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/prepareCaptureClone.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { prepareCaptureClone } from "#/shared/ui/effects/thanosDisintegrate/prepareCaptureClone"; +import { prepareCaptureClone } from "#/shared/ui/effects/domDisintegrate/prepareCaptureClone"; const createStyledElement = (): HTMLDivElement => { const element = document.createElement("div"); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/resolveRelativeOrigin.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/resolveRelativeOrigin.test.ts similarity index 85% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/resolveRelativeOrigin.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/resolveRelativeOrigin.test.ts index 221dd1ca..54060bf5 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/resolveRelativeOrigin.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/resolveRelativeOrigin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveRelativeOrigin } from "#/shared/ui/effects/thanosDisintegrate/resolveRelativeOrigin"; +import { resolveRelativeOrigin } from "#/shared/ui/effects/domDisintegrate/resolveRelativeOrigin"; describe("resolveRelativeOrigin", () => { it("converts client coordinates into element-local coordinates", () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts similarity index 81% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts index b1a4573f..56019701 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts @@ -4,25 +4,25 @@ vi.mock("#/shared/lib/prefersReducedMotion", () => ({ prefersReducedMotion: () => false, })); -vi.mock("#/shared/ui/effects/thanosDisintegrate/buildThanosCapture", () => ({ - buildThanosCapture: vi.fn(), +vi.mock("#/shared/ui/effects/domDisintegrate/buildDisintegrateCapture", () => ({ + buildDisintegrateCapture: vi.fn(), })); vi.mock( - "#/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync", + "#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync", () => ({ buildChunkMaskSequenceAsync: vi.fn(), }), ); -const { buildThanosCapture } = - await import("#/shared/ui/effects/thanosDisintegrate/buildThanosCapture"); +const { buildDisintegrateCapture } = + await import("#/shared/ui/effects/domDisintegrate/buildDisintegrateCapture"); const { buildChunkMaskSequenceAsync } = - await import("#/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync"); -const { runThanosDisintegrate } = - await import("#/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate"); -const { ThanosDisintegrateError } = - await import("#/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError"); + await import("#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"); +const { runDomDisintegrate } = + await import("#/shared/ui/effects/domDisintegrate/runDomDisintegrate"); +const { DomDisintegrateError } = + await import("#/shared/ui/effects/domDisintegrate/domDisintegrateError"); const createParticle = () => ({ x: 0, @@ -62,7 +62,7 @@ const createElement = () => { return element; }; -describe("runThanosDisintegrate", () => { +describe("runDomDisintegrate", () => { afterEach(() => { document.body.innerHTML = ""; vi.clearAllMocks(); @@ -79,19 +79,19 @@ describe("runThanosDisintegrate", () => { }; await expect( - runThanosDisintegrate(element, { + runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, }), ).resolves.toBeUndefined(); - expect(buildThanosCapture).not.toHaveBeenCalled(); + expect(buildDisintegrateCapture).not.toHaveBeenCalled(); }); it("builds a fast capture when no snapshot is available", async () => { vi.mocked(buildChunkMaskSequenceAsync).mockResolvedValue(null); const element = createElement(); - vi.mocked(buildThanosCapture).mockResolvedValue({ + vi.mocked(buildDisintegrateCapture).mockResolvedValue({ sourceCanvas: null, particles: [createParticle()], displayWidth: 120, @@ -99,9 +99,9 @@ describe("runThanosDisintegrate", () => { }); await expect( - runThanosDisintegrate(element, { origin: { x: 60, y: 24 } }), + runDomDisintegrate(element, { origin: { x: 60, y: 24 } }), ).resolves.toBeUndefined(); - expect(buildThanosCapture).toHaveBeenCalledWith(element, { + expect(buildDisintegrateCapture).toHaveBeenCalledWith(element, { mode: "fast", disintegrateOptions: expect.any(Object), }); @@ -109,7 +109,7 @@ describe("runThanosDisintegrate", () => { it("throws when capture produces no particles", async () => { vi.mocked(buildChunkMaskSequenceAsync).mockResolvedValue(null); - vi.mocked(buildThanosCapture).mockResolvedValue({ + vi.mocked(buildDisintegrateCapture).mockResolvedValue({ sourceCanvas: null, particles: [], displayWidth: 120, @@ -118,10 +118,10 @@ describe("runThanosDisintegrate", () => { const element = createElement(); await expect( - runThanosDisintegrate(element, { + runDomDisintegrate(element, { origin: { x: 60, y: 24 }, }), - ).rejects.toBeInstanceOf(ThanosDisintegrateError); + ).rejects.toBeInstanceOf(DomDisintegrateError); }); it("falls back to radial masks when chunk mask generation is unavailable", async () => { @@ -135,7 +135,7 @@ describe("runThanosDisintegrate", () => { }; await expect( - runThanosDisintegrate(element, { + runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, maskMode: "chunks", @@ -147,7 +147,7 @@ describe("runThanosDisintegrate", () => { it("applies radial masks while chunk masks are building when origin is set", async () => { const syncElementWaveMask = - await import("#/shared/ui/effects/thanosDisintegrate/syncElementWaveMask"); + await import("#/shared/ui/effects/domDisintegrate/syncElementWaveMask"); const applyWaveMaskSpy = vi.spyOn( syncElementWaveMask, "applyWaveMaskToElement", @@ -165,7 +165,7 @@ describe("runThanosDisintegrate", () => { displayHeight: 48, }; - const animation = runThanosDisintegrate(element, { + const animation = runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, maskMode: "chunks", @@ -206,7 +206,7 @@ describe("runThanosDisintegrate", () => { displayHeight: 48, }; - const animation = runThanosDisintegrate(element, { + const animation = runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, maskMode: "chunks", @@ -222,9 +222,9 @@ describe("runThanosDisintegrate", () => { it("keeps radial masks when chunk sequence arrives after radial fallback started", async () => { const syncChunkMaskSequence = - await import("#/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence"); + await import("#/shared/ui/effects/domDisintegrate/syncChunkMaskSequence"); const syncElementWaveMask = - await import("#/shared/ui/effects/thanosDisintegrate/syncElementWaveMask"); + await import("#/shared/ui/effects/domDisintegrate/syncElementWaveMask"); const applyChunkMaskSpy = vi.spyOn( syncChunkMaskSequence, "applyChunkMaskFrame", @@ -255,7 +255,7 @@ describe("runThanosDisintegrate", () => { displayHeight: 48, }; - const animation = runThanosDisintegrate(element, { + const animation = runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, maskMode: "chunks", diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/sampleColorAtLocalPoint.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/sampleColorAtLocalPoint.test.ts similarity index 92% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/sampleColorAtLocalPoint.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/sampleColorAtLocalPoint.test.ts index eac2be5e..d9515197 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/sampleColorAtLocalPoint.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/sampleColorAtLocalPoint.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { sampleColorAtLocalPoint } from "#/shared/ui/effects/thanosDisintegrate/sampleColorAtLocalPoint"; +import { sampleColorAtLocalPoint } from "#/shared/ui/effects/domDisintegrate/sampleColorAtLocalPoint"; describe("sampleColorAtLocalPoint", () => { it("prefers text color over bright button backgrounds", () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/sparkFlutter.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/sparkFlutter.test.ts similarity index 89% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/sparkFlutter.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/sparkFlutter.test.ts index 7f99eba6..ca97a80a 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/sparkFlutter.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/sparkFlutter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { sampleSparkFlutter } from "#/shared/ui/effects/thanosDisintegrate/sparkFlutter"; +import { sampleSparkFlutter } from "#/shared/ui/effects/domDisintegrate/sparkFlutter"; describe("sampleSparkFlutter", () => { it("produces bounded, per-particle flutter forces", () => { diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts similarity index 88% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index ea670847..e0c69371 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; -import { createThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/createThanosParticle"; -import { stepParticles } from "#/shared/ui/effects/thanosDisintegrate/stepParticles"; +import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; +import { stepParticles } from "#/shared/ui/effects/domDisintegrate/stepParticles"; describe("stepParticles", () => { it("uses delta time so motion is independent of frame rate", () => { - const particle = createThanosParticle({ + const particle = createDisintegrateParticle({ x: 10, y: 10, color: "rgb(255, 255, 255)", @@ -57,7 +57,7 @@ describe("stepParticles", () => { }); it("arcs windy particles upward before gravity pulls them down", () => { - const particle = createThanosParticle({ + const particle = createDisintegrateParticle({ x: 20, y: 40, color: "rgb(255, 255, 255)", diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/syncElementWaveMask.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts similarity index 97% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/syncElementWaveMask.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts index 66695f3a..18d6eea8 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/syncElementWaveMask.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts @@ -8,7 +8,7 @@ import { createModalWaveMask, createParticleWaveMask, getWaveMaskRadii, -} from "#/shared/ui/effects/thanosDisintegrate/syncElementWaveMask"; +} from "#/shared/ui/effects/domDisintegrate/syncElementWaveMask"; describe("syncElementWaveMask", () => { afterEach(() => { diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/warmDisintegrateCapture.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/warmDisintegrateCapture.test.ts new file mode 100644 index 00000000..06140141 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/warmDisintegrateCapture.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const buildDisintegrateCaptureMock = vi.fn(); + +vi.mock("#/shared/ui/effects/domDisintegrate/buildDisintegrateCapture", () => ({ + buildDisintegrateCapture: buildDisintegrateCaptureMock, +})); + +const { warmDisintegrateCapture } = + await import("#/shared/ui/effects/domDisintegrate/warmDisintegrateCapture"); + +describe("warmDisintegrateCapture", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("does not leave an unhandled rejection when warm capture fails", async () => { + vi.useFakeTimers(); + buildDisintegrateCaptureMock.mockRejectedValue(new Error("capture failed")); + + const element = document.createElement("div"); + const cacheRef = { current: null }; + + const cancelWarm = warmDisintegrateCapture(element, cacheRef); + await vi.runAllTimersAsync(); + + expect(cacheRef.current).toBeNull(); + cancelWarm(); + }); +}); diff --git a/src/shared/ui/effects/thanosDisintegrate/applyWaveOrigin.ts b/src/shared/ui/effects/domDisintegrate/applyWaveOrigin.ts similarity index 83% rename from src/shared/ui/effects/thanosDisintegrate/applyWaveOrigin.ts rename to src/shared/ui/effects/domDisintegrate/applyWaveOrigin.ts index 486c5166..e17e902f 100644 --- a/src/shared/ui/effects/thanosDisintegrate/applyWaveOrigin.ts +++ b/src/shared/ui/effects/domDisintegrate/applyWaveOrigin.ts @@ -1,10 +1,10 @@ -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; const RELEASE_JITTER_SECONDS = 0.03; /** Staggers particle release times by distance from the click origin. */ export const applyWaveOrigin = ( - particles: ThanosParticle[], + particles: DisintegrateParticle[], origin: { x: number; y: number }, waveSpeedPxPerSecond: number, ): number => { diff --git a/src/shared/ui/effects/thanosDisintegrate/assignParticleReleaseTimes.ts b/src/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes.ts similarity index 77% rename from src/shared/ui/effects/thanosDisintegrate/assignParticleReleaseTimes.ts rename to src/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes.ts index 45c7587d..7e92ce20 100644 --- a/src/shared/ui/effects/thanosDisintegrate/assignParticleReleaseTimes.ts +++ b/src/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes.ts @@ -1,16 +1,16 @@ -import { applyWaveOrigin } from "#/shared/ui/effects/thanosDisintegrate/applyWaveOrigin"; -import { createMaskStrategyGrid } from "#/shared/ui/effects/thanosDisintegrate/maskStrategies"; +import { applyWaveOrigin } from "#/shared/ui/effects/domDisintegrate/applyWaveOrigin"; +import { createMaskStrategyGrid } from "#/shared/ui/effects/domDisintegrate/maskStrategies"; import type { - ThanosMaskStrategy, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; + DisintegrateMaskStrategy, + DisintegrateParticle, +} from "#/shared/ui/effects/domDisintegrate/types"; const RELEASE_JITTER_SECONDS = 0.03; export const resolveEffectiveMaskStrategy = ( - maskStrategy: ThanosMaskStrategy | undefined, + maskStrategy: DisintegrateMaskStrategy | undefined, hasClickOrigin: boolean, -): ThanosMaskStrategy => { +): DisintegrateMaskStrategy => { if (maskStrategy) { return maskStrategy; } @@ -19,8 +19,8 @@ export const resolveEffectiveMaskStrategy = ( }; const applyGridMaskStrategy = ( - particles: ThanosParticle[], - strategy: Exclude, + particles: DisintegrateParticle[], + strategy: Exclude, displayWidth: number, displayHeight: number, particleStep: number, @@ -46,7 +46,7 @@ const applyGridMaskStrategy = ( }; export const assignParticleReleaseTimes = ( - particles: ThanosParticle[], + particles: DisintegrateParticle[], { strategy, displayWidth, @@ -56,7 +56,7 @@ export const assignParticleReleaseTimes = ( waveOrigin, waveSpeed, }: { - strategy: ThanosMaskStrategy; + strategy: DisintegrateMaskStrategy; displayWidth: number; displayHeight: number; particleStep: number; diff --git a/src/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync.ts b/src/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync.ts similarity index 93% rename from src/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync.ts rename to src/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync.ts index 0cbf7d07..54da0dcd 100644 --- a/src/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync.ts +++ b/src/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync.ts @@ -1,12 +1,12 @@ import type { ChunkMaskWorkerRequest, ChunkMaskWorkerResponse, -} from "#/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.types"; +} from "#/shared/ui/effects/domDisintegrate/chunkMaskWorker.types"; import { type ChunkMaskSequence, type ChunkMaskSequenceInput, createChunkMaskSequenceAsync, -} from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; +} from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; let requestCounter = 0; let workerInstance: Worker | null = null; @@ -67,7 +67,7 @@ const warnChunkMaskFallback = (reason: string): void => { return; } - console.warn(`[thanosDisintegrate] ${reason}`); + console.warn(`[domDisintegrate] ${reason}`); }; const buildChunkMaskSequenceViaWorker = ( @@ -152,7 +152,7 @@ export const buildChunkMaskSequenceAsync = async ( return await buildChunkMaskSequenceYieldingOnMainThread(input); } catch (error) { console.error( - "[thanosDisintegrate] Chunk mask build failed on the main thread.", + "[domDisintegrate] Chunk mask build failed on the main thread.", error, ); return null; diff --git a/src/shared/ui/effects/thanosDisintegrate/buildThanosCapture.ts b/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts similarity index 63% rename from src/shared/ui/effects/thanosDisintegrate/buildThanosCapture.ts rename to src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts index a380fb98..be90d079 100644 --- a/src/shared/ui/effects/thanosDisintegrate/buildThanosCapture.ts +++ b/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts @@ -1,18 +1,18 @@ -import { captureElementViaSnapdom } from "#/shared/ui/effects/thanosDisintegrate/captureElementViaSnapdom"; -import { captureElementViaSvgForeignObject } from "#/shared/ui/effects/thanosDisintegrate/captureElementViaSvgForeignObject"; -import { createFallbackParticlesFromElement } from "#/shared/ui/effects/thanosDisintegrate/createFallbackParticlesFromElement"; -import { createParticlesFromImageData } from "#/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData"; -import { normalizeCaptureToDisplay } from "#/shared/ui/effects/thanosDisintegrate/normalizeCaptureToDisplay"; +import { captureElementViaSnapdom } from "#/shared/ui/effects/domDisintegrate/captureElementViaSnapdom"; +import { captureElementViaSvgForeignObject } from "#/shared/ui/effects/domDisintegrate/captureElementViaSvgForeignObject"; +import { createFallbackParticlesFromElement } from "#/shared/ui/effects/domDisintegrate/createFallbackParticlesFromElement"; +import { createParticlesFromImageData } from "#/shared/ui/effects/domDisintegrate/createParticlesFromImageData"; import type { - BuildThanosCaptureOptions, - ThanosCaptureSnapshot, -} from "#/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot"; -import { getElementDisplaySize } from "#/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot"; -import { waitForDocumentFonts } from "#/shared/ui/effects/thanosDisintegrate/waitForDocumentFonts"; + BuildDisintegrateCaptureOptions, + DisintegrateCaptureSnapshot, +} from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { getElementDisplaySize } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { normalizeCaptureToDisplay } from "#/shared/ui/effects/domDisintegrate/normalizeCaptureToDisplay"; +import { waitForDocumentFonts } from "#/shared/ui/effects/domDisintegrate/waitForDocumentFonts"; const tryCreateParticlesFromCanvas = ( canvas: HTMLCanvasElement, - disintegrateOptions?: BuildThanosCaptureOptions["disintegrateOptions"], + disintegrateOptions?: BuildDisintegrateCaptureOptions["disintegrateOptions"], ) => { const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { @@ -32,10 +32,10 @@ const tryCreateParticlesFromCanvas = ( }; /** Builds particles + optional snapshot canvas for the disintegration effect. */ -export const buildThanosCapture = async ( +export const buildDisintegrateCapture = async ( element: HTMLElement, - { mode, disintegrateOptions }: BuildThanosCaptureOptions, -): Promise => { + { mode, disintegrateOptions }: BuildDisintegrateCaptureOptions, +): Promise => { if (mode === "quality") { await waitForDocumentFonts(); } diff --git a/src/shared/ui/effects/thanosDisintegrate/canvasToDataUrl.ts b/src/shared/ui/effects/domDisintegrate/canvasToDataUrl.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/canvasToDataUrl.ts rename to src/shared/ui/effects/domDisintegrate/canvasToDataUrl.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/captureElementToCanvas.ts b/src/shared/ui/effects/domDisintegrate/captureElementToCanvas.ts similarity index 78% rename from src/shared/ui/effects/thanosDisintegrate/captureElementToCanvas.ts rename to src/shared/ui/effects/domDisintegrate/captureElementToCanvas.ts index 86116d5b..b1a6321d 100644 --- a/src/shared/ui/effects/thanosDisintegrate/captureElementToCanvas.ts +++ b/src/shared/ui/effects/domDisintegrate/captureElementToCanvas.ts @@ -1,4 +1,4 @@ -import { captureElementViaSvgForeignObject } from "#/shared/ui/effects/thanosDisintegrate/captureElementViaSvgForeignObject"; +import { captureElementViaSvgForeignObject } from "#/shared/ui/effects/domDisintegrate/captureElementViaSvgForeignObject"; /** Fast synchronous-path capture (SVG foreignObject). SnapDOM runs only via idle warm-up. */ export const captureElementToCanvas = captureElementViaSvgForeignObject; diff --git a/src/shared/ui/effects/thanosDisintegrate/captureElementViaSnapdom.ts b/src/shared/ui/effects/domDisintegrate/captureElementViaSnapdom.ts similarity index 90% rename from src/shared/ui/effects/thanosDisintegrate/captureElementViaSnapdom.ts rename to src/shared/ui/effects/domDisintegrate/captureElementViaSnapdom.ts index 82943bf0..f23dcf6d 100644 --- a/src/shared/ui/effects/thanosDisintegrate/captureElementViaSnapdom.ts +++ b/src/shared/ui/effects/domDisintegrate/captureElementViaSnapdom.ts @@ -1,6 +1,6 @@ import { snapdom } from "@zumer/snapdom"; -import { waitForDocumentFonts } from "#/shared/ui/effects/thanosDisintegrate/waitForDocumentFonts"; +import { waitForDocumentFonts } from "#/shared/ui/effects/domDisintegrate/waitForDocumentFonts"; const getPageBackgroundColor = (): string | undefined => { const background = window.getComputedStyle(document.body).backgroundColor; diff --git a/src/shared/ui/effects/thanosDisintegrate/captureElementViaSvgForeignObject.ts b/src/shared/ui/effects/domDisintegrate/captureElementViaSvgForeignObject.ts similarity index 89% rename from src/shared/ui/effects/thanosDisintegrate/captureElementViaSvgForeignObject.ts rename to src/shared/ui/effects/domDisintegrate/captureElementViaSvgForeignObject.ts index cc82d2e1..6cfcf14d 100644 --- a/src/shared/ui/effects/thanosDisintegrate/captureElementViaSvgForeignObject.ts +++ b/src/shared/ui/effects/domDisintegrate/captureElementViaSvgForeignObject.ts @@ -1,6 +1,6 @@ -import { collectCaptureTypographyCss } from "#/shared/ui/effects/thanosDisintegrate/collectDocumentFontFaceCss"; -import { prepareCaptureClone } from "#/shared/ui/effects/thanosDisintegrate/prepareCaptureClone"; -import { waitForDocumentFonts } from "#/shared/ui/effects/thanosDisintegrate/waitForDocumentFonts"; +import { collectCaptureTypographyCss } from "#/shared/ui/effects/domDisintegrate/collectDocumentFontFaceCss"; +import { prepareCaptureClone } from "#/shared/ui/effects/domDisintegrate/prepareCaptureClone"; +import { waitForDocumentFonts } from "#/shared/ui/effects/domDisintegrate/waitForDocumentFonts"; const loadImage = (url: string): Promise => new Promise((resolve, reject) => { diff --git a/src/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.ts b/src/shared/ui/effects/domDisintegrate/chunkMaskWorker.ts similarity index 90% rename from src/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.ts rename to src/shared/ui/effects/domDisintegrate/chunkMaskWorker.ts index e21ce6e9..678f6688 100644 --- a/src/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.ts +++ b/src/shared/ui/effects/domDisintegrate/chunkMaskWorker.ts @@ -1,8 +1,8 @@ import type { ChunkMaskWorkerRequest, ChunkMaskWorkerResponse, -} from "#/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.types"; -import { createChunkMaskSequenceAsync } from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; +} from "#/shared/ui/effects/domDisintegrate/chunkMaskWorker.types"; +import { createChunkMaskSequenceAsync } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; self.addEventListener( "message", diff --git a/src/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.types.ts b/src/shared/ui/effects/domDisintegrate/chunkMaskWorker.types.ts similarity index 92% rename from src/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.types.ts rename to src/shared/ui/effects/domDisintegrate/chunkMaskWorker.types.ts index d9c21f0b..9859e931 100644 --- a/src/shared/ui/effects/thanosDisintegrate/chunkMaskWorker.types.ts +++ b/src/shared/ui/effects/domDisintegrate/chunkMaskWorker.types.ts @@ -1,4 +1,4 @@ -import type { ChunkMaskSequenceInput } from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; +import type { ChunkMaskSequenceInput } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; export type ChunkMaskSequencePayload = { timeThresholds: number[]; diff --git a/src/shared/ui/effects/thanosDisintegrate/collectDocumentFontFaceCss.ts b/src/shared/ui/effects/domDisintegrate/collectDocumentFontFaceCss.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/collectDocumentFontFaceCss.ts rename to src/shared/ui/effects/domDisintegrate/collectDocumentFontFaceCss.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/constants.ts b/src/shared/ui/effects/domDisintegrate/constants.ts similarity index 60% rename from src/shared/ui/effects/thanosDisintegrate/constants.ts rename to src/shared/ui/effects/domDisintegrate/constants.ts index e3da1384..7ea47908 100644 --- a/src/shared/ui/effects/thanosDisintegrate/constants.ts +++ b/src/shared/ui/effects/domDisintegrate/constants.ts @@ -1,6 +1,6 @@ -import type { ResolvedThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { ResolvedDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/types"; -export const THANOS_DISINTEGRATE_DEFAULTS = { +export const DOM_DISINTEGRATE_DEFAULTS = { particleStep: 3, particleSize: 2, maxVelocity: 165, @@ -16,4 +16,4 @@ export const THANOS_DISINTEGRATE_DEFAULTS = { particleRenderMode: "color", useChunkMaskWorker: true, particleMotionMode: "windy", -} as const satisfies ResolvedThanosDisintegrateOptions; +} as const satisfies ResolvedDomDisintegrateOptions; diff --git a/src/shared/ui/effects/thanosDisintegrate/copyInlineStyles.ts b/src/shared/ui/effects/domDisintegrate/copyInlineStyles.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/copyInlineStyles.ts rename to src/shared/ui/effects/domDisintegrate/copyInlineStyles.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence.ts b/src/shared/ui/effects/domDisintegrate/createChunkMaskSequence.ts similarity index 97% rename from src/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence.ts rename to src/shared/ui/effects/domDisintegrate/createChunkMaskSequence.ts index 9391a3b1..74d97674 100644 --- a/src/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence.ts +++ b/src/shared/ui/effects/domDisintegrate/createChunkMaskSequence.ts @@ -1,11 +1,14 @@ -import { canvasToDataUrl } from "#/shared/ui/effects/thanosDisintegrate/canvasToDataUrl"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import { canvasToDataUrl } from "#/shared/ui/effects/domDisintegrate/canvasToDataUrl"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; type Mask2dContext = | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; -export type ChunkMaskParticle = Pick; +export type ChunkMaskParticle = Pick< + DisintegrateParticle, + "x" | "y" | "releaseTime" +>; export type ChunkMaskSequence = { /** Elapsed times (seconds) at which the mask advances to the next step. */ diff --git a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts similarity index 79% rename from src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts rename to src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts index 86b77f26..4f346ecf 100644 --- a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts +++ b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts @@ -1,9 +1,9 @@ -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import { getParticleMotionProfile } from "#/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import { getParticleMotionProfile } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; type CreateParticleInput = { x: number; @@ -12,7 +12,7 @@ type CreateParticleInput = { alpha: number; surfaceWidth: number; surfaceHeight: number; - options?: ThanosDisintegrateOptions; + options?: DomDisintegrateOptions; }; const createSplatVelocity = ( @@ -20,7 +20,7 @@ const createSplatVelocity = ( y: number, surfaceWidth: number, surfaceHeight: number, - resolvedOptions: ReturnType, + resolvedOptions: ReturnType, ): { vx: number; vy: number } => { const centerX = surfaceWidth / 2; const centerY = surfaceHeight / 2; @@ -42,7 +42,7 @@ const createSplatVelocity = ( }; const createSparkVelocity = ( - resolvedOptions: ReturnType, + resolvedOptions: ReturnType, ): { vx: number; vy: number } => { const launchAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.35; const speed = resolvedOptions.maxVelocity * (0.32 + Math.random() * 0.58); @@ -59,7 +59,7 @@ const createSparkVelocity = ( }; /** Creates one particle with outward-biased velocity, drag, rotation, and fade timing. */ -export const createThanosParticle = ({ +export const createDisintegrateParticle = ({ x, y, color, @@ -67,8 +67,8 @@ export const createThanosParticle = ({ surfaceWidth, surfaceHeight, options, -}: CreateParticleInput): ThanosParticle => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); +}: CreateParticleInput): DisintegrateParticle => { + const resolvedOptions = resolveDomDisintegrateOptions(options); const motionProfile = getParticleMotionProfile( resolvedOptions.particleMotionMode, ); diff --git a/src/shared/ui/effects/thanosDisintegrate/createFallbackParticlesFromElement.ts b/src/shared/ui/effects/domDisintegrate/createFallbackParticlesFromElement.ts similarity index 64% rename from src/shared/ui/effects/thanosDisintegrate/createFallbackParticlesFromElement.ts rename to src/shared/ui/effects/domDisintegrate/createFallbackParticlesFromElement.ts index 80450c24..86fbe8cb 100644 --- a/src/shared/ui/effects/thanosDisintegrate/createFallbackParticlesFromElement.ts +++ b/src/shared/ui/effects/domDisintegrate/createFallbackParticlesFromElement.ts @@ -1,21 +1,21 @@ -import { createThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/createThanosParticle"; +import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; import { parseCssColor, type RgbaColor, -} from "#/shared/ui/effects/thanosDisintegrate/parseCssColor"; -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import { sampleColorAtLocalPoint } from "#/shared/ui/effects/thanosDisintegrate/sampleColorAtLocalPoint"; +} from "#/shared/ui/effects/domDisintegrate/parseCssColor"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import { sampleColorAtLocalPoint } from "#/shared/ui/effects/domDisintegrate/sampleColorAtLocalPoint"; import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; /** Builds particles from live DOM colors when raster capture is empty (e.g. glass blur). */ export const createFallbackParticlesFromElement = ( element: HTMLElement, - options?: ThanosDisintegrateOptions, -): ThanosParticle[] => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); + options?: DomDisintegrateOptions, +): DisintegrateParticle[] => { + const resolvedOptions = resolveDomDisintegrateOptions(options); const rootRect = element.getBoundingClientRect(); const width = Math.round(rootRect.width); const height = Math.round(rootRect.height); @@ -32,7 +32,7 @@ export const createFallbackParticlesFromElement = ( alpha: 0.85, }; - const particles: ThanosParticle[] = []; + const particles: DisintegrateParticle[] = []; const { particleStep } = resolvedOptions; for (let y = 0; y < height; y += particleStep) { @@ -42,7 +42,7 @@ export const createFallbackParticlesFromElement = ( const color: RgbaColor = sampleColorAtLocalPoint(element, sampleX, sampleY) ?? defaultColor; particles.push( - createThanosParticle({ + createDisintegrateParticle({ x, y, color: `rgb(${color.red}, ${color.green}, ${color.blue})`, diff --git a/src/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData.ts b/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts similarity index 60% rename from src/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData.ts rename to src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts index 5d9236db..96ecdcfd 100644 --- a/src/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData.ts +++ b/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts @@ -1,16 +1,16 @@ -import { createThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/createThanosParticle"; -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; +import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; export const createParticlesFromImageData = ( imageData: ImageData, - options?: ThanosDisintegrateOptions, -): ThanosParticle[] => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); - const particles: ThanosParticle[] = []; + options?: DomDisintegrateOptions, +): DisintegrateParticle[] => { + const resolvedOptions = resolveDomDisintegrateOptions(options); + const particles: DisintegrateParticle[] = []; const { data, width, height } = imageData; const { particleStep } = resolvedOptions; @@ -27,7 +27,7 @@ export const createParticlesFromImageData = ( const blue = data[index + 2] ?? 0; particles.push( - createThanosParticle({ + createDisintegrateParticle({ x, y, color: `rgb(${red}, ${green}, ${blue})`, diff --git a/src/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot.ts b/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts similarity index 54% rename from src/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot.ts rename to src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts index d62786f1..ab4ac9bf 100644 --- a/src/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot.ts +++ b/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts @@ -1,21 +1,21 @@ import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; -export type ThanosCaptureSnapshot = { +export type DisintegrateCaptureSnapshot = { sourceCanvas: HTMLCanvasElement | null; - particles: ThanosParticle[]; + particles: DisintegrateParticle[]; displayWidth: number; displayHeight: number; }; -export const cloneThanosParticles = ( - particles: ThanosParticle[], -): ThanosParticle[] => particles.map((particle) => ({ ...particle })); +export const cloneDisintegrateParticles = ( + particles: DisintegrateParticle[], +): DisintegrateParticle[] => particles.map((particle) => ({ ...particle })); -export const isThanosCaptureSnapshotValid = ( - snapshot: ThanosCaptureSnapshot, +export const isDisintegrateCaptureSnapshotValid = ( + snapshot: DisintegrateCaptureSnapshot, element: HTMLElement, ): boolean => { const rect = element.getBoundingClientRect(); @@ -39,9 +39,9 @@ export const getElementDisplaySize = ( }; }; -export type BuildThanosCaptureMode = "fast" | "quality"; +export type BuildDisintegrateCaptureMode = "fast" | "quality"; -export type BuildThanosCaptureOptions = { - mode: BuildThanosCaptureMode; - disintegrateOptions?: ThanosDisintegrateOptions; +export type BuildDisintegrateCaptureOptions = { + mode: BuildDisintegrateCaptureMode; + disintegrateOptions?: DomDisintegrateOptions; }; diff --git a/src/shared/ui/effects/domDisintegrate/domDisintegrateError.ts b/src/shared/ui/effects/domDisintegrate/domDisintegrateError.ts new file mode 100644 index 00000000..c4c2e0b0 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/domDisintegrateError.ts @@ -0,0 +1,15 @@ +export type DomDisintegrateErrorCode = + | "canvas_unavailable" + | "no_particles" + | "no_target" + | "zero_size_surface"; + +export class DomDisintegrateError extends Error { + readonly code: DomDisintegrateErrorCode; + + constructor(code: DomDisintegrateErrorCode, message: string) { + super(message); + this.name = "DomDisintegrateError"; + this.code = code; + } +} diff --git a/src/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame.ts b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts similarity index 85% rename from src/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame.ts rename to src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts index ca8dcac9..fd9660c2 100644 --- a/src/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame.ts +++ b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts @@ -1,15 +1,15 @@ -import type { ThanosParticleRenderMode } from "#/shared/ui/effects/thanosDisintegrate/types"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DisintegrateParticleRenderMode } from "#/shared/ui/effects/domDisintegrate/types"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; export type DrawDisintegrationFrameOptions = { - renderMode?: ThanosParticleRenderMode; + renderMode?: DisintegrateParticleRenderMode; sourceCanvas?: HTMLCanvasElement | null; }; /** Draws flying particles on the canvas layer beneath the live masked surface. */ export const drawDisintegrationFrame = ( context: CanvasRenderingContext2D, - particles: ThanosParticle[], + particles: DisintegrateParticle[], elapsedSeconds: number, canvasWidth: number, canvasHeight: number, diff --git a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts similarity index 65% rename from src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts rename to src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts index bae820aa..5c30972b 100644 --- a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts @@ -1,15 +1,15 @@ -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import { SPARK_TRAVEL_PADDING, SPARK_UPWARD_TRAVEL_FACTOR, -} from "#/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics"; -import type { ThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/types"; +} from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import type { DomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/types"; /** Max distance a particle can travel from its origin during the animation. */ export const getParticleMaxTravel = ( - options?: ThanosDisintegrateOptions, + options?: DomDisintegrateOptions, ): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); + const resolvedOptions = resolveDomDisintegrateOptions(options); const duration = resolvedOptions.maxDuration; const baseTravel = resolvedOptions.maxVelocity * duration + @@ -30,9 +30,9 @@ export const getParticleMaxTravel = ( /** Margin around each chunk hole on the particle mask so flying pixels stay visible. */ export const getParticleRevealMargin = ( - options?: ThanosDisintegrateOptions, + options?: DomDisintegrateOptions, ): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); + const resolvedOptions = resolveDomDisintegrateOptions(options); return Math.ceil( getParticleMaxTravel(options) + resolvedOptions.particleSize, ); @@ -40,9 +40,9 @@ export const getParticleRevealMargin = ( /** Extra canvas margin so flying particles are not clipped by the surface bounds. */ export const getParticleCanvasPadding = ( - options?: ThanosDisintegrateOptions, + options?: DomDisintegrateOptions, ): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); + const resolvedOptions = resolveDomDisintegrateOptions(options); return Math.ceil( getParticleMaxTravel(options) + resolvedOptions.particleSize * 4, ); diff --git a/src/shared/ui/effects/thanosDisintegrate/getWaveDisintegrationProgress.ts b/src/shared/ui/effects/domDisintegrate/getWaveDisintegrationProgress.ts similarity index 86% rename from src/shared/ui/effects/thanosDisintegrate/getWaveDisintegrationProgress.ts rename to src/shared/ui/effects/domDisintegrate/getWaveDisintegrationProgress.ts index 2a2555dc..1990855d 100644 --- a/src/shared/ui/effects/thanosDisintegrate/getWaveDisintegrationProgress.ts +++ b/src/shared/ui/effects/domDisintegrate/getWaveDisintegrationProgress.ts @@ -1,10 +1,10 @@ -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; const FADE_SECONDS_AFTER_RELEASE = 0.12; /** Returns 0–1 progress of how much of the surface has been consumed by the wave. */ export const getWaveDisintegrationProgress = ( - particles: ThanosParticle[], + particles: DisintegrateParticle[], elapsedSeconds: number, ): number => { if (particles.length === 0) { diff --git a/src/shared/ui/effects/domDisintegrate/index.ts b/src/shared/ui/effects/domDisintegrate/index.ts new file mode 100644 index 00000000..f4c6a4eb --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/index.ts @@ -0,0 +1,20 @@ +export { DOM_DISINTEGRATE_DEFAULTS } from "#/shared/ui/effects/domDisintegrate/constants"; +export { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +export { + runDomDisintegrate, + type RunDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/runDomDisintegrate"; +export { + DomDisintegrateError, + type DomDisintegrateErrorCode, +} from "#/shared/ui/effects/domDisintegrate/domDisintegrateError"; +export { useDomDisintegrate } from "#/shared/ui/effects/domDisintegrate/useDomDisintegrate"; +export type { + DomDisintegrateOptions, + DomDisintegrateOrigin, + DomDisintegrateMaskMode, + DisintegrateMaskStrategy, + DisintegrateParticleMotionMode, + DisintegrateParticleRenderMode, + DisintegrateParticle, +} from "#/shared/ui/effects/domDisintegrate/types"; diff --git a/src/shared/ui/effects/thanosDisintegrate/maskStrategies.ts b/src/shared/ui/effects/domDisintegrate/maskStrategies.ts similarity index 97% rename from src/shared/ui/effects/thanosDisintegrate/maskStrategies.ts rename to src/shared/ui/effects/domDisintegrate/maskStrategies.ts index 81512103..62e1ee27 100644 --- a/src/shared/ui/effects/thanosDisintegrate/maskStrategies.ts +++ b/src/shared/ui/effects/domDisintegrate/maskStrategies.ts @@ -1,4 +1,4 @@ -import type { ThanosMaskStrategy } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DisintegrateMaskStrategy } from "#/shared/ui/effects/domDisintegrate/types"; export type MaskStrategyGrid = { grid: number[][]; @@ -228,7 +228,7 @@ const randomMask: MaskStrategyGenerator = (cols, rows) => { }; export const MASK_STRATEGY_GENERATORS: Record< - Exclude, + Exclude, MaskStrategyGenerator > = { leftToRight: leftToRightMask, @@ -248,7 +248,7 @@ export const MASK_STRATEGY_GENERATORS: Record< }; export const createMaskStrategyGrid = ( - strategy: Exclude, + strategy: Exclude, cols: number, rows: number, ): MaskStrategyGrid => MASK_STRATEGY_GENERATORS[strategy](cols, rows); diff --git a/src/shared/ui/effects/thanosDisintegrate/normalizeCaptureToDisplay.ts b/src/shared/ui/effects/domDisintegrate/normalizeCaptureToDisplay.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/normalizeCaptureToDisplay.ts rename to src/shared/ui/effects/domDisintegrate/normalizeCaptureToDisplay.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/overlayPosition.ts b/src/shared/ui/effects/domDisintegrate/overlayPosition.ts similarity index 96% rename from src/shared/ui/effects/thanosDisintegrate/overlayPosition.ts rename to src/shared/ui/effects/domDisintegrate/overlayPosition.ts index 9ac5b284..b2884dc5 100644 --- a/src/shared/ui/effects/thanosDisintegrate/overlayPosition.ts +++ b/src/shared/ui/effects/domDisintegrate/overlayPosition.ts @@ -1,4 +1,4 @@ -import { clearWaveMaskFromElement } from "#/shared/ui/effects/thanosDisintegrate/syncElementWaveMask"; +import { clearWaveMaskFromElement } from "#/shared/ui/effects/domDisintegrate/syncElementWaveMask"; /** Keeps the live surface visible; particles render on a canvas beneath it. */ export const prepareElementForDisintegrate = ( diff --git a/src/shared/ui/effects/thanosDisintegrate/parseCssColor.ts b/src/shared/ui/effects/domDisintegrate/parseCssColor.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/parseCssColor.ts rename to src/shared/ui/effects/domDisintegrate/parseCssColor.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/prepareCaptureClone.ts b/src/shared/ui/effects/domDisintegrate/prepareCaptureClone.ts similarity index 94% rename from src/shared/ui/effects/thanosDisintegrate/prepareCaptureClone.ts rename to src/shared/ui/effects/domDisintegrate/prepareCaptureClone.ts index 08377a31..1dc5597e 100644 --- a/src/shared/ui/effects/thanosDisintegrate/prepareCaptureClone.ts +++ b/src/shared/ui/effects/domDisintegrate/prepareCaptureClone.ts @@ -1,4 +1,4 @@ -import { copyInlineStyles } from "#/shared/ui/effects/thanosDisintegrate/copyInlineStyles"; +import { copyInlineStyles } from "#/shared/ui/effects/domDisintegrate/copyInlineStyles"; /** Strips live-only effects and copies raster-friendly styles onto a capture clone. */ export const prepareCaptureClone = (element: HTMLElement): HTMLElement => { diff --git a/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts b/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts new file mode 100644 index 00000000..f9885b28 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts @@ -0,0 +1,12 @@ +import { DOM_DISINTEGRATE_DEFAULTS } from "#/shared/ui/effects/domDisintegrate/constants"; +import type { + DomDisintegrateOptions, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +export const resolveDomDisintegrateOptions = ( + options?: DomDisintegrateOptions, +): ResolvedDomDisintegrateOptions => ({ + ...DOM_DISINTEGRATE_DEFAULTS, + ...options, +}); diff --git a/src/shared/ui/effects/thanosDisintegrate/resolveRelativeOrigin.ts b/src/shared/ui/effects/domDisintegrate/resolveRelativeOrigin.ts similarity index 75% rename from src/shared/ui/effects/thanosDisintegrate/resolveRelativeOrigin.ts rename to src/shared/ui/effects/domDisintegrate/resolveRelativeOrigin.ts index a5c63ca8..8337050b 100644 --- a/src/shared/ui/effects/thanosDisintegrate/resolveRelativeOrigin.ts +++ b/src/shared/ui/effects/domDisintegrate/resolveRelativeOrigin.ts @@ -1,9 +1,9 @@ -import type { ThanosDisintegrateOrigin } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DomDisintegrateOrigin } from "#/shared/ui/effects/domDisintegrate/types"; /** Converts a client or local origin into coordinates relative to `element`. */ export const resolveRelativeOrigin = ( element: HTMLElement, - origin?: ThanosDisintegrateOrigin, + origin?: DomDisintegrateOrigin, ): { x: number; y: number } | null => { if (!origin) { return null; diff --git a/src/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate.ts b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts similarity index 80% rename from src/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate.ts rename to src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts index e63e9de3..3aeac624 100644 --- a/src/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts @@ -2,58 +2,58 @@ import { prefersReducedMotion } from "#/shared/lib/prefersReducedMotion"; import { assignParticleReleaseTimes, resolveEffectiveMaskStrategy, -} from "#/shared/ui/effects/thanosDisintegrate/assignParticleReleaseTimes"; -import { buildChunkMaskSequenceAsync } from "#/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync"; -import { buildThanosCapture } from "#/shared/ui/effects/thanosDisintegrate/buildThanosCapture"; -import type { ChunkMaskSequence } from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; -import { drawDisintegrationFrame } from "#/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame"; +} from "#/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes"; +import { buildChunkMaskSequenceAsync } from "#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"; +import { buildDisintegrateCapture } from "#/shared/ui/effects/domDisintegrate/buildDisintegrateCapture"; +import type { ChunkMaskSequence } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; +import { + cloneDisintegrateParticles, + type DisintegrateCaptureSnapshot, + isDisintegrateCaptureSnapshotValid, +} from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { DomDisintegrateError } from "#/shared/ui/effects/domDisintegrate/domDisintegrateError"; +import { drawDisintegrationFrame } from "#/shared/ui/effects/domDisintegrate/drawDisintegrationFrame"; import { getParticleCanvasPadding, getParticleRevealMargin, -} from "#/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding"; -import { getWaveDisintegrationProgress } from "#/shared/ui/effects/thanosDisintegrate/getWaveDisintegrationProgress"; +} from "#/shared/ui/effects/domDisintegrate/getParticleCanvasPadding"; +import { getWaveDisintegrationProgress } from "#/shared/ui/effects/domDisintegrate/getWaveDisintegrationProgress"; import { prepareElementForDisintegrate, subscribeToViewportChanges, syncFixedOverlayToElement, -} from "#/shared/ui/effects/thanosDisintegrate/overlayPosition"; -import { resolveRelativeOrigin } from "#/shared/ui/effects/thanosDisintegrate/resolveRelativeOrigin"; -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import { stepParticles } from "#/shared/ui/effects/thanosDisintegrate/stepParticles"; +} from "#/shared/ui/effects/domDisintegrate/overlayPosition"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import { resolveRelativeOrigin } from "#/shared/ui/effects/domDisintegrate/resolveRelativeOrigin"; +import { stepParticles } from "#/shared/ui/effects/domDisintegrate/stepParticles"; import { applyChunkMaskFrame, clearChunkMaskFromCanvas, clearChunkMaskFromElement, -} from "#/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence"; +} from "#/shared/ui/effects/domDisintegrate/syncChunkMaskSequence"; import { applyParticleWaveMaskToCanvas, applyWaveMaskToElement, clearParticleWaveMaskFromCanvas, clearWaveMaskFromElement, -} from "#/shared/ui/effects/thanosDisintegrate/syncElementWaveMask"; -import { - cloneThanosParticles, - isThanosCaptureSnapshotValid, - type ThanosCaptureSnapshot, -} from "#/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot"; -import { ThanosDisintegrateError } from "#/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError"; +} from "#/shared/ui/effects/domDisintegrate/syncElementWaveMask"; import type { - ResolvedThanosDisintegrateOptions, - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; + DisintegrateParticle, + DomDisintegrateOptions, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; const MAX_DELTA_SECONDS = 0.05; -export type RunThanosDisintegrateOptions = ThanosDisintegrateOptions & { +export type RunDomDisintegrateOptions = DomDisintegrateOptions & { /** Pre-built capture from idle warm-up; avoids blocking SnapDOM on dismiss. */ - captureSnapshot?: ThanosCaptureSnapshot | null; + captureSnapshot?: DisintegrateCaptureSnapshot | null; }; const applyOpacityCrossfade = ( element: HTMLElement, particleCanvas: HTMLCanvasElement, - particles: ThanosParticle[], + particles: DisintegrateParticle[], elapsedSeconds: number, ): void => { const disintegrationProgress = getWaveDisintegrationProgress( @@ -105,12 +105,12 @@ type DualLayerMaskState = { const syncDualLayerWave = ( element: HTMLElement, particleCanvas: HTMLCanvasElement, - particles: ThanosParticle[], + particles: DisintegrateParticle[], elapsedSeconds: number, relativeOrigin: { x: number; y: number } | null, displayWidth: number, displayHeight: number, - resolvedOptions: ResolvedThanosDisintegrateOptions, + resolvedOptions: ResolvedDomDisintegrateOptions, particlePadding: number, maskState: DualLayerMaskState, ): void => { @@ -167,38 +167,39 @@ const clearActiveMasks = ( }; /** - * Plays a Thanos-style particle disintegration on a DOM surface, then resolves. + * Plays a particle disintegration particle disintegration on a DOM surface, then resolves. * The live element stays visible with a synced radial mask; particles fly underneath. */ -export const runThanosDisintegrate = async ( +export const runDomDisintegrate = async ( element: HTMLElement, - options?: RunThanosDisintegrateOptions, + options?: RunDomDisintegrateOptions, ): Promise => { if (prefersReducedMotion()) { return; } const { captureSnapshot, ...disintegrateOptions } = options ?? {}; - const resolvedOptions = resolveThanosDisintegrateOptions(disintegrateOptions); + const resolvedOptions = resolveDomDisintegrateOptions(disintegrateOptions); const initialRect = element.getBoundingClientRect(); if (initialRect.width <= 0 || initialRect.height <= 0) { - throw new ThanosDisintegrateError( + throw new DomDisintegrateError( "zero_size_surface", "Cannot disintegrate an element with zero width or height.", ); } const snapshot = - captureSnapshot && isThanosCaptureSnapshotValid(captureSnapshot, element) + captureSnapshot && + isDisintegrateCaptureSnapshotValid(captureSnapshot, element) ? captureSnapshot - : await buildThanosCapture(element, { + : await buildDisintegrateCapture(element, { mode: "fast", disintegrateOptions: resolvedOptions, }); - const particles = cloneThanosParticles(snapshot.particles); + const particles = cloneDisintegrateParticles(snapshot.particles); if (particles.length === 0) { - throw new ThanosDisintegrateError( + throw new DomDisintegrateError( "no_particles", "Cannot disintegrate a surface with no sample particles.", ); @@ -273,7 +274,7 @@ export const runThanosDisintegrate = async ( if (!overlayContext) { isAnimationActive = false; maskState.chunkMaskSequence?.revoke(); - throw new ThanosDisintegrateError( + throw new DomDisintegrateError( "canvas_unavailable", "2D canvas context is unavailable for the particle overlay.", ); diff --git a/src/shared/ui/effects/thanosDisintegrate/sampleColorAtLocalPoint.ts b/src/shared/ui/effects/domDisintegrate/sampleColorAtLocalPoint.ts similarity index 97% rename from src/shared/ui/effects/thanosDisintegrate/sampleColorAtLocalPoint.ts rename to src/shared/ui/effects/domDisintegrate/sampleColorAtLocalPoint.ts index ad16e047..818d90a8 100644 --- a/src/shared/ui/effects/thanosDisintegrate/sampleColorAtLocalPoint.ts +++ b/src/shared/ui/effects/domDisintegrate/sampleColorAtLocalPoint.ts @@ -2,7 +2,7 @@ import { blendColors, parseCssColor, type RgbaColor, -} from "#/shared/ui/effects/thanosDisintegrate/parseCssColor"; +} from "#/shared/ui/effects/domDisintegrate/parseCssColor"; const getLuminance = (color: RgbaColor): number => (0.299 * color.red + 0.587 * color.green + 0.114 * color.blue) / 255; diff --git a/src/shared/ui/effects/thanosDisintegrate/scaleParticleCoordinates.ts b/src/shared/ui/effects/domDisintegrate/scaleParticleCoordinates.ts similarity index 74% rename from src/shared/ui/effects/thanosDisintegrate/scaleParticleCoordinates.ts rename to src/shared/ui/effects/domDisintegrate/scaleParticleCoordinates.ts index eb7e2635..e03ae850 100644 --- a/src/shared/ui/effects/thanosDisintegrate/scaleParticleCoordinates.ts +++ b/src/shared/ui/effects/domDisintegrate/scaleParticleCoordinates.ts @@ -1,7 +1,7 @@ -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; export const scaleParticleCoordinates = ( - particles: ThanosParticle[], + particles: DisintegrateParticle[], scaleX: number, scaleY: number, ): void => { diff --git a/src/shared/ui/effects/thanosDisintegrate/sparkFlutter.ts b/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/sparkFlutter.ts rename to src/shared/ui/effects/domDisintegrate/sparkFlutter.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts similarity index 85% rename from src/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics.ts rename to src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts index 963eb2bc..c9c58593 100644 --- a/src/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -1,4 +1,4 @@ -import type { ThanosParticleMotionMode } from "#/shared/ui/effects/thanosDisintegrate/types"; +import type { DisintegrateParticleMotionMode } from "#/shared/ui/effects/domDisintegrate/types"; export const SPARK_FLUTTER_STRENGTH = 210; export const SPARK_BUOYANCY = -260; @@ -19,7 +19,7 @@ type ParticleMotionProfile = { }; const PARTICLE_MOTION_PROFILES: Record< - ThanosParticleMotionMode, + DisintegrateParticleMotionMode, ParticleMotionProfile > = { splat: { @@ -43,5 +43,5 @@ const PARTICLE_MOTION_PROFILES: Record< }; export const getParticleMotionProfile = ( - motionMode: ThanosParticleMotionMode, + motionMode: DisintegrateParticleMotionMode, ): ParticleMotionProfile => PARTICLE_MOTION_PROFILES[motionMode]; diff --git a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts similarity index 83% rename from src/shared/ui/effects/thanosDisintegrate/stepParticles.ts rename to src/shared/ui/effects/domDisintegrate/stepParticles.ts index 78d7b925..e25d501d 100644 --- a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -1,22 +1,22 @@ -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import { sampleSparkFlutter } from "#/shared/ui/effects/thanosDisintegrate/sparkFlutter"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import { sampleSparkFlutter } from "#/shared/ui/effects/domDisintegrate/sparkFlutter"; import { SPARK_BUOYANCY, SPARK_BUOYANCY_DECAY, SPARK_FLUTTER_STRENGTH, SPARK_GRAVITY_RAMP_SECONDS, SPARK_WIND_MULTIPLIER, -} from "#/shared/ui/effects/thanosDisintegrate/sparkParticlePhysics"; +} from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; const applyDrag = (velocity: number, drag: number, deltaSeconds: number) => velocity * Math.pow(drag, deltaSeconds * 60); const updateFadeAlpha = ( - particle: ThanosParticle, + particle: DisintegrateParticle, timeSinceRelease: number, ): void => { const progress = timeSinceRelease / particle.fadeDuration; @@ -31,7 +31,7 @@ const updateFadeAlpha = ( }; const stepSplatParticle = ( - particle: ThanosParticle, + particle: DisintegrateParticle, deltaSeconds: number, windX: number, windY: number, @@ -46,7 +46,7 @@ const stepSplatParticle = ( }; const stepSparkParticle = ( - particle: ThanosParticle, + particle: DisintegrateParticle, deltaSeconds: number, elapsedSeconds: number, timeSinceRelease: number, @@ -80,12 +80,12 @@ const stepSparkParticle = ( /** Advances particle physics using delta time (stable on high-refresh displays). */ export const stepParticles = ( - particles: ThanosParticle[], + particles: DisintegrateParticle[], deltaSeconds: number, elapsedSeconds: number, - options?: ThanosDisintegrateOptions, + options?: DomDisintegrateOptions, ): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); + const resolvedOptions = resolveDomDisintegrateOptions(options); let visibleCount = 0; for (const particle of particles) { diff --git a/src/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence.ts b/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts similarity index 83% rename from src/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence.ts rename to src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts index 426c165c..354763d3 100644 --- a/src/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence.ts +++ b/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts @@ -1,9 +1,9 @@ -import type { ChunkMaskSequence } from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; -import { getChunkMaskIndex } from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; +import type { ChunkMaskSequence } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; +import { getChunkMaskIndex } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; import { clearParticleWaveMaskFromCanvas, clearWaveMaskFromElement, -} from "#/shared/ui/effects/thanosDisintegrate/syncElementWaveMask"; +} from "#/shared/ui/effects/domDisintegrate/syncElementWaveMask"; const applyMaskImage = ( target: HTMLElement, diff --git a/src/shared/ui/effects/thanosDisintegrate/syncElementWaveMask.ts b/src/shared/ui/effects/domDisintegrate/syncElementWaveMask.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/syncElementWaveMask.ts rename to src/shared/ui/effects/domDisintegrate/syncElementWaveMask.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/types.ts b/src/shared/ui/effects/domDisintegrate/types.ts similarity index 79% rename from src/shared/ui/effects/thanosDisintegrate/types.ts rename to src/shared/ui/effects/domDisintegrate/types.ts index cdfaf729..7904d0c8 100644 --- a/src/shared/ui/effects/thanosDisintegrate/types.ts +++ b/src/shared/ui/effects/domDisintegrate/types.ts @@ -1,7 +1,7 @@ -export type ThanosDisintegrateMaskMode = "radial" | "chunks"; +export type DomDisintegrateMaskMode = "radial" | "chunks"; /** Click-origin radial wave or a morpheus-style grid dissolve pattern. */ -export type ThanosMaskStrategy = +export type DisintegrateMaskStrategy = | "wave" | "centerOut" | "sand" @@ -18,12 +18,12 @@ export type ThanosMaskStrategy = | "splitHorizontal" | "splitVertical"; -export type ThanosParticleRenderMode = "color" | "sprite"; +export type DisintegrateParticleRenderMode = "color" | "sprite"; /** Outward burst ("splat") or fire-spark flutter with buoyant rise ("windy"). */ -export type ThanosParticleMotionMode = "splat" | "windy"; +export type DisintegrateParticleMotionMode = "splat" | "windy"; -export type ThanosParticle = { +export type DisintegrateParticle = { x: number; y: number; /** Snapshot grid position used to punch holes in the captured surface. */ @@ -49,11 +49,11 @@ export type ThanosParticle = { turbulenceSeed: number; }; -export type ThanosDisintegrateOrigin = +export type DomDisintegrateOrigin = | { x: number; y: number } | { clientX: number; clientY: number }; -export type ThanosDisintegrateOptions = { +export type DomDisintegrateOptions = { /** Pixel stride when sampling the captured surface. */ particleStep?: number; /** Base particle size in CSS pixels. */ @@ -71,26 +71,26 @@ export type ThanosDisintegrateOptions = { /** Canvas stacking order while animating. */ zIndex?: number; /** Click origin for a radial crumble wave (local or client coordinates). */ - origin?: ThanosDisintegrateOrigin; + origin?: DomDisintegrateOrigin; /** Radial wave speed in px/s. */ waveSpeed?: number; /** Dissolve mask style: smooth radial wave or pixel chunk bitmasks. */ - maskMode?: ThanosDisintegrateMaskMode; + maskMode?: DomDisintegrateMaskMode; /** Max precomputed chunk mask steps (bounds memory for large surfaces). */ maxChunkMaskSteps?: number; /** Dissolve pattern for particle release times (defaults to wave when origin is set). */ - maskStrategy?: ThanosMaskStrategy; + maskStrategy?: DisintegrateMaskStrategy; /** Seconds for grid strategies to spread from first to last cell. */ maskSpreadDuration?: number; /** Solid-color squares or snapshot sprites via drawImage. */ - particleRenderMode?: ThanosParticleRenderMode; + particleRenderMode?: DisintegrateParticleRenderMode; /** Build chunk masks in a Web Worker when available. */ useChunkMaskWorker?: boolean; /** Particle physics style after release (defaults to windy). */ - particleMotionMode?: ThanosParticleMotionMode; + particleMotionMode?: DisintegrateParticleMotionMode; }; /** Options with defaults applied; excludes runtime-only `origin` and strategy defaulting. */ -export type ResolvedThanosDisintegrateOptions = Required< - Omit +export type ResolvedDomDisintegrateOptions = Required< + Omit >; diff --git a/src/shared/ui/effects/thanosDisintegrate/useThanosDisintegrate.ts b/src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts similarity index 67% rename from src/shared/ui/effects/thanosDisintegrate/useThanosDisintegrate.ts rename to src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts index f4795491..1ea46e33 100644 --- a/src/shared/ui/effects/thanosDisintegrate/useThanosDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts @@ -1,27 +1,27 @@ import { type RefCallback, useCallback, useEffect, useRef } from "react"; -import { runThanosDisintegrate } from "#/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate"; -import type { RunThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate"; -import type { ThanosCaptureSnapshot } from "#/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot"; -import { ThanosDisintegrateError } from "#/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError"; -import { warmThanosCapture } from "#/shared/ui/effects/thanosDisintegrate/warmThanosCapture"; +import type { DisintegrateCaptureSnapshot } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { DomDisintegrateError } from "#/shared/ui/effects/domDisintegrate/domDisintegrateError"; +import { runDomDisintegrate } from "#/shared/ui/effects/domDisintegrate/runDomDisintegrate"; +import type { RunDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/runDomDisintegrate"; +import { warmDisintegrateCapture } from "#/shared/ui/effects/domDisintegrate/warmDisintegrateCapture"; -type UseThanosDisintegrateResult = { +type UseDomDisintegrateResult = { /** Attach to the visual surface that should crumble away. */ targetRef: RefCallback; /** Starts the disintegration animation on `targetRef`. */ - disintegrate: (options?: RunThanosDisintegrateOptions) => Promise; + disintegrate: (options?: RunDomDisintegrateOptions) => Promise; /** Drops any pre-warmed capture so the next dismiss re-captures. */ invalidateCapture: () => void; }; /** - * Reusable hook for triggering a Thanos-style dismiss animation on a DOM node. + * Reusable hook for triggering a particle disintegration dismiss animation on a DOM node. * Pre-warms a quality capture in idle time so dismiss stays responsive. */ -export const useThanosDisintegrate = (): UseThanosDisintegrateResult => { +export const useDomDisintegrate = (): UseDomDisintegrateResult => { const elementRef = useRef(null); - const captureCacheRef = useRef(null); + const captureCacheRef = useRef(null); const isAnimatingRef = useRef(false); const cancelWarmRef = useRef<(() => void) | null>(null); @@ -33,7 +33,7 @@ export const useThanosDisintegrate = (): UseThanosDisintegrateResult => { } cancelWarmRef.current?.(); - cancelWarmRef.current = warmThanosCapture(element, captureCacheRef); + cancelWarmRef.current = warmDisintegrateCapture(element, captureCacheRef); }, []); const targetRef = useCallback((node: HTMLDivElement | null) => { @@ -44,7 +44,7 @@ export const useThanosDisintegrate = (): UseThanosDisintegrateResult => { if (node) { // Chunk mask workers are created lazily when maskMode is "chunks". - cancelWarmRef.current = warmThanosCapture(node, captureCacheRef); + cancelWarmRef.current = warmDisintegrateCapture(node, captureCacheRef); } }, []); @@ -79,14 +79,14 @@ export const useThanosDisintegrate = (): UseThanosDisintegrateResult => { }, [invalidateCapture]); const disintegrate = useCallback( - async (options?: RunThanosDisintegrateOptions) => { + async (options?: RunDomDisintegrateOptions) => { if (isAnimatingRef.current) { return; } const element = elementRef.current; if (!element) { - throw new ThanosDisintegrateError( + throw new DomDisintegrateError( "no_target", "Disintegrate target is not mounted.", ); @@ -94,7 +94,7 @@ export const useThanosDisintegrate = (): UseThanosDisintegrateResult => { isAnimatingRef.current = true; try { - await runThanosDisintegrate(element, { + await runDomDisintegrate(element, { ...options, captureSnapshot: captureCacheRef.current, }); diff --git a/src/shared/ui/effects/thanosDisintegrate/waitForDocumentFonts.ts b/src/shared/ui/effects/domDisintegrate/waitForDocumentFonts.ts similarity index 100% rename from src/shared/ui/effects/thanosDisintegrate/waitForDocumentFonts.ts rename to src/shared/ui/effects/domDisintegrate/waitForDocumentFonts.ts diff --git a/src/shared/ui/effects/thanosDisintegrate/warmThanosCapture.ts b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts similarity index 63% rename from src/shared/ui/effects/thanosDisintegrate/warmThanosCapture.ts rename to src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts index b6ee8bd8..08da3607 100644 --- a/src/shared/ui/effects/thanosDisintegrate/warmThanosCapture.ts +++ b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts @@ -1,9 +1,9 @@ import type { RefObject } from "react"; -import { buildThanosCapture } from "#/shared/ui/effects/thanosDisintegrate/buildThanosCapture"; -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import type { ThanosCaptureSnapshot } from "#/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot"; -import type { ThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/types"; +import { buildDisintegrateCapture } from "#/shared/ui/effects/domDisintegrate/buildDisintegrateCapture"; +import type { DisintegrateCaptureSnapshot } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import type { DomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/types"; type IdleRequestCallback = (deadline: IdleDeadline) => void; @@ -32,20 +32,20 @@ const cancelIdle = (handle: number): void => { }; /** Pre-captures in idle time so dismiss does not block on SnapDOM. */ -export const warmThanosCapture = ( +export const warmDisintegrateCapture = ( element: HTMLElement, - cacheRef: RefObject, - disintegrateOptions?: ThanosDisintegrateOptions, + cacheRef: RefObject, + disintegrateOptions?: DomDisintegrateOptions, ): (() => void) => { let cancelled = false; - const resolvedOptions = resolveThanosDisintegrateOptions(disintegrateOptions); + const resolvedOptions = resolveDomDisintegrateOptions(disintegrateOptions); const idleHandle = scheduleIdle(() => { if (cancelled) { return; } - void buildThanosCapture(element, { + void buildDisintegrateCapture(element, { mode: "quality", disintegrateOptions: resolvedOptions, }) diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/thanosDisintegrateError.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/thanosDisintegrateError.test.ts deleted file mode 100644 index 42acc511..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/thanosDisintegrateError.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - ThanosDisintegrateError, - type ThanosDisintegrateErrorCode, -} from "#/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError"; - -describe("ThanosDisintegrateError", () => { - it("exposes a stable error code for callers", () => { - const code: ThanosDisintegrateErrorCode = "canvas_unavailable"; - const error = new ThanosDisintegrateError( - code, - "2D canvas context is unavailable.", - ); - - expect(error).toBeInstanceOf(Error); - expect(error.name).toBe("ThanosDisintegrateError"); - expect(error.code).toBe("canvas_unavailable"); - expect(error.message).toBe("2D canvas context is unavailable."); - }); -}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/warmThanosCapture.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/warmThanosCapture.test.ts deleted file mode 100644 index 1f0d7ecf..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/warmThanosCapture.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -const buildThanosCaptureMock = vi.fn(); - -vi.mock("#/shared/ui/effects/thanosDisintegrate/buildThanosCapture", () => ({ - buildThanosCapture: buildThanosCaptureMock, -})); - -const { warmThanosCapture } = - await import("#/shared/ui/effects/thanosDisintegrate/warmThanosCapture"); - -describe("warmThanosCapture", () => { - afterEach(() => { - vi.clearAllMocks(); - vi.useRealTimers(); - }); - - it("does not leave an unhandled rejection when warm capture fails", async () => { - vi.useFakeTimers(); - buildThanosCaptureMock.mockRejectedValue(new Error("capture failed")); - - const element = document.createElement("div"); - const cacheRef = { current: null }; - - const cancelWarm = warmThanosCapture(element, cacheRef); - await vi.runAllTimersAsync(); - - expect(cacheRef.current).toBeNull(); - cancelWarm(); - }); -}); diff --git a/src/shared/ui/effects/thanosDisintegrate/index.ts b/src/shared/ui/effects/thanosDisintegrate/index.ts deleted file mode 100644 index d4fcab10..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { THANOS_DISINTEGRATE_DEFAULTS } from "#/shared/ui/effects/thanosDisintegrate/constants"; -export { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -export { - runThanosDisintegrate, - type RunThanosDisintegrateOptions, -} from "#/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate"; -export { - ThanosDisintegrateError, - type ThanosDisintegrateErrorCode, -} from "#/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError"; -export { useThanosDisintegrate } from "#/shared/ui/effects/thanosDisintegrate/useThanosDisintegrate"; -export type { - ThanosDisintegrateOptions, - ThanosDisintegrateOrigin, - ThanosDisintegrateMaskMode, - ThanosMaskStrategy, - ThanosParticleMotionMode, - ThanosParticleRenderMode, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; diff --git a/src/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions.ts b/src/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions.ts deleted file mode 100644 index 8d7055a2..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { THANOS_DISINTEGRATE_DEFAULTS } from "#/shared/ui/effects/thanosDisintegrate/constants"; -import type { - ResolvedThanosDisintegrateOptions, - ThanosDisintegrateOptions, -} from "#/shared/ui/effects/thanosDisintegrate/types"; - -export const resolveThanosDisintegrateOptions = ( - options?: ThanosDisintegrateOptions, -): ResolvedThanosDisintegrateOptions => ({ - ...THANOS_DISINTEGRATE_DEFAULTS, - ...options, -}); diff --git a/src/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError.ts b/src/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError.ts deleted file mode 100644 index f788e0e0..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/thanosDisintegrateError.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type ThanosDisintegrateErrorCode = - | "canvas_unavailable" - | "no_particles" - | "no_target" - | "zero_size_surface"; - -export class ThanosDisintegrateError extends Error { - readonly code: ThanosDisintegrateErrorCode; - - constructor(code: ThanosDisintegrateErrorCode, message: string) { - super(message); - this.name = "ThanosDisintegrateError"; - this.code = code; - } -} diff --git a/vibe-docs/ThanosDisintegrate-Library-Design.md b/vibe-docs/DomDisintegrate-Library-Design.md similarity index 75% rename from vibe-docs/ThanosDisintegrate-Library-Design.md rename to vibe-docs/DomDisintegrate-Library-Design.md index 924c5238..92f2cb87 100644 --- a/vibe-docs/ThanosDisintegrate-Library-Design.md +++ b/vibe-docs/DomDisintegrate-Library-Design.md @@ -1,15 +1,15 @@ -# Thanos Disintegrate — Standalone Library Design +# DOM Disintegrate — Standalone Library Design > **Status:** Proposal — for a future agent session. -> **Source of truth in dStruct:** `src/shared/ui/effects/thanosDisintegrate/` (~62 files, 58 unit tests). +> **Source of truth in dStruct:** `src/shared/ui/effects/domDisintegrate/` (~62 files, 58 unit tests). > **First consumer:** cookie consent dismiss (`src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx`). > **Related PR:** #153 (`cursor/thanos-modal-particle-effect-3657`). ## Executive summary -Extract the Thanos-style DOM disintegration effect from dStruct into a **framework-agnostic npm package** with an optional React adapter. The in-repo module is mature enough to extract: dual-layer masks, chunk bitmask worker, sprite rendering, 15+ mask strategies, warm capture, and explicit error types. +Extract the DOM disintegration particle effect from dStruct into a **framework-agnostic npm package** with an optional React adapter. The in-repo module is mature enough to extract: dual-layer masks, chunk bitmask worker, sprite rendering, 15+ mask strategies, warm capture, and explicit error types. -**Recommended approach:** publish `@dstruct/thanos-disintegrate` (scoped) or `thanos-disintegrate` (unscoped) as a **monorepo package** or **separate repo**, depending on the table below. +**Recommended approach:** publish `@dstruct/dom-disintegrate` (scoped) or `dom-disintegrate` (unscoped) as a **monorepo package** or **separate repo**, depending on the table below. --- @@ -17,7 +17,7 @@ Extract the Thanos-style DOM disintegration effect from dStruct into a **framewo | Criterion | Keep in `src/shared/ui/effects/` | Extract to standalone package | |-----------|----------------------------------|-------------------------------| -| **Reuse outside dStruct** | Copy-paste or git subtree | `pnpm add @scope/thanos-disintegrate` | +| **Reuse outside dStruct** | Copy-paste or git subtree | `pnpm add @scope/dom-disintegrate` | | **Bundle size in dStruct** | Tree-shaken with app | Peer deps + smaller app chunk if shared across apps | | **Release cadence** | Ships with dStruct | Independent semver | | **Test isolation** | Vitest in monorepo | Package owns its test suite | @@ -34,7 +34,7 @@ Extract the Thanos-style DOM disintegration effect from dStruct into a **framewo | Option | Pros | Cons | Fit for dStruct | |--------|------|------|-----------------| | **A. Separate GitHub repo** | Clean OSS story, independent CI | Cross-repo PRs, version sync pain | Medium | -| **B. pnpm workspace package in dStruct** (`packages/thanos-disintegrate`) | Single PR for app + lib changes, shared CI | Repo grows; publish step still needed | **Best first step** | +| **B. pnpm workspace package in dStruct** (`packages/dom-disintegrate`) | Single PR for app + lib changes, shared CI | Repo grows; publish step still needed | **Best first step** | | **C. npm publish from `src/shared/ui/effects/` via build script** | Minimal file moves | Awkward public API, leaks `#/` paths | Poor | | **D. Git submodule / subtree** | No npm | Worst DX | Avoid | @@ -46,8 +46,8 @@ Extract the Thanos-style DOM disintegration effect from dStruct into a **framewo | Export tier | Contents | Consumers | |-------------|----------|-----------| -| **Core (required)** | `runThanosDisintegrate`, `resolveThanosDisintegrateOptions`, `ThanosDisintegrateError`, types, `THANOS_DISINTEGRATE_DEFAULTS` | Vanilla JS, any framework | -| **React adapter** | `useThanosDisintegrate` | React apps only (separate entry: `thanos-disintegrate/react`) | +| **Core (required)** | `runDomDisintegrate`, `resolveDomDisintegrateOptions`, `DomDisintegrateError`, types, `DOM_DISINTEGRATE_DEFAULTS` | Vanilla JS, any framework | +| **React adapter** | `useDomDisintegrate` | React apps only (separate entry: `dom-disintegrate/react`) | | **Internal (do not export from package root)** | Mask builders, workers, capture helpers, `MASK_STRATEGY_GENERATORS` | Implementation detail / deep imports only if needed | --- @@ -83,9 +83,9 @@ Extract the Thanos-style DOM disintegration effect from dStruct into a **framewo | Adapter | Entry point | Notes | |---------|-------------|-------| -| **Vanilla** | `runThanosDisintegrate(element, options)` | Returns `Promise`; throws `ThanosDisintegrateError` on hard failures | -| **React** | `useThanosDisintegrate()` → `{ targetRef, disintegrate, invalidateCapture }` | Pre-warms capture on mount; invalidates on resize/fonts | -| **Vue / Svelte** | Not implemented | Future: thin wrappers around core `runThanosDisintegrate` | +| **Vanilla** | `runDomDisintegrate(element, options)` | Returns `Promise`; throws `DomDisintegrateError` on hard failures | +| **React** | `useDomDisintegrate()` → `{ targetRef, disintegrate, invalidateCapture }` | Pre-warms capture on mount; invalidates on resize/fonts | +| **Vue / Svelte** | Not implemented | Future: thin wrappers around core `runDomDisintegrate` | --- @@ -104,16 +104,16 @@ Extract the Thanos-style DOM disintegration effect from dStruct into a **framewo ## Suggested package structure ``` -packages/thanos-disintegrate/ +packages/dom-disintegrate/ ├── package.json ├── tsconfig.json ├── vitest.config.ts ├── src/ │ ├── index.ts # core exports -│ ├── react.ts # useThanosDisintegrate +│ ├── react.ts # useDomDisintegrate │ ├── core/ -│ │ ├── runThanosDisintegrate.ts -│ │ ├── buildThanosCapture.ts +│ │ ├── runDomDisintegrate.ts +│ │ ├── buildDisintegrateCapture.ts │ │ ├── types.ts │ │ └── ... │ ├── capture/ @@ -127,7 +127,7 @@ packages/thanos-disintegrate/ │ └── particles/ │ ├── stepParticles.ts │ └── drawFrame.ts -└── __tests__/ # port from src/shared/ui/effects/thanosDisintegrate/__tests__ +└── __tests__/ # port from src/shared/ui/effects/domDisintegrate/__tests__ ``` --- @@ -147,7 +147,7 @@ packages/thanos-disintegrate/ ## Testing requirements for extracted package -- [ ] Port all 58 tests from `src/shared/ui/effects/thanosDisintegrate/__tests__/` +- [ ] Port all 58 tests from `src/shared/ui/effects/domDisintegrate/__tests__/` - [ ] Add package-level build test (no `#/` imports leak) - [ ] Verify worker bundle resolves in Vite / webpack / Turbopack consumers - [ ] Smoke test in dStruct cookie consent after swapping import path @@ -157,9 +157,9 @@ packages/thanos-disintegrate/ ## Migration path for dStruct (after package exists) -1. Add `packages/thanos-disintegrate` and port source + tests. -2. Point `src/shared/ui/effects/thanosDisintegrate/index.ts` to re-export from workspace package (shim, one release cycle). -3. Update cookie consent import to `@dstruct/thanos-disintegrate/react` or keep shim. +1. Add `packages/dom-disintegrate` and port source + tests. +2. Point `src/shared/ui/effects/domDisintegrate/index.ts` to re-export from workspace package (shim, one release cycle). +3. Update cookie consent import to `@dstruct/dom-disintegrate/react` or keep shim. 4. Delete shim + in-app copy once stable. 5. Configure Changesets or semantic-release for package publishing. @@ -167,7 +167,7 @@ packages/thanos-disintegrate/ ## Open questions for implementing agent -1. **Package name:** `@dstruct/thanos-disintegrate` vs unscoped `thanos-disintegrate`? +1. **Package name:** `@dstruct/dom-disintegrate` vs unscoped `dom-disintegrate`? 2. **License:** Match dStruct repo license? 3. **Publish target:** npm public, GitHub packages, or private registry only? 4. **SnapDOM:** required peer vs optional — affects default capture quality story. @@ -177,7 +177,7 @@ packages/thanos-disintegrate/ ## References -- Implementation: `src/shared/ui/effects/thanosDisintegrate/` +- Implementation: `src/shared/ui/effects/domDisintegrate/` - Integration example: `src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx` - Architecture rules: `.cursorrules` (MUI, Redux boundaries — library must **not** depend on these) -- Checklist: `vibe-docs/ThanosDisintegrate-Library-TODO.md` +- Checklist: `vibe-docs/DomDisintegrate-Library-TODO.md` diff --git a/vibe-docs/ThanosDisintegrate-Library-TODO.md b/vibe-docs/DomDisintegrate-Library-TODO.md similarity index 76% rename from vibe-docs/ThanosDisintegrate-Library-TODO.md rename to vibe-docs/DomDisintegrate-Library-TODO.md index bee91f4a..4c91ca2d 100644 --- a/vibe-docs/ThanosDisintegrate-Library-TODO.md +++ b/vibe-docs/DomDisintegrate-Library-TODO.md @@ -1,17 +1,17 @@ -# Thanos Disintegrate Library — TODO +# DOM Disintegrate Library — TODO -> Pick up after reading `vibe-docs/ThanosDisintegrate-Library-Design.md`. +> Pick up after reading `vibe-docs/DomDisintegrate-Library-Design.md`. ## Phase 1 — Scaffold package -- [ ] Create `packages/thanos-disintegrate/` with `package.json`, `tsconfig`, Vitest +- [ ] Create `packages/dom-disintegrate/` with `package.json`, `tsconfig`, Vitest - [ ] Define exports: `.`, `./react`, types - [ ] Add `peerDependencies`: `react` (optional), `@zumer/snapdom` (optional) - [ ] Replace `#/` imports with package-relative paths ## Phase 2 — Port source -- [ ] Move `src/shared/ui/effects/thanosDisintegrate/**` → package `src/` +- [ ] Move `src/shared/ui/effects/domDisintegrate/**` → package `src/` - [ ] Inline or inject `prefersReducedMotion` (remove `#/shared/lib` dep) - [ ] Keep worker as separate entry; verify Next.js `import.meta.url` worker pattern - [ ] Do not prewarm the chunk mask worker on mount — create lazily when `maskMode: "chunks"` @@ -21,7 +21,7 @@ ## Phase 3 — Wire dStruct consumer - [ ] Add workspace dependency in root `package.json` / `pnpm-workspace.yaml` -- [ ] Shim `src/shared/ui/effects/thanosDisintegrate/index.ts` to re-export package +- [ ] Shim `src/shared/ui/effects/domDisintegrate/index.ts` to re-export package - [ ] Run full `pnpm test` + `pnpm lint` in monorepo - [ ] Manual smoke: cookie consent dismiss on localhost From 052f33d5b710335e03118f60aa32f52c35029699 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:13:36 +0000 Subject: [PATCH 10/21] feat(domDisintegrate): zig-zag spark paths, longer travel, review fixes - Retune flutter to slower sine waves keyed on time since release - Boost lateral sway, reduce drag, extend buoyancy/fade, stronger launch - Extract createTestDisintegrateParticle; fix padding double-resolve - Fix stale turbulenceSeed comment; drop legacy .cursor/commands note --- .cursorrules | 1 - .../__tests__/applyWaveOrigin.test.ts | 27 +++-------- .../assignParticleReleaseTimes.test.ts | 33 +++----------- .../buildChunkMaskSequenceAsync.test.ts | 27 +---------- .../__tests__/createChunkMaskSequence.test.ts | 37 +++------------ .../createTestDisintegrateParticle.ts | 24 ++++++++++ .../__tests__/drawDisintegrationFrame.test.ts | 45 +++++++------------ .../getWaveDisintegrationProgress.test.ts | 27 +++-------- .../__tests__/runDomDisintegrate.test.ts | 23 ++-------- .../__tests__/stepParticles.test.ts | 39 ++++++++++------ .../createDisintegrateParticle.ts | 10 ++--- .../getParticleCanvasPadding.ts | 23 ++++++---- .../effects/domDisintegrate/sparkFlutter.ts | 21 ++++----- .../domDisintegrate/sparkParticlePhysics.ts | 24 +++++----- .../effects/domDisintegrate/stepParticles.ts | 4 +- .../ui/effects/domDisintegrate/types.ts | 2 +- 16 files changed, 138 insertions(+), 229 deletions(-) create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts diff --git a/.cursorrules b/.cursorrules index cf781747..31d436e9 100644 --- a/.cursorrules +++ b/.cursorrules @@ -4,7 +4,6 @@ How this fits together: - **`AGENTS.md`** — Next.js doc lookup, pnpm, DB/env, local dev commands, slash commands. - **`.cursor/rules/*.mdc`** — Always-on code style (hook imports, type imports, `useEffect` comments). -- **`.cursor/commands/`** — Legacy slash-command location (migrating to `.cursor/skills/`). - **`.cursor/skills/`** — Agent skills; `deep-code-review` auto-applies on casual review requests (not only `/deep-code-review`). - **This file** — Architecture, stack, patterns, testing, and feature workflow. diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts index 3badd4a3..328d97ae 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts @@ -1,31 +1,14 @@ import { describe, expect, it } from "vitest"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { applyWaveOrigin } from "#/shared/ui/effects/domDisintegrate/applyWaveOrigin"; -import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; - -const createParticle = (x: number, y: number): DisintegrateParticle => ({ - x, - y, - originX: x, - originY: y, - vx: 0, - vy: 0, - color: "rgb(0, 0, 0)", - alpha: 1, - baseAlpha: 1, - size: 2, - rotation: 0, - rotationSpeed: 0, - drag: 0.96, - fadeStart: 0.5, - fadeDuration: 0.4, - releaseTime: 0, - turbulenceSeed: 0, -}); describe("applyWaveOrigin", () => { it("delays particles farther from the origin", () => { - const particles = [createParticle(0, 0), createParticle(30, 0)]; + const particles = [ + createTestDisintegrateParticle({ x: 0, y: 0, originX: 0, originY: 0 }), + createTestDisintegrateParticle({ x: 30, y: 0, originX: 30, originY: 0 }), + ]; const maxReleaseTime = applyWaveOrigin(particles, { x: 0, y: 0 }, 10); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts index 2af5c9b6..50d10911 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts @@ -1,33 +1,10 @@ import { describe, expect, it } from "vitest"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { assignParticleReleaseTimes, resolveEffectiveMaskStrategy, } from "#/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes"; -import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; - -const createParticle = ( - overrides: Partial = {}, -): DisintegrateParticle => ({ - x: 0, - y: 0, - originX: 0, - originY: 0, - vx: 0, - vy: 0, - color: "rgb(255, 0, 0)", - alpha: 1, - baseAlpha: 1, - size: 2, - rotation: 0, - rotationSpeed: 0, - drag: 0.96, - fadeStart: 0.5, - fadeDuration: 0.4, - releaseTime: 0, - turbulenceSeed: 0, - ...overrides, -}); describe("resolveEffectiveMaskStrategy", () => { it("defaults to wave when a click origin is present", () => { @@ -46,8 +23,8 @@ describe("resolveEffectiveMaskStrategy", () => { describe("assignParticleReleaseTimes", () => { it("staggers release times from a click-origin wave", () => { const particles = [ - createParticle({ x: 0, y: 0 }), - createParticle({ x: 100, y: 0 }), + createTestDisintegrateParticle({ x: 0, y: 0 }), + createTestDisintegrateParticle({ x: 100, y: 0 }), ]; const maxReleaseTime = assignParticleReleaseTimes(particles, { @@ -66,8 +43,8 @@ describe("assignParticleReleaseTimes", () => { it("spreads release times across a grid strategy", () => { const particles = [ - createParticle({ x: 0, y: 0 }), - createParticle({ x: 90, y: 30 }), + createTestDisintegrateParticle({ x: 0, y: 0 }), + createTestDisintegrateParticle({ x: 90, y: 30 }), ]; assignParticleReleaseTimes(particles, { diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts index 6072cb6e..48ebeb82 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; const workerInstances: MockWorker[] = []; @@ -54,31 +54,8 @@ vi.stubGlobal("Worker", MockWorker as unknown as typeof Worker); const { buildChunkMaskSequenceAsync, terminateChunkMaskWorker } = await import("#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"); -const createParticle = ( - overrides: Partial = {}, -): DisintegrateParticle => ({ - x: 0, - y: 0, - originX: 0, - originY: 0, - vx: 0, - vy: 0, - color: "rgb(255, 0, 0)", - alpha: 1, - baseAlpha: 1, - size: 2, - rotation: 0, - rotationSpeed: 0, - drag: 0.96, - fadeStart: 0.5, - fadeDuration: 0.4, - releaseTime: 0, - turbulenceSeed: 0, - ...overrides, -}); - const maskInput = { - particles: [createParticle({ x: 0, y: 0, releaseTime: 0 })], + particles: [createTestDisintegrateParticle({ x: 0, y: 0, releaseTime: 0 })], displayWidth: 12, displayHeight: 6, particlePadding: 4, diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts index 64235afd..c7b7214d 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { buildChunkMaskThresholds, createChunkMaskSequence, @@ -7,38 +8,14 @@ import { revealParticleChunk, revealParticleMaskBleedZone, } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; -import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; - -const createParticle = ( - overrides: Partial = {}, -): DisintegrateParticle => ({ - x: 0, - y: 0, - originX: 0, - originY: 0, - vx: 0, - vy: 0, - color: "rgb(255, 0, 0)", - alpha: 1, - baseAlpha: 1, - size: 2, - rotation: 0, - rotationSpeed: 0, - drag: 0.96, - fadeStart: 0.5, - fadeDuration: 0.4, - releaseTime: 0, - turbulenceSeed: 0, - ...overrides, -}); describe("createChunkMaskSequence", () => { it("buckets release times to a bounded number of thresholds", () => { const particles = [ - createParticle({ releaseTime: 0 }), - createParticle({ releaseTime: 0.1 }), - createParticle({ releaseTime: 0.2 }), - createParticle({ releaseTime: 0.35 }), + createTestDisintegrateParticle({ releaseTime: 0 }), + createTestDisintegrateParticle({ releaseTime: 0.1 }), + createTestDisintegrateParticle({ releaseTime: 0.2 }), + createTestDisintegrateParticle({ releaseTime: 0.35 }), ]; const thresholds = buildChunkMaskThresholds(particles, 2); @@ -56,8 +33,8 @@ describe("createChunkMaskSequence", () => { it("builds paired modal and particle mask data urls", () => { const particles = [ - createParticle({ x: 0, y: 0, releaseTime: 0 }), - createParticle({ x: 3, y: 0, releaseTime: 0.5 }), + createTestDisintegrateParticle({ x: 0, y: 0, releaseTime: 0 }), + createTestDisintegrateParticle({ x: 3, y: 0, releaseTime: 0.5 }), ]; const sequence = createChunkMaskSequence({ diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts new file mode 100644 index 00000000..18750836 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts @@ -0,0 +1,24 @@ +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; + +export const createTestDisintegrateParticle = ( + overrides: Partial = {}, +): DisintegrateParticle => ({ + x: 0, + y: 0, + originX: 0, + originY: 0, + vx: 0, + vy: 0, + color: "rgb(255, 0, 0)", + alpha: 1, + baseAlpha: 1, + size: 2, + rotation: 0, + rotationSpeed: 0, + drag: 0.96, + fadeStart: 0.5, + fadeDuration: 0.4, + releaseTime: 0, + turbulenceSeed: 0, + ...overrides, +}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts index 0e1fdf8c..1d9b02bd 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts @@ -1,30 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { drawDisintegrationFrame } from "#/shared/ui/effects/domDisintegrate/drawDisintegrationFrame"; -import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; - -const createParticle = ( - overrides: Partial = {}, -): DisintegrateParticle => ({ - x: 0, - y: 0, - originX: 0, - originY: 0, - vx: 0, - vy: 0, - color: "rgb(255, 0, 0)", - alpha: 1, - baseAlpha: 1, - size: 2, - rotation: 0, - rotationSpeed: 0, - drag: 0.96, - fadeStart: 0.5, - fadeDuration: 0.4, - releaseTime: 0, - turbulenceSeed: 0, - ...overrides, -}); describe("drawDisintegrationFrame", () => { it("draws only released flying particles beneath the live surface", () => { @@ -40,8 +17,8 @@ describe("drawDisintegrationFrame", () => { } as unknown as CanvasRenderingContext2D; const particles = [ - createParticle({ releaseTime: 0.5 }), - createParticle({ releaseTime: 0.2, x: 1, y: 1 }), + createTestDisintegrateParticle({ releaseTime: 0.5 }), + createTestDisintegrateParticle({ releaseTime: 0.2, x: 1, y: 1 }), ]; drawDisintegrationFrame(context, particles, 0, 6, 6); @@ -62,7 +39,9 @@ describe("drawDisintegrationFrame", () => { globalAlpha: 1, } as unknown as CanvasRenderingContext2D; - const particles = [createParticle({ releaseTime: 0, x: 1, y: 1 })]; + const particles = [ + createTestDisintegrateParticle({ releaseTime: 0, x: 1, y: 1 }), + ]; drawDisintegrationFrame(context, particles, 0.1, 6, 6); @@ -81,7 +60,9 @@ describe("drawDisintegrationFrame", () => { globalAlpha: 1, } as unknown as CanvasRenderingContext2D; - const particles = [createParticle({ releaseTime: 0, x: 4, y: 6, size: 2 })]; + const particles = [ + createTestDisintegrateParticle({ releaseTime: 0, x: 4, y: 6, size: 2 }), + ]; drawDisintegrationFrame(context, particles, 0.1, 20, 20, 8); @@ -103,7 +84,13 @@ describe("drawDisintegrationFrame", () => { const sourceCanvas = document.createElement("canvas"); const particles = [ - createParticle({ releaseTime: 0, x: 2, y: 2, originX: 4, originY: 6 }), + createTestDisintegrateParticle({ + releaseTime: 0, + x: 2, + y: 2, + originX: 4, + originY: 6, + }), ]; drawDisintegrationFrame(context, particles, 0.1, 20, 20, 0, { diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts index 504e2afe..f91c060e 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts @@ -1,31 +1,14 @@ import { describe, expect, it } from "vitest"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { getWaveDisintegrationProgress } from "#/shared/ui/effects/domDisintegrate/getWaveDisintegrationProgress"; -import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; - -const createParticle = (releaseTime: number): DisintegrateParticle => ({ - x: 0, - y: 0, - originX: 0, - originY: 0, - vx: 0, - vy: 0, - color: "rgb(0, 0, 0)", - alpha: 1, - baseAlpha: 1, - size: 2, - rotation: 0, - rotationSpeed: 0, - drag: 0.96, - fadeStart: 0.5, - fadeDuration: 0.4, - releaseTime, - turbulenceSeed: 0, -}); describe("getWaveDisintegrationProgress", () => { it("ramps from 0 before the wave to 1 after all particles release", () => { - const particles = [createParticle(0), createParticle(0.5)]; + const particles = [ + createTestDisintegrateParticle({ releaseTime: 0 }), + createTestDisintegrateParticle({ releaseTime: 0.5 }), + ]; expect(getWaveDisintegrationProgress(particles, 0)).toBe(0); expect(getWaveDisintegrationProgress(particles, 1)).toBe(1); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts index 56019701..78d6428c 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts @@ -23,26 +23,11 @@ const { runDomDisintegrate } = await import("#/shared/ui/effects/domDisintegrate/runDomDisintegrate"); const { DomDisintegrateError } = await import("#/shared/ui/effects/domDisintegrate/domDisintegrateError"); +const { createTestDisintegrateParticle } = + await import("#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"); -const createParticle = () => ({ - x: 0, - y: 0, - originX: 0, - originY: 0, - vx: 0, - vy: 0, - color: "rgb(100, 120, 140)", - alpha: 1, - baseAlpha: 1, - size: 2, - rotation: 0, - rotationSpeed: 0, - drag: 0.96, - fadeStart: 0.5, - fadeDuration: 0.4, - releaseTime: 0, - turbulenceSeed: 0, -}); +const createParticle = () => + createTestDisintegrateParticle({ color: "rgb(100, 120, 140)" }); const createElement = () => { const element = document.createElement("div"); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index e0c69371..1c2c4aee 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -56,7 +56,7 @@ describe("stepParticles", () => { expect(secondParticle.x).toBeCloseTo(positionAfterOneFrame, 2); }); - it("arcs windy particles upward before gravity pulls them down", () => { + it("zig-zags laterally while rising", () => { const particle = createDisintegrateParticle({ x: 20, y: 40, @@ -67,32 +67,43 @@ describe("stepParticles", () => { options: { particleMotionMode: "windy", maxVelocity: 80, - windX: 10, - windY: -6, + windX: 0, + windY: 0, gravity: 240, }, }); particle.releaseTime = 0; - particle.vx = 6; - particle.vy = -40; - particle.drag = 1; - particle.turbulenceSeed = 42; + particle.vx = 0; + particle.vy = -50; + particle.drag = 0.97; + particle.turbulenceSeed = 17; + const xPositions: number[] = [particle.x]; const yPositions: number[] = [particle.y]; - for (let frame = 1; frame <= 120; frame += 1) { + for (let frame = 1; frame <= 150; frame += 1) { stepParticles([particle], 1 / 60, frame / 60, { particleMotionMode: "windy", - windX: 10, - windY: -6, + windX: 0, + windY: 0, gravity: 240, }); + xPositions.push(particle.x); yPositions.push(particle.y); } - const lowestY = Math.min(...yPositions); - const highestY = Math.max(...yPositions); + const xDeltas = xPositions + .slice(1) + .map((xPosition, index) => xPosition - xPositions[index]!); + const directionChanges = xDeltas + .slice(1) + .filter( + (delta, index) => Math.sign(delta) !== Math.sign(xDeltas[index]!), + ).length; + const lateralSpread = Math.max(...xPositions) - Math.min(...xPositions); + const peakAltitude = Math.min(...yPositions); - expect(lowestY).toBeLessThan(40); - expect(highestY).toBeGreaterThan(lowestY + 6); + expect(directionChanges).toBeGreaterThanOrEqual(4); + expect(lateralSpread).toBeGreaterThan(12); + expect(peakAltitude).toBeLessThan(40 - 15); }); }); diff --git a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts index 4f346ecf..32005d87 100644 --- a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts +++ b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts @@ -44,17 +44,17 @@ const createSplatVelocity = ( const createSparkVelocity = ( resolvedOptions: ReturnType, ): { vx: number; vy: number } => { - const launchAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.35; - const speed = resolvedOptions.maxVelocity * (0.32 + Math.random() * 0.58); + const launchAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.5; + const speed = resolvedOptions.maxVelocity * (0.42 + Math.random() * 0.62); return { vx: - Math.cos(launchAngle) * speed * 0.42 + + Math.cos(launchAngle) * speed * 0.55 + resolvedOptions.windX * 0.25 + - (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.18, + (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.28, vy: Math.sin(launchAngle) * speed - - resolvedOptions.maxVelocity * (0.08 + Math.random() * 0.14), + resolvedOptions.maxVelocity * (0.1 + Math.random() * 0.16), }; }; diff --git a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts index 5c30972b..5b3f5e99 100644 --- a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts @@ -3,13 +3,14 @@ import { SPARK_TRAVEL_PADDING, SPARK_UPWARD_TRAVEL_FACTOR, } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; -import type { DomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/types"; +import type { + DomDisintegrateOptions, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; -/** Max distance a particle can travel from its origin during the animation. */ -export const getParticleMaxTravel = ( - options?: DomDisintegrateOptions, +const computeParticleMaxTravel = ( + resolvedOptions: ResolvedDomDisintegrateOptions, ): number => { - const resolvedOptions = resolveDomDisintegrateOptions(options); const duration = resolvedOptions.maxDuration; const baseTravel = resolvedOptions.maxVelocity * duration + @@ -19,7 +20,7 @@ export const getParticleMaxTravel = ( if (resolvedOptions.particleMotionMode === "windy") { return ( - baseTravel * 1.45 + + baseTravel * 1.65 + resolvedOptions.maxVelocity * SPARK_UPWARD_TRAVEL_FACTOR + SPARK_TRAVEL_PADDING ); @@ -28,13 +29,18 @@ export const getParticleMaxTravel = ( return baseTravel; }; +/** Max distance a particle can travel from its origin during the animation. */ +export const getParticleMaxTravel = ( + options?: DomDisintegrateOptions, +): number => computeParticleMaxTravel(resolveDomDisintegrateOptions(options)); + /** Margin around each chunk hole on the particle mask so flying pixels stay visible. */ export const getParticleRevealMargin = ( options?: DomDisintegrateOptions, ): number => { const resolvedOptions = resolveDomDisintegrateOptions(options); return Math.ceil( - getParticleMaxTravel(options) + resolvedOptions.particleSize, + computeParticleMaxTravel(resolvedOptions) + resolvedOptions.particleSize, ); }; @@ -44,6 +50,7 @@ export const getParticleCanvasPadding = ( ): number => { const resolvedOptions = resolveDomDisintegrateOptions(options); return Math.ceil( - getParticleMaxTravel(options) + resolvedOptions.particleSize * 4, + computeParticleMaxTravel(resolvedOptions) + + resolvedOptions.particleSize * 4, ); }; diff --git a/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts b/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts index cbaaad89..7f845492 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts @@ -3,22 +3,23 @@ type SparkFlutterForces = { forceY: number; }; -/** High-frequency oscillation that makes sparks waver and zig-zag in the air. */ +/** + * Lateral sway and wobble along a spark's flight path. + * Uses slower sine waves so motion reads as zig-zag, not high-frequency jitter. + */ export const sampleSparkFlutter = ( - elapsedSeconds: number, + timeSinceRelease: number, turbulenceSeed: number, ): SparkFlutterForces => { - const phase = elapsedSeconds + turbulenceSeed * 0.017; + const phase = timeSinceRelease + turbulenceSeed * 0.031; return { forceX: - Math.sin(phase * 19.3) * 0.42 + - Math.sin(phase * 33.7 + turbulenceSeed) * 0.28 + - Math.cos(phase * 47.1) * 0.14 + - Math.sin(phase * 11.2 + turbulenceSeed * 0.35) * 0.12, + Math.sin(phase * 7.5) * 0.58 + + Math.sin(phase * 4.2 + turbulenceSeed * 0.08) * 0.36 + + Math.sin(phase * 11.4 + turbulenceSeed * 0.12) * 0.2, forceY: - Math.cos(phase * 24.5 + turbulenceSeed * 0.5) * 0.16 + - Math.sin(phase * 38.9) * 0.1 + - Math.cos(phase * 15.8 + turbulenceSeed * 0.2) * 0.08, + Math.cos(phase * 6.1 + turbulenceSeed * 0.15) * 0.14 + + Math.sin(phase * 9.4) * 0.09, }; }; diff --git a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts index c9c58593..7f0a2fc0 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -1,12 +1,12 @@ import type { DisintegrateParticleMotionMode } from "#/shared/ui/effects/domDisintegrate/types"; -export const SPARK_FLUTTER_STRENGTH = 210; -export const SPARK_BUOYANCY = -260; -export const SPARK_BUOYANCY_DECAY = 5.2; -export const SPARK_GRAVITY_RAMP_SECONDS = 0.18; +export const SPARK_FLUTTER_STRENGTH = 340; +export const SPARK_BUOYANCY = -290; +export const SPARK_BUOYANCY_DECAY = 3.8; +export const SPARK_GRAVITY_RAMP_SECONDS = 0.3; export const SPARK_WIND_MULTIPLIER = 0.55; -export const SPARK_UPWARD_TRAVEL_FACTOR = 0.75; -export const SPARK_TRAVEL_PADDING = 36; +export const SPARK_UPWARD_TRAVEL_FACTOR = 1.05; +export const SPARK_TRAVEL_PADDING = 52; type ParticleMotionProfile = { dragMin: number; @@ -32,13 +32,13 @@ const PARTICLE_MOTION_PROFILES: Record< fadeDurationRange: 0.35, }, windy: { - dragMin: 0.978, - dragRange: 0.015, + dragMin: 0.966, + dragRange: 0.018, rotationSpeed: 9, - fadeStartMin: 0.22, - fadeStartRange: 0.18, - fadeDurationMin: 0.22, - fadeDurationRange: 0.24, + fadeStartMin: 0.28, + fadeStartRange: 0.2, + fadeDurationMin: 0.32, + fadeDurationRange: 0.28, }, }; diff --git a/src/shared/ui/effects/domDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts index e25d501d..d762fa4b 100644 --- a/src/shared/ui/effects/domDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -48,7 +48,6 @@ const stepSplatParticle = ( const stepSparkParticle = ( particle: DisintegrateParticle, deltaSeconds: number, - elapsedSeconds: number, timeSinceRelease: number, windX: number, windY: number, @@ -57,7 +56,7 @@ const stepSparkParticle = ( particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); - const flutter = sampleSparkFlutter(elapsedSeconds, particle.turbulenceSeed); + const flutter = sampleSparkFlutter(timeSinceRelease, particle.turbulenceSeed); const buoyancy = SPARK_BUOYANCY * Math.exp(-timeSinceRelease * SPARK_BUOYANCY_DECAY); const gravityRamp = Math.min( @@ -100,7 +99,6 @@ export const stepParticles = ( stepSparkParticle( particle, deltaSeconds, - elapsedSeconds, timeSinceRelease, resolvedOptions.windX, resolvedOptions.windY, diff --git a/src/shared/ui/effects/domDisintegrate/types.ts b/src/shared/ui/effects/domDisintegrate/types.ts index 7904d0c8..8db31741 100644 --- a/src/shared/ui/effects/domDisintegrate/types.ts +++ b/src/shared/ui/effects/domDisintegrate/types.ts @@ -45,7 +45,7 @@ export type DisintegrateParticle = { fadeDuration: number; /** Seconds from animation start when this particle begins to crumble. */ releaseTime: number; - /** Per-particle phase offset for windy flow-field sampling. */ + /** Per-particle phase offset for spark flutter oscillation. */ turbulenceSeed: number; }; From d67ef66273e289cb363853ef29bc75e0fa73dd49 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 22:34:01 +0000 Subject: [PATCH 11/21] feat(domDisintegrate): faster zig-zag sparks with longer TTL - Steer lateral velocity along a high-frequency sine for visible zig-zag legs - Extend fade duration and buoyancy for longer flight before disappearing - Widen canvas padding to account for zig-zag lateral extent --- .../createDisintegrateParticle.test.ts | 2 +- .../__tests__/stepParticles.test.ts | 8 ++--- .../createDisintegrateParticle.ts | 6 ++-- .../getParticleCanvasPadding.ts | 8 ++++- .../effects/domDisintegrate/sparkFlutter.ts | 17 +++++----- .../domDisintegrate/sparkParticlePhysics.ts | 34 +++++++++++-------- .../effects/domDisintegrate/stepParticles.ts | 10 ++++++ 7 files changed, 54 insertions(+), 31 deletions(-) diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts index f0130436..8044d43d 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts @@ -53,6 +53,6 @@ describe("createDisintegrateParticle", () => { expect(averageVelocityY).toBeLessThan(-8); expect(averageSpeed).toBeGreaterThan(20); - expect(averageSpeed).toBeLessThan(90); + expect(averageSpeed).toBeLessThan(105); }); }); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index 1c2c4aee..a7685fd6 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -80,7 +80,7 @@ describe("stepParticles", () => { const xPositions: number[] = [particle.x]; const yPositions: number[] = [particle.y]; - for (let frame = 1; frame <= 150; frame += 1) { + for (let frame = 1; frame <= 200; frame += 1) { stepParticles([particle], 1 / 60, frame / 60, { particleMotionMode: "windy", windX: 0, @@ -102,8 +102,8 @@ describe("stepParticles", () => { const lateralSpread = Math.max(...xPositions) - Math.min(...xPositions); const peakAltitude = Math.min(...yPositions); - expect(directionChanges).toBeGreaterThanOrEqual(4); - expect(lateralSpread).toBeGreaterThan(12); - expect(peakAltitude).toBeLessThan(40 - 15); + expect(directionChanges).toBeGreaterThanOrEqual(10); + expect(lateralSpread).toBeGreaterThan(24); + expect(peakAltitude).toBeLessThan(40 - 12); }); }); diff --git a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts index 32005d87..eb47257e 100644 --- a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts +++ b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts @@ -49,9 +49,9 @@ const createSparkVelocity = ( return { vx: - Math.cos(launchAngle) * speed * 0.55 + - resolvedOptions.windX * 0.25 + - (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.28, + Math.cos(launchAngle) * speed * 0.38 + + resolvedOptions.windX * 0.2 + + (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.16, vy: Math.sin(launchAngle) * speed - resolvedOptions.maxVelocity * (0.1 + Math.random() * 0.16), diff --git a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts index 5b3f5e99..5c970334 100644 --- a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts @@ -2,6 +2,8 @@ import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegra import { SPARK_TRAVEL_PADDING, SPARK_UPWARD_TRAVEL_FACTOR, + SPARK_ZIGZAG_FREQUENCY, + SPARK_ZIGZAG_VELOCITY, } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import type { DomDisintegrateOptions, @@ -19,9 +21,13 @@ const computeParticleMaxTravel = ( Math.abs(resolvedOptions.windY) * duration; if (resolvedOptions.particleMotionMode === "windy") { + const zigzagLateralExtent = + (SPARK_ZIGZAG_VELOCITY / SPARK_ZIGZAG_FREQUENCY) * 2.2; + return ( - baseTravel * 1.65 + + baseTravel * 1.95 + resolvedOptions.maxVelocity * SPARK_UPWARD_TRAVEL_FACTOR + + zigzagLateralExtent + SPARK_TRAVEL_PADDING ); } diff --git a/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts b/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts index 7f845492..8c235b51 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts @@ -1,25 +1,26 @@ +import { SPARK_ZIGZAG_FREQUENCY } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; + type SparkFlutterForces = { forceX: number; forceY: number; }; /** - * Lateral sway and wobble along a spark's flight path. - * Uses slower sine waves so motion reads as zig-zag, not high-frequency jitter. + * Secondary high-frequency wobble layered on top of the primary zig-zag steer. */ export const sampleSparkFlutter = ( timeSinceRelease: number, turbulenceSeed: number, ): SparkFlutterForces => { - const phase = timeSinceRelease + turbulenceSeed * 0.031; + const phase = timeSinceRelease + turbulenceSeed * 0.027; + const zigzag = phase * SPARK_ZIGZAG_FREQUENCY; return { forceX: - Math.sin(phase * 7.5) * 0.58 + - Math.sin(phase * 4.2 + turbulenceSeed * 0.08) * 0.36 + - Math.sin(phase * 11.4 + turbulenceSeed * 0.12) * 0.2, + Math.sin(zigzag * 1.62 + turbulenceSeed * 0.11) * 0.28 + + Math.sin(zigzag * 2.24 + turbulenceSeed * 0.07) * 0.16, forceY: - Math.cos(phase * 6.1 + turbulenceSeed * 0.15) * 0.14 + - Math.sin(phase * 9.4) * 0.09, + Math.cos(zigzag * 1.38 + turbulenceSeed * 0.13) * 0.1 + + Math.sin(zigzag * 1.94) * 0.06, }; }; diff --git a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts index 7f0a2fc0..e3ba6a65 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -1,12 +1,18 @@ import type { DisintegrateParticleMotionMode } from "#/shared/ui/effects/domDisintegrate/types"; -export const SPARK_FLUTTER_STRENGTH = 340; -export const SPARK_BUOYANCY = -290; -export const SPARK_BUOYANCY_DECAY = 3.8; -export const SPARK_GRAVITY_RAMP_SECONDS = 0.3; -export const SPARK_WIND_MULTIPLIER = 0.55; -export const SPARK_UPWARD_TRAVEL_FACTOR = 1.05; -export const SPARK_TRAVEL_PADDING = 52; +export const SPARK_FLUTTER_STRENGTH = 280; +export const SPARK_BUOYANCY = -300; +export const SPARK_BUOYANCY_DECAY = 2.5; +export const SPARK_GRAVITY_RAMP_SECONDS = 0.42; +export const SPARK_WIND_MULTIPLIER = 0.5; +export const SPARK_UPWARD_TRAVEL_FACTOR = 1.35; +export const SPARK_TRAVEL_PADDING = 84; +/** Zig-zag angular frequency (rad/s); ~5 lateral swings per second of flight. */ +export const SPARK_ZIGZAG_FREQUENCY = 32; +/** Peak lateral speed while steering along the zig-zag sine path (px/s). */ +export const SPARK_ZIGZAG_VELOCITY = 620; +/** How quickly horizontal velocity tracks the zig-zag sine target. */ +export const SPARK_ZIGZAG_STEER_RATE = 22; type ParticleMotionProfile = { dragMin: number; @@ -32,13 +38,13 @@ const PARTICLE_MOTION_PROFILES: Record< fadeDurationRange: 0.35, }, windy: { - dragMin: 0.966, - dragRange: 0.018, - rotationSpeed: 9, - fadeStartMin: 0.28, - fadeStartRange: 0.2, - fadeDurationMin: 0.32, - fadeDurationRange: 0.28, + dragMin: 0.958, + dragRange: 0.016, + rotationSpeed: 10, + fadeStartMin: 0.42, + fadeStartRange: 0.22, + fadeDurationMin: 0.58, + fadeDurationRange: 0.42, }, }; diff --git a/src/shared/ui/effects/domDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts index d762fa4b..22bf3e51 100644 --- a/src/shared/ui/effects/domDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -6,6 +6,9 @@ import { SPARK_FLUTTER_STRENGTH, SPARK_GRAVITY_RAMP_SECONDS, SPARK_WIND_MULTIPLIER, + SPARK_ZIGZAG_FREQUENCY, + SPARK_ZIGZAG_STEER_RATE, + SPARK_ZIGZAG_VELOCITY, } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import type { DisintegrateParticle, @@ -56,6 +59,13 @@ const stepSparkParticle = ( particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); + const zigzagPhase = + timeSinceRelease * SPARK_ZIGZAG_FREQUENCY + particle.turbulenceSeed * 0.09; + const targetZigzagVx = Math.sin(zigzagPhase) * SPARK_ZIGZAG_VELOCITY; + particle.vx += + (targetZigzagVx - particle.vx) * + Math.min(1, deltaSeconds * SPARK_ZIGZAG_STEER_RATE); + const flutter = sampleSparkFlutter(timeSinceRelease, particle.turbulenceSeed); const buoyancy = SPARK_BUOYANCY * Math.exp(-timeSinceRelease * SPARK_BUOYANCY_DECAY); From d33e1e3f419d3f64c0a0ae5c0b92f141ce006c2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 06:40:55 +0000 Subject: [PATCH 12/21] fix(domDisintegrate): natural per-particle spark turbulence Replace synchronized sine steering with position-based turbulence: per-particle frequency/phase/influence, vertical-travel-proportional sway, and scattered launch velocities from spawn position. --- .../__tests__/sparkFlutter.test.ts | 22 ---- .../__tests__/sparkTurbulence.test.ts | 64 +++++++++++ .../__tests__/stepParticles.test.ts | 100 +++++++++++------- .../createDisintegrateParticle.ts | 30 ++++-- .../getParticleCanvasPadding.ts | 10 +- .../effects/domDisintegrate/sparkFlutter.ts | 26 ----- .../domDisintegrate/sparkParticlePhysics.ts | 35 +++--- .../domDisintegrate/sparkTurbulence.ts | 80 ++++++++++++++ .../effects/domDisintegrate/stepParticles.ts | 28 +++-- .../ui/effects/domDisintegrate/types.ts | 2 +- 10 files changed, 262 insertions(+), 135 deletions(-) delete mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/sparkFlutter.test.ts create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts delete mode 100644 src/shared/ui/effects/domDisintegrate/sparkFlutter.ts create mode 100644 src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/sparkFlutter.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/sparkFlutter.test.ts deleted file mode 100644 index ca97a80a..00000000 --- a/src/shared/ui/effects/domDisintegrate/__tests__/sparkFlutter.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { sampleSparkFlutter } from "#/shared/ui/effects/domDisintegrate/sparkFlutter"; - -describe("sampleSparkFlutter", () => { - it("produces bounded, per-particle flutter forces", () => { - const first = sampleSparkFlutter(0.2, 12); - const second = sampleSparkFlutter(0.2, 84); - - expect(Math.hypot(first.forceX, first.forceY)).toBeLessThan(1.2); - expect(Math.hypot(second.forceX, second.forceY)).toBeLessThan(1.2); - expect(first.forceX).not.toBeCloseTo(second.forceX, 2); - }); - - it("evolves flutter over time for wavering spark motion", () => { - const early = sampleSparkFlutter(0.1, 5); - const later = sampleSparkFlutter(0.8, 5); - - expect(early.forceX).not.toBeCloseTo(later.forceX, 2); - expect(early.forceY).not.toBeCloseTo(later.forceY, 2); - }); -}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts new file mode 100644 index 00000000..92c687dd --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { sampleSparkTurbulence } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; + +describe("sampleSparkTurbulence", () => { + it("varies forces per particle seed and spawn position", () => { + const first = sampleSparkTurbulence({ + originX: 12, + originY: 40, + timeSinceRelease: 0.35, + turbulenceSeed: 12, + verticalTravel: 28, + }); + const second = sampleSparkTurbulence({ + originX: 84, + originY: 16, + timeSinceRelease: 0.35, + turbulenceSeed: 512, + verticalTravel: 28, + }); + + expect(Math.hypot(first.forceX, first.forceY)).toBeLessThan(1.4); + expect(Math.hypot(second.forceX, second.forceY)).toBeLessThan(1.4); + expect(first.forceX).not.toBeCloseTo(second.forceX, 2); + }); + + it("ramps lateral sway with vertical travel", () => { + const noRise = sampleSparkTurbulence({ + originX: 20, + originY: 40, + timeSinceRelease: 0.55, + turbulenceSeed: 5, + verticalTravel: 0, + }); + const risen = sampleSparkTurbulence({ + originX: 20, + originY: 40, + timeSinceRelease: 0.55, + turbulenceSeed: 5, + verticalTravel: 58, + }); + + expect(Math.abs(risen.forceX)).toBeGreaterThan(Math.abs(noRise.forceX)); + }); + + it("evolves turbulence over time", () => { + const early = sampleSparkTurbulence({ + originX: 10, + originY: 20, + timeSinceRelease: 0.1, + turbulenceSeed: 5, + verticalTravel: 30, + }); + const later = sampleSparkTurbulence({ + originX: 10, + originY: 20, + timeSinceRelease: 0.85, + turbulenceSeed: 5, + verticalTravel: 30, + }); + + expect(early.forceX).not.toBeCloseTo(later.forceX, 2); + }); +}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index a7685fd6..f70695ae 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -3,6 +3,14 @@ import { describe, expect, it } from "vitest"; import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; import { stepParticles } from "#/shared/ui/effects/domDisintegrate/stepParticles"; +const windyOptions = { + particleMotionMode: "windy" as const, + maxVelocity: 80, + windX: 12, + windY: -6, + gravity: 240, +}; + describe("stepParticles", () => { it("uses delta time so motion is independent of frame rate", () => { const particle = createDisintegrateParticle({ @@ -56,7 +64,38 @@ describe("stepParticles", () => { expect(secondParticle.x).toBeCloseTo(positionAfterOneFrame, 2); }); - it("zig-zags laterally while rising", () => { + it("diverges windy particles with different turbulence seeds", () => { + const first = createDisintegrateParticle({ + x: 40, + y: 40, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 120, + surfaceHeight: 60, + options: windyOptions, + }); + const second = createDisintegrateParticle({ + x: 42, + y: 40, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 120, + surfaceHeight: 60, + options: windyOptions, + }); + first.releaseTime = 0; + second.releaseTime = 0; + first.turbulenceSeed = 11; + second.turbulenceSeed = 907; + + for (let frame = 1; frame <= 120; frame += 1) { + stepParticles([first, second], 1 / 60, frame / 60, windyOptions); + } + + expect(Math.abs(first.x - second.x)).toBeGreaterThan(8); + }); + + it("sways more laterally after rising farther", () => { const particle = createDisintegrateParticle({ x: 20, y: 40, @@ -64,46 +103,35 @@ describe("stepParticles", () => { alpha: 1, surfaceWidth: 120, surfaceHeight: 60, - options: { - particleMotionMode: "windy", - maxVelocity: 80, - windX: 0, - windY: 0, - gravity: 240, - }, + options: windyOptions, }); particle.releaseTime = 0; - particle.vx = 0; - particle.vy = -50; + particle.vx = 4; + particle.vy = -42; particle.drag = 0.97; - particle.turbulenceSeed = 17; + particle.turbulenceSeed = 33; - const xPositions: number[] = [particle.x]; - const yPositions: number[] = [particle.y]; - for (let frame = 1; frame <= 200; frame += 1) { - stepParticles([particle], 1 / 60, frame / 60, { - particleMotionMode: "windy", - windX: 0, - windY: 0, - gravity: 240, - }); - xPositions.push(particle.x); - yPositions.push(particle.y); + let earlyLateralDelta = 0; + let lateralAtPeakRise = 0; + let peakVerticalTravel = 0; + + for (let frame = 1; frame <= 120; frame += 1) { + stepParticles([particle], 1 / 60, frame / 60, windyOptions); + + const verticalTravel = Math.max(0, particle.originY - particle.y); + const lateralDelta = Math.abs(particle.x - particle.originX); + + if (frame <= 12) { + earlyLateralDelta = Math.max(earlyLateralDelta, lateralDelta); + } + + if (verticalTravel > peakVerticalTravel) { + peakVerticalTravel = verticalTravel; + lateralAtPeakRise = lateralDelta; + } } - const xDeltas = xPositions - .slice(1) - .map((xPosition, index) => xPosition - xPositions[index]!); - const directionChanges = xDeltas - .slice(1) - .filter( - (delta, index) => Math.sign(delta) !== Math.sign(xDeltas[index]!), - ).length; - const lateralSpread = Math.max(...xPositions) - Math.min(...xPositions); - const peakAltitude = Math.min(...yPositions); - - expect(directionChanges).toBeGreaterThanOrEqual(10); - expect(lateralSpread).toBeGreaterThan(24); - expect(peakAltitude).toBeLessThan(40 - 12); + expect(peakVerticalTravel).toBeGreaterThan(10); + expect(lateralAtPeakRise).toBeGreaterThan(earlyLateralDelta + 2); }); }); diff --git a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts index eb47257e..cd492d19 100644 --- a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts +++ b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts @@ -42,19 +42,33 @@ const createSplatVelocity = ( }; const createSparkVelocity = ( + x: number, + y: number, + surfaceWidth: number, + surfaceHeight: number, resolvedOptions: ReturnType, ): { vx: number; vy: number } => { - const launchAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.5; - const speed = resolvedOptions.maxVelocity * (0.42 + Math.random() * 0.62); + const centerX = surfaceWidth / 2; + const centerY = surfaceHeight * 0.55; + const deltaX = x - centerX; + const deltaY = y - centerY; + const distanceFromCenter = Math.hypot(deltaX, deltaY); + const outwardAngle = + distanceFromCenter > 0.5 + ? Math.atan2(deltaY, deltaX) + : Math.random() * Math.PI * 2; + const upwardAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.25; + const launchAngle = outwardAngle * 0.4 + upwardAngle * 0.6; + const speed = resolvedOptions.maxVelocity * (0.36 + Math.random() * 0.78); return { vx: - Math.cos(launchAngle) * speed * 0.38 + - resolvedOptions.windX * 0.2 + - (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.16, + Math.cos(launchAngle) * speed + + resolvedOptions.windX * (0.12 + Math.random() * 0.22) + + (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.42, vy: - Math.sin(launchAngle) * speed - - resolvedOptions.maxVelocity * (0.1 + Math.random() * 0.16), + Math.sin(launchAngle) * speed * 0.88 - + resolvedOptions.maxVelocity * (0.05 + Math.random() * 0.2), }; }; @@ -74,7 +88,7 @@ export const createDisintegrateParticle = ({ ); const velocity = resolvedOptions.particleMotionMode === "windy" - ? createSparkVelocity(resolvedOptions) + ? createSparkVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions) : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); return { diff --git a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts index 5c970334..249e5821 100644 --- a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts @@ -1,9 +1,8 @@ import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import { SPARK_TRAVEL_PADDING, + SPARK_TURBULENCE_MAX_LATERAL, SPARK_UPWARD_TRAVEL_FACTOR, - SPARK_ZIGZAG_FREQUENCY, - SPARK_ZIGZAG_VELOCITY, } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import type { DomDisintegrateOptions, @@ -21,13 +20,10 @@ const computeParticleMaxTravel = ( Math.abs(resolvedOptions.windY) * duration; if (resolvedOptions.particleMotionMode === "windy") { - const zigzagLateralExtent = - (SPARK_ZIGZAG_VELOCITY / SPARK_ZIGZAG_FREQUENCY) * 2.2; - return ( - baseTravel * 1.95 + + baseTravel * 1.75 + resolvedOptions.maxVelocity * SPARK_UPWARD_TRAVEL_FACTOR + - zigzagLateralExtent + + SPARK_TURBULENCE_MAX_LATERAL + SPARK_TRAVEL_PADDING ); } diff --git a/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts b/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts deleted file mode 100644 index 8c235b51..00000000 --- a/src/shared/ui/effects/domDisintegrate/sparkFlutter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SPARK_ZIGZAG_FREQUENCY } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; - -type SparkFlutterForces = { - forceX: number; - forceY: number; -}; - -/** - * Secondary high-frequency wobble layered on top of the primary zig-zag steer. - */ -export const sampleSparkFlutter = ( - timeSinceRelease: number, - turbulenceSeed: number, -): SparkFlutterForces => { - const phase = timeSinceRelease + turbulenceSeed * 0.027; - const zigzag = phase * SPARK_ZIGZAG_FREQUENCY; - - return { - forceX: - Math.sin(zigzag * 1.62 + turbulenceSeed * 0.11) * 0.28 + - Math.sin(zigzag * 2.24 + turbulenceSeed * 0.07) * 0.16, - forceY: - Math.cos(zigzag * 1.38 + turbulenceSeed * 0.13) * 0.1 + - Math.sin(zigzag * 1.94) * 0.06, - }; -}; diff --git a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts index e3ba6a65..2d3d3001 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -1,18 +1,13 @@ import type { DisintegrateParticleMotionMode } from "#/shared/ui/effects/domDisintegrate/types"; -export const SPARK_FLUTTER_STRENGTH = 280; -export const SPARK_BUOYANCY = -300; -export const SPARK_BUOYANCY_DECAY = 2.5; -export const SPARK_GRAVITY_RAMP_SECONDS = 0.42; -export const SPARK_WIND_MULTIPLIER = 0.5; -export const SPARK_UPWARD_TRAVEL_FACTOR = 1.35; -export const SPARK_TRAVEL_PADDING = 84; -/** Zig-zag angular frequency (rad/s); ~5 lateral swings per second of flight. */ -export const SPARK_ZIGZAG_FREQUENCY = 32; -/** Peak lateral speed while steering along the zig-zag sine path (px/s). */ -export const SPARK_ZIGZAG_VELOCITY = 620; -/** How quickly horizontal velocity tracks the zig-zag sine target. */ -export const SPARK_ZIGZAG_STEER_RATE = 22; +export const SPARK_TURBULENCE_STRENGTH = 195; +export const SPARK_TURBULENCE_MAX_LATERAL = 48; +export const SPARK_BUOYANCY = -285; +export const SPARK_BUOYANCY_DECAY = 3.1; +export const SPARK_GRAVITY_RAMP_SECONDS = 0.36; +export const SPARK_WIND_MULTIPLIER = 0.62; +export const SPARK_UPWARD_TRAVEL_FACTOR = 1.2; +export const SPARK_TRAVEL_PADDING = 72; type ParticleMotionProfile = { dragMin: number; @@ -38,13 +33,13 @@ const PARTICLE_MOTION_PROFILES: Record< fadeDurationRange: 0.35, }, windy: { - dragMin: 0.958, - dragRange: 0.016, - rotationSpeed: 10, - fadeStartMin: 0.42, - fadeStartRange: 0.22, - fadeDurationMin: 0.58, - fadeDurationRange: 0.42, + dragMin: 0.964, + dragRange: 0.022, + rotationSpeed: 11, + fadeStartMin: 0.38, + fadeStartRange: 0.24, + fadeDurationMin: 0.5, + fadeDurationRange: 0.38, }, }; diff --git a/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts new file mode 100644 index 00000000..90063d4c --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts @@ -0,0 +1,80 @@ +type SparkTurbulenceForces = { + forceX: number; + forceY: number; +}; + +export type SparkTurbulenceInput = { + originX: number; + originY: number; + timeSinceRelease: number; + turbulenceSeed: number; + /** Pixels risen above the spawn point (positive when moving up). */ + verticalTravel: number; +}; + +type SparkTurbulenceProfile = { + influence: number; + frequency: number; + phase: number; + noiseScale: number; +}; + +const fract = (value: number) => value - Math.floor(value); + +const hash01 = (seed: number) => fract(Math.sin(seed) * 43758.5453123); + +const getSparkTurbulenceProfile = ( + turbulenceSeed: number, +): SparkTurbulenceProfile => ({ + influence: 0.42 + hash01(turbulenceSeed * 1.17) * 0.52, + frequency: 2.1 + hash01(turbulenceSeed * 2.31) * 3.4, + phase: hash01(turbulenceSeed * 3.79) * Math.PI * 2, + noiseScale: 0.75 + hash01(turbulenceSeed * 5.13) * 0.85, +}); + +/** + * Wind-borne spark turbulence: per-particle phase/frequency, position-based + * noise, and lateral sway that ramps with vertical travel (not time alone). + */ +export const sampleSparkTurbulence = ({ + originX, + originY, + timeSinceRelease, + turbulenceSeed, + verticalTravel, +}: SparkTurbulenceInput): SparkTurbulenceForces => { + const profile = getSparkTurbulenceProfile(turbulenceSeed); + const travelFactor = Math.min(1, verticalTravel / 56); + const swayEnvelope = 0.08 + travelFactor * 0.92; + + const slowPhase = timeSinceRelease * profile.frequency + profile.phase; + + const positionNoise = + Math.sin(originX * 0.044 + slowPhase * 0.32 + turbulenceSeed) * + Math.cos(originY * 0.036 + turbulenceSeed * 0.43); + + const sway = + Math.sin(slowPhase) * 0.64 + + Math.sin(slowPhase * 0.58 + turbulenceSeed * 0.17) * 0.36; + + const eddy = + Math.sin(originX * 0.055 + timeSinceRelease * 1.15 + turbulenceSeed) * + 0.22 + + Math.cos( + originY * 0.048 + timeSinceRelease * 0.95 + turbulenceSeed * 0.66, + ) * + 0.14; + + return { + forceX: + (sway * swayEnvelope + positionNoise * 0.38 + eddy) * + profile.influence * + profile.noiseScale, + forceY: + (Math.cos(slowPhase * 0.81 + turbulenceSeed * 0.21) * + swayEnvelope * + 0.14 + + positionNoise * 0.06) * + profile.influence, + }; +}; diff --git a/src/shared/ui/effects/domDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts index 22bf3e51..0fddb953 100644 --- a/src/shared/ui/effects/domDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -1,15 +1,12 @@ import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; -import { sampleSparkFlutter } from "#/shared/ui/effects/domDisintegrate/sparkFlutter"; import { SPARK_BUOYANCY, SPARK_BUOYANCY_DECAY, - SPARK_FLUTTER_STRENGTH, SPARK_GRAVITY_RAMP_SECONDS, + SPARK_TURBULENCE_STRENGTH, SPARK_WIND_MULTIPLIER, - SPARK_ZIGZAG_FREQUENCY, - SPARK_ZIGZAG_STEER_RATE, - SPARK_ZIGZAG_VELOCITY, } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { sampleSparkTurbulence } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; import type { DisintegrateParticle, DomDisintegrateOptions, @@ -59,14 +56,14 @@ const stepSparkParticle = ( particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); - const zigzagPhase = - timeSinceRelease * SPARK_ZIGZAG_FREQUENCY + particle.turbulenceSeed * 0.09; - const targetZigzagVx = Math.sin(zigzagPhase) * SPARK_ZIGZAG_VELOCITY; - particle.vx += - (targetZigzagVx - particle.vx) * - Math.min(1, deltaSeconds * SPARK_ZIGZAG_STEER_RATE); - - const flutter = sampleSparkFlutter(timeSinceRelease, particle.turbulenceSeed); + const verticalTravel = Math.max(0, particle.originY - particle.y); + const turbulence = sampleSparkTurbulence({ + originX: particle.originX, + originY: particle.originY, + timeSinceRelease, + turbulenceSeed: particle.turbulenceSeed, + verticalTravel, + }); const buoyancy = SPARK_BUOYANCY * Math.exp(-timeSinceRelease * SPARK_BUOYANCY_DECAY); const gravityRamp = Math.min( @@ -75,10 +72,11 @@ const stepSparkParticle = ( ); particle.vx += - (flutter.forceX * SPARK_FLUTTER_STRENGTH + windX * SPARK_WIND_MULTIPLIER) * + (turbulence.forceX * SPARK_TURBULENCE_STRENGTH + + windX * SPARK_WIND_MULTIPLIER) * deltaSeconds; particle.vy += - (flutter.forceY * SPARK_FLUTTER_STRENGTH + + (turbulence.forceY * SPARK_TURBULENCE_STRENGTH + buoyancy + gravity * gravityRamp + windY * SPARK_WIND_MULTIPLIER) * diff --git a/src/shared/ui/effects/domDisintegrate/types.ts b/src/shared/ui/effects/domDisintegrate/types.ts index 8db31741..d88f5097 100644 --- a/src/shared/ui/effects/domDisintegrate/types.ts +++ b/src/shared/ui/effects/domDisintegrate/types.ts @@ -45,7 +45,7 @@ export type DisintegrateParticle = { fadeDuration: number; /** Seconds from animation start when this particle begins to crumble. */ releaseTime: number; - /** Per-particle phase offset for spark flutter oscillation. */ + /** Per-particle seed for turbulence phase, frequency, and influence. */ turbulenceSeed: number; }; From f1bfe1fc53b3188782eba7d5aa19693c14450bd8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 07:10:44 +0000 Subject: [PATCH 13/21] fix(domDisintegrate): sustained upwind spark drift, no ballistic fall - Remove gravity and decaying buoyancy from windy mode - Apply constant per-particle lift plus shared global wind acceleration - Align launch velocity with wind drift (up-right when wind blows right) - Clamp downward velocity; turbulence affects lateral motion only --- .../createDisintegrateParticle.test.ts | 5 ++ .../__tests__/sparkParticlePhysics.test.ts | 26 +++++++++ .../__tests__/stepParticles.test.ts | 43 +++++++++++++-- .../createDisintegrateParticle.ts | 37 ++++++------- .../domDisintegrate/sparkParticlePhysics.ts | 54 ++++++++++++++++--- .../domDisintegrate/sparkTurbulence.ts | 7 +-- .../effects/domDisintegrate/stepParticles.ts | 30 ++++------- 7 files changed, 143 insertions(+), 59 deletions(-) create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/sparkParticlePhysics.test.ts diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts index 8044d43d..e3a241ff 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts @@ -54,5 +54,10 @@ describe("createDisintegrateParticle", () => { expect(averageVelocityY).toBeLessThan(-8); expect(averageSpeed).toBeGreaterThan(20); expect(averageSpeed).toBeLessThan(105); + + const averageVelocityX = + particles.reduce((sum, particle) => sum + particle.vx, 0) / + particles.length; + expect(averageVelocityX).toBeGreaterThan(4); }); }); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/sparkParticlePhysics.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/sparkParticlePhysics.test.ts new file mode 100644 index 00000000..6cfde143 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/sparkParticlePhysics.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + getSparkDriftDirection, + getSparkLiftFactor, +} from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; + +describe("sparkParticlePhysics", () => { + it("points drift up-right when wind blows from left to right", () => { + const drift = getSparkDriftDirection(14, -8); + + expect(drift.driftX).toBeGreaterThan(0); + expect(drift.driftY).toBeLessThan(0); + }); + + it("varies lift per particle while keeping a shared wind field", () => { + const firstLift = getSparkLiftFactor(12); + const secondLift = getSparkLiftFactor(512); + + expect(firstLift).toBeGreaterThan(0.6); + expect(firstLift).toBeLessThan(1.3); + expect(secondLift).toBeGreaterThan(0.6); + expect(secondLift).toBeLessThan(1.3); + expect(firstLift).not.toBeCloseTo(secondLift, 1); + }); +}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index f70695ae..b26a25e3 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -66,7 +66,7 @@ describe("stepParticles", () => { it("diverges windy particles with different turbulence seeds", () => { const first = createDisintegrateParticle({ - x: 40, + x: 20, y: 40, color: "rgb(255, 255, 255)", alpha: 1, @@ -75,7 +75,7 @@ describe("stepParticles", () => { options: windyOptions, }); const second = createDisintegrateParticle({ - x: 42, + x: 90, y: 40, color: "rgb(255, 255, 255)", alpha: 1, @@ -92,7 +92,7 @@ describe("stepParticles", () => { stepParticles([first, second], 1 / 60, frame / 60, windyOptions); } - expect(Math.abs(first.x - second.x)).toBeGreaterThan(8); + expect(Math.abs(first.x - second.x)).toBeGreaterThan(50); }); it("sways more laterally after rising farther", () => { @@ -134,4 +134,41 @@ describe("stepParticles", () => { expect(peakVerticalTravel).toBeGreaterThan(10); expect(lateralAtPeakRise).toBeGreaterThan(earlyLateralDelta + 2); }); + + it("keeps windy sparks rising with the global wind instead of falling", () => { + const particle = createDisintegrateParticle({ + x: 30, + y: 50, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 120, + surfaceHeight: 60, + options: { + particleMotionMode: "windy", + maxVelocity: 80, + windX: 16, + windY: -10, + gravity: 320, + }, + }); + particle.releaseTime = 0; + particle.turbulenceSeed = 44; + + const yPositions: number[] = [particle.y]; + for (let frame = 1; frame <= 150; frame += 1) { + stepParticles([particle], 1 / 60, frame / 60, { + particleMotionMode: "windy", + maxVelocity: 80, + windX: 16, + windY: -10, + gravity: 320, + }); + yPositions.push(particle.y); + } + + expect(particle.y).toBeLessThan(particle.originY); + expect(particle.x).toBeGreaterThan(particle.originX); + expect(Math.min(...yPositions)).toBeLessThan(particle.originY - 8); + expect(particle.vy).toBeLessThanOrEqual(8); + }); }); diff --git a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts index cd492d19..5b91cd40 100644 --- a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts +++ b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts @@ -1,5 +1,8 @@ import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; -import { getParticleMotionProfile } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { + getParticleMotionProfile, + getSparkDriftDirection, +} from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import type { DisintegrateParticle, DomDisintegrateOptions, @@ -42,33 +45,23 @@ const createSplatVelocity = ( }; const createSparkVelocity = ( - x: number, - y: number, - surfaceWidth: number, - surfaceHeight: number, resolvedOptions: ReturnType, ): { vx: number; vy: number } => { - const centerX = surfaceWidth / 2; - const centerY = surfaceHeight * 0.55; - const deltaX = x - centerX; - const deltaY = y - centerY; - const distanceFromCenter = Math.hypot(deltaX, deltaY); - const outwardAngle = - distanceFromCenter > 0.5 - ? Math.atan2(deltaY, deltaX) - : Math.random() * Math.PI * 2; - const upwardAngle = -Math.PI / 2 + (Math.random() - 0.5) * 1.25; - const launchAngle = outwardAngle * 0.4 + upwardAngle * 0.6; - const speed = resolvedOptions.maxVelocity * (0.36 + Math.random() * 0.78); + const { driftX, driftY } = getSparkDriftDirection( + resolvedOptions.windX, + resolvedOptions.windY, + ); + const speed = resolvedOptions.maxVelocity * (0.38 + Math.random() * 0.72); + const scatterAngle = (Math.random() - 0.5) * 0.6; + const launchAngle = Math.atan2(driftY, driftX) + scatterAngle; return { vx: Math.cos(launchAngle) * speed + - resolvedOptions.windX * (0.12 + Math.random() * 0.22) + - (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.42, + (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.16, vy: - Math.sin(launchAngle) * speed * 0.88 - - resolvedOptions.maxVelocity * (0.05 + Math.random() * 0.2), + Math.sin(launchAngle) * speed - + resolvedOptions.maxVelocity * (0.03 + Math.random() * 0.1), }; }; @@ -88,7 +81,7 @@ export const createDisintegrateParticle = ({ ); const velocity = resolvedOptions.particleMotionMode === "windy" - ? createSparkVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions) + ? createSparkVelocity(resolvedOptions) : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); return { diff --git a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts index 2d3d3001..1dc4b406 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -2,12 +2,14 @@ import type { DisintegrateParticleMotionMode } from "#/shared/ui/effects/domDisi export const SPARK_TURBULENCE_STRENGTH = 195; export const SPARK_TURBULENCE_MAX_LATERAL = 48; -export const SPARK_BUOYANCY = -285; -export const SPARK_BUOYANCY_DECAY = 3.1; -export const SPARK_GRAVITY_RAMP_SECONDS = 0.36; -export const SPARK_WIND_MULTIPLIER = 0.62; -export const SPARK_UPWARD_TRAVEL_FACTOR = 1.2; -export const SPARK_TRAVEL_PADDING = 72; +/** Sustained upward acceleration (negative = rise on canvas Y). */ +export const SPARK_LIFT_ACCEL = -158; +/** Global wind from options applied as acceleration (same for every spark). */ +export const SPARK_WIND_ACCEL = 1; +/** Sparks may wobble slightly but never arc downward like thrown balls. */ +export const SPARK_MAX_DOWNWARD_VELOCITY = 8; +export const SPARK_UPWARD_TRAVEL_FACTOR = 1.25; +export const SPARK_TRAVEL_PADDING = 78; type ParticleMotionProfile = { dragMin: number; @@ -33,8 +35,8 @@ const PARTICLE_MOTION_PROFILES: Record< fadeDurationRange: 0.35, }, windy: { - dragMin: 0.964, - dragRange: 0.022, + dragMin: 0.966, + dragRange: 0.02, rotationSpeed: 11, fadeStartMin: 0.38, fadeStartRange: 0.24, @@ -43,6 +45,42 @@ const PARTICLE_MOTION_PROFILES: Record< }, }; +const hash01 = (seed: number) => { + const value = Math.sin(seed) * 43758.5453123; + return value - Math.floor(value); +}; + +/** Per-particle lift variation while sharing the same global wind field. */ +export const getSparkLiftFactor = (turbulenceSeed: number): number => + 0.68 + hash01(turbulenceSeed * 7.31) * 0.56; + +export type SparkDriftDirection = { + driftX: number; + driftY: number; +}; + +/** + * Shared upwind drift for all sparks — wind from the left pushes everyone up-right. + * Blends option wind with a mandatory upward component. + */ +export const getSparkDriftDirection = ( + windX: number, + windY: number, +): SparkDriftDirection => { + const windLength = Math.hypot(windX, windY); + const windNormX = windLength > 0.01 ? windX / windLength : 0.85; + const windNormY = windLength > 0.01 ? windY / windLength : -0.52; + + const blendedX = windNormX * 0.62; + const blendedY = windNormY * 0.62 - 0.78; + const length = Math.hypot(blendedX, blendedY) || 1; + + return { + driftX: blendedX / length, + driftY: blendedY / length, + }; +}; + export const getParticleMotionProfile = ( motionMode: DisintegrateParticleMotionMode, ): ParticleMotionProfile => PARTICLE_MOTION_PROFILES[motionMode]; diff --git a/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts index 90063d4c..d36fc704 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts @@ -70,11 +70,6 @@ export const sampleSparkTurbulence = ({ (sway * swayEnvelope + positionNoise * 0.38 + eddy) * profile.influence * profile.noiseScale, - forceY: - (Math.cos(slowPhase * 0.81 + turbulenceSeed * 0.21) * - swayEnvelope * - 0.14 + - positionNoise * 0.06) * - profile.influence, + forceY: 0, }; }; diff --git a/src/shared/ui/effects/domDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts index 0fddb953..a168c983 100644 --- a/src/shared/ui/effects/domDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -1,10 +1,10 @@ import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import { - SPARK_BUOYANCY, - SPARK_BUOYANCY_DECAY, - SPARK_GRAVITY_RAMP_SECONDS, + getSparkLiftFactor, + SPARK_LIFT_ACCEL, + SPARK_MAX_DOWNWARD_VELOCITY, SPARK_TURBULENCE_STRENGTH, - SPARK_WIND_MULTIPLIER, + SPARK_WIND_ACCEL, } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import { sampleSparkTurbulence } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; import type { @@ -51,7 +51,6 @@ const stepSparkParticle = ( timeSinceRelease: number, windX: number, windY: number, - gravity: number, ): void => { particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); @@ -64,23 +63,15 @@ const stepSparkParticle = ( turbulenceSeed: particle.turbulenceSeed, verticalTravel, }); - const buoyancy = - SPARK_BUOYANCY * Math.exp(-timeSinceRelease * SPARK_BUOYANCY_DECAY); - const gravityRamp = Math.min( - 1, - timeSinceRelease / SPARK_GRAVITY_RAMP_SECONDS, - ); + const liftFactor = getSparkLiftFactor(particle.turbulenceSeed); + const uplift = SPARK_LIFT_ACCEL * liftFactor; particle.vx += - (turbulence.forceX * SPARK_TURBULENCE_STRENGTH + - windX * SPARK_WIND_MULTIPLIER) * - deltaSeconds; - particle.vy += - (turbulence.forceY * SPARK_TURBULENCE_STRENGTH + - buoyancy + - gravity * gravityRamp + - windY * SPARK_WIND_MULTIPLIER) * + (turbulence.forceX * SPARK_TURBULENCE_STRENGTH + windX * SPARK_WIND_ACCEL) * deltaSeconds; + particle.vy += (uplift + windY * SPARK_WIND_ACCEL) * deltaSeconds; + particle.vy = Math.min(particle.vy, SPARK_MAX_DOWNWARD_VELOCITY); + particle.x += particle.vx * deltaSeconds; particle.y += particle.vy * deltaSeconds; }; @@ -110,7 +101,6 @@ export const stepParticles = ( timeSinceRelease, resolvedOptions.windX, resolvedOptions.windY, - resolvedOptions.gravity, ); } else { stepSplatParticle( From f9527ac57bd88ea3fb64789cdbc78fdf15b32d5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 07:12:54 +0000 Subject: [PATCH 14/21] refactor(domDisintegrate): dedupe spark hash, wire worker prewarm - Share hashSparkSeed01 between spark physics and turbulence - Prewarm chunk mask worker during idle capture for chunks mode - Clarify windy vs splat option docs; fix duplicate JSDoc typo - Update deep-code-review skill example --- .cursor/skills/deep-code-review/SKILL.md | 8 ++++---- .../__tests__/stepParticles.test.ts | 1 - .../effects/domDisintegrate/runDomDisintegrate.ts | 2 +- .../domDisintegrate/sparkParticlePhysics.ts | 8 ++------ .../ui/effects/domDisintegrate/sparkSeedHash.ts | 5 +++++ .../ui/effects/domDisintegrate/sparkTurbulence.ts | 14 ++++++-------- src/shared/ui/effects/domDisintegrate/types.ts | 6 +++--- .../domDisintegrate/warmDisintegrateCapture.ts | 5 +++++ 8 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 src/shared/ui/effects/domDisintegrate/sparkSeedHash.ts diff --git a/.cursor/skills/deep-code-review/SKILL.md b/.cursor/skills/deep-code-review/SKILL.md index aa3605b2..7e3c775b 100644 --- a/.cursor/skills/deep-code-review/SKILL.md +++ b/.cursor/skills/deep-code-review/SKILL.md @@ -68,11 +68,11 @@ Use code citations for non-obvious changes. Keep prose concise. ## Example -**Finding:** `windTurbulence.ts` exports Perlin noise used only for a tiny drift term on top of sine flutter. +**Finding:** `sparkFlutter.ts` duplicated `hash01` with `sparkParticlePhysics.ts` and used stale global sine steering. -**Action:** Delete file; replace with `sparkFlutter.ts`; centralize constants in `sparkParticlePhysics.ts`; update tests. +**Action:** Replace with `sparkTurbulence.ts` + shared `sparkSeedHash.ts`; wire `prewarmChunkMaskWorker` during chunk warm-up. **Response excerpt:** -> **Fixes applied** — Removed 80-line Perlin module (dead weight). Consolidated spark constants and motion profiles. Fixed double-resolve in padding helper. +> **Fixes applied** — Deduplicated seed hash helper. Clarified windy vs splat option docs. Fixed duplicate JSDoc typo. Prewarm chunk mask worker on idle capture when `maskMode: "chunks"`. > -> **Verification** — 64 domDisintegrate tests pass; ESLint clean on touched files. +> **Verification** — 69 domDisintegrate tests pass; ESLint clean on touched files. diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index b26a25e3..02077218 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -8,7 +8,6 @@ const windyOptions = { maxVelocity: 80, windX: 12, windY: -6, - gravity: 240, }; describe("stepParticles", () => { diff --git a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts index 3aeac624..dd270cef 100644 --- a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts @@ -167,7 +167,7 @@ const clearActiveMasks = ( }; /** - * Plays a particle disintegration particle disintegration on a DOM surface, then resolves. + * Plays a particle disintegration on a DOM surface, then resolves. * The live element stays visible with a synced radial mask; particles fly underneath. */ export const runDomDisintegrate = async ( diff --git a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts index 1dc4b406..67e528e8 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -1,3 +1,4 @@ +import { hashSparkSeed01 } from "#/shared/ui/effects/domDisintegrate/sparkSeedHash"; import type { DisintegrateParticleMotionMode } from "#/shared/ui/effects/domDisintegrate/types"; export const SPARK_TURBULENCE_STRENGTH = 195; @@ -45,14 +46,9 @@ const PARTICLE_MOTION_PROFILES: Record< }, }; -const hash01 = (seed: number) => { - const value = Math.sin(seed) * 43758.5453123; - return value - Math.floor(value); -}; - /** Per-particle lift variation while sharing the same global wind field. */ export const getSparkLiftFactor = (turbulenceSeed: number): number => - 0.68 + hash01(turbulenceSeed * 7.31) * 0.56; + 0.68 + hashSparkSeed01(turbulenceSeed * 7.31) * 0.56; export type SparkDriftDirection = { driftX: number; diff --git a/src/shared/ui/effects/domDisintegrate/sparkSeedHash.ts b/src/shared/ui/effects/domDisintegrate/sparkSeedHash.ts new file mode 100644 index 00000000..75bc63ea --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/sparkSeedHash.ts @@ -0,0 +1,5 @@ +/** Deterministic [0, 1) hash for per-particle spark variation. */ +export const hashSparkSeed01 = (seed: number): number => { + const value = Math.sin(seed) * 43758.5453123; + return value - Math.floor(value); +}; diff --git a/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts index d36fc704..2ccabf1a 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts @@ -1,3 +1,5 @@ +import { hashSparkSeed01 } from "#/shared/ui/effects/domDisintegrate/sparkSeedHash"; + type SparkTurbulenceForces = { forceX: number; forceY: number; @@ -19,17 +21,13 @@ type SparkTurbulenceProfile = { noiseScale: number; }; -const fract = (value: number) => value - Math.floor(value); - -const hash01 = (seed: number) => fract(Math.sin(seed) * 43758.5453123); - const getSparkTurbulenceProfile = ( turbulenceSeed: number, ): SparkTurbulenceProfile => ({ - influence: 0.42 + hash01(turbulenceSeed * 1.17) * 0.52, - frequency: 2.1 + hash01(turbulenceSeed * 2.31) * 3.4, - phase: hash01(turbulenceSeed * 3.79) * Math.PI * 2, - noiseScale: 0.75 + hash01(turbulenceSeed * 5.13) * 0.85, + influence: 0.42 + hashSparkSeed01(turbulenceSeed * 1.17) * 0.52, + frequency: 2.1 + hashSparkSeed01(turbulenceSeed * 2.31) * 3.4, + phase: hashSparkSeed01(turbulenceSeed * 3.79) * Math.PI * 2, + noiseScale: 0.75 + hashSparkSeed01(turbulenceSeed * 5.13) * 0.85, }); /** diff --git a/src/shared/ui/effects/domDisintegrate/types.ts b/src/shared/ui/effects/domDisintegrate/types.ts index d88f5097..723ef1b4 100644 --- a/src/shared/ui/effects/domDisintegrate/types.ts +++ b/src/shared/ui/effects/domDisintegrate/types.ts @@ -60,11 +60,11 @@ export type DomDisintegrateOptions = { particleSize?: number; /** Max outward speed magnitude in px/s. */ maxVelocity?: number; - /** Horizontal drift in px/s. */ + /** Shared horizontal wind acceleration in px/s² (`windy` mode). */ windX?: number; - /** Vertical drift in px/s. */ + /** Shared vertical wind acceleration in px/s²; negative values rise on canvas. */ windY?: number; - /** Downward acceleration in px/s². */ + /** Downward acceleration in px/s² (`splat` mode only). */ gravity?: number; /** Hard cap on animation duration in seconds after the wave reaches the edge. */ maxDuration?: number; diff --git a/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts index 08da3607..fa5e7c68 100644 --- a/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts +++ b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts @@ -1,5 +1,6 @@ import type { RefObject } from "react"; +import { prewarmChunkMaskWorker } from "#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"; import { buildDisintegrateCapture } from "#/shared/ui/effects/domDisintegrate/buildDisintegrateCapture"; import type { DisintegrateCaptureSnapshot } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; @@ -40,6 +41,10 @@ export const warmDisintegrateCapture = ( let cancelled = false; const resolvedOptions = resolveDomDisintegrateOptions(disintegrateOptions); + if (resolvedOptions.maskMode === "chunks") { + prewarmChunkMaskWorker(); + } + const idleHandle = scheduleIdle(() => { if (cancelled) { return; From cd1c84d27a1dfcb1f8d4e283663d8c7abf42b154 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 07:15:40 +0000 Subject: [PATCH 15/21] docs(skills): generalize deep-code-review example (not feature-specific) --- .cursor/skills/deep-code-review/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.cursor/skills/deep-code-review/SKILL.md b/.cursor/skills/deep-code-review/SKILL.md index 7e3c775b..7b3cde9a 100644 --- a/.cursor/skills/deep-code-review/SKILL.md +++ b/.cursor/skills/deep-code-review/SKILL.md @@ -68,11 +68,11 @@ Use code citations for non-obvious changes. Keep prose concise. ## Example -**Finding:** `sparkFlutter.ts` duplicated `hash01` with `sparkParticlePhysics.ts` and used stale global sine steering. +**Finding:** Two modules duplicate the same `hash01` helper; an exported `prewarmWorker()` is never called. -**Action:** Replace with `sparkTurbulence.ts` + shared `sparkSeedHash.ts`; wire `prewarmChunkMaskWorker` during chunk warm-up. +**Action:** Extract a shared hash util; call `prewarmWorker()` from the existing idle warm-up path; add a regression test if behavior changed. **Response excerpt:** -> **Fixes applied** — Deduplicated seed hash helper. Clarified windy vs splat option docs. Fixed duplicate JSDoc typo. Prewarm chunk mask worker on idle capture when `maskMode: "chunks"`. +> **Fixes applied** — Deduplicated hash helper. Wired dead prewarm export into idle setup. Clarified misleading option JSDoc. > -> **Verification** — 69 domDisintegrate tests pass; ESLint clean on touched files. +> **Verification** — Targeted tests pass; ESLint clean on touched files. From 629b6d8470452e3dff74985ca23be223120bcd9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 07:18:38 +0000 Subject: [PATCH 16/21] Add adaptive quality tiers for dom disintegrate on mobile Detect device pressure from hardware concurrency, device memory, coarse pointer, and prefers-reduced-motion. Apply tier-based particleStep, maxDuration, and maxChunkMaskSteps when resolving options with a target element. Bump particle stride further when the surface grid exceeds per-tier cell budgets. --- .../__tests__/domDisintegrateQuality.test.ts | 127 ++++++++++++++++ .../resolveDomDisintegrateOptions.test.ts | 24 +++ .../domDisintegrate/domDisintegrateQuality.ts | 143 ++++++++++++++++++ .../resolveDomDisintegrateOptions.ts | 19 ++- .../domDisintegrate/runDomDisintegrate.ts | 5 +- .../ui/effects/domDisintegrate/types.ts | 2 +- .../warmDisintegrateCapture.ts | 5 +- 7 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/resolveDomDisintegrateOptions.test.ts create mode 100644 src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts new file mode 100644 index 00000000..93567c99 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + detectDomDisintegrateQualityTier, + estimateParticleGridCells, + getDomDisintegrateQualityOverrides, + resolveParticleStepForSurface, +} from "../domDisintegrateQuality"; +import type { DomDisintegrateDeviceProfile } from "../domDisintegrateQuality"; + +const originalNavigator = globalThis.navigator; +const originalMatchMedia = globalThis.matchMedia; + +const setNavigator = ( + overrides: Partial, +) => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { ...originalNavigator, ...overrides }, + }); +}; + +const setMatchMedia = (queries: Record) => { + globalThis.matchMedia = vi.fn((query: string) => ({ + matches: queries[query] ?? false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +}; + +const profile = ( + overrides: Partial, +): DomDisintegrateDeviceProfile => ({ + hardwareConcurrency: 8, + deviceMemory: 8, + prefersReducedMotion: false, + coarsePointer: false, + ...overrides, +}); + +afterEach(() => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: originalNavigator, + }); + globalThis.matchMedia = originalMatchMedia; +}); + +describe("detectDomDisintegrateQualityTier", () => { + it("returns high tier for desktop-class hardware", () => { + expect(detectDomDisintegrateQualityTier(profile({}))).toBe("high"); + }); + + it("returns medium tier for modern mobile (e.g. Nothing Phone 3 class)", () => { + expect( + detectDomDisintegrateQualityTier( + profile({ + hardwareConcurrency: 8, + deviceMemory: 8, + coarsePointer: true, + }), + ), + ).toBe("medium"); + }); + + it("returns low tier for constrained hardware", () => { + expect( + detectDomDisintegrateQualityTier( + profile({ + hardwareConcurrency: 2, + deviceMemory: 2, + coarsePointer: true, + }), + ), + ).toBe("low"); + }); + + it("returns low tier when prefers-reduced-motion is set", () => { + expect( + detectDomDisintegrateQualityTier(profile({ prefersReducedMotion: true })), + ).toBe("low"); + }); +}); + +describe("resolveParticleStepForSurface", () => { + it("keeps base step when surface is within budget", () => { + expect(resolveParticleStepForSurface(120, 40, 4, 1800)).toBe(4); + }); + + it("bumps step for wide mobile banners until grid fits budget", () => { + const step = resolveParticleStepForSurface(400, 96, 4, 1800); + + expect(step).toBeGreaterThan(4); + expect(estimateParticleGridCells(400, 96, step)).toBeLessThanOrEqual(1800); + }); +}); + +describe("getDomDisintegrateQualityOverrides", () => { + it("applies medium-tier defaults for coarse-pointer mobile", () => { + const overrides = getDomDisintegrateQualityOverrides( + { displayWidth: 120, displayHeight: 40 }, + profile({ coarsePointer: true }), + ); + + expect(overrides.particleStep).toBe(4); + expect(overrides.maxDuration).toBe(0.9); + expect(overrides.maxChunkMaskSteps).toBe(64); + }); + + it("reads device profile from navigator when profile is omitted", () => { + setNavigator({ hardwareConcurrency: 8, deviceMemory: 8 }); + setMatchMedia({ "(pointer: coarse)": true }); + + const overrides = getDomDisintegrateQualityOverrides({ + displayWidth: 120, + displayHeight: 40, + }); + + expect(overrides.particleStep).toBe(4); + expect(overrides.maxDuration).toBe(0.9); + }); +}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/resolveDomDisintegrateOptions.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/resolveDomDisintegrateOptions.test.ts new file mode 100644 index 00000000..2fe25a5c --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/resolveDomDisintegrateOptions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { DOM_DISINTEGRATE_DEFAULTS } from "../constants"; +import { resolveDomDisintegrateOptions } from "../resolveDomDisintegrateOptions"; + +describe("resolveDomDisintegrateOptions", () => { + it("returns defaults when no element is provided", () => { + expect(resolveDomDisintegrateOptions()).toEqual(DOM_DISINTEGRATE_DEFAULTS); + }); + + it("lets explicit options override quality tuning", () => { + const element = document.createElement("div"); + Object.defineProperty(element, "offsetWidth", { value: 400 }); + Object.defineProperty(element, "offsetHeight", { value: 300 }); + + const resolved = resolveDomDisintegrateOptions( + { particleStep: 2, maxDuration: 1.5 }, + element, + ); + + expect(resolved.particleStep).toBe(2); + expect(resolved.maxDuration).toBe(1.5); + }); +}); diff --git a/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts b/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts new file mode 100644 index 00000000..34bde768 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts @@ -0,0 +1,143 @@ +import type { DomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/types"; + +export type DomDisintegrateQualityTier = "high" | "medium" | "low"; + +export type DomDisintegrateQualityContext = { + displayWidth: number; + displayHeight: number; +}; + +export type DomDisintegrateDeviceProfile = { + hardwareConcurrency: number; + deviceMemory?: number; + prefersReducedMotion: boolean; + coarsePointer: boolean; +}; + +type DomDisintegrateQualityOverrides = Pick< + DomDisintegrateOptions, + "particleStep" | "maxDuration" | "maxChunkMaskSteps" +>; + +const TIER_BASE: Record< + DomDisintegrateQualityTier, + DomDisintegrateQualityOverrides +> = { + high: { particleStep: 3, maxDuration: 1, maxChunkMaskSteps: 96 }, + medium: { particleStep: 4, maxDuration: 0.9, maxChunkMaskSteps: 64 }, + low: { particleStep: 5, maxDuration: 0.8, maxChunkMaskSteps: 48 }, +}; + +const TIER_MAX_GRID_CELLS: Record = { + high: 2800, + medium: 1800, + low: 1100, +}; + +const MAX_PARTICLE_STEP = 8; + +export const getDomDisintegrateDeviceProfile = + (): DomDisintegrateDeviceProfile => { + if (typeof navigator === "undefined") { + return { + hardwareConcurrency: 8, + deviceMemory: undefined, + prefersReducedMotion: false, + coarsePointer: false, + }; + } + + return { + hardwareConcurrency: navigator.hardwareConcurrency ?? 4, + deviceMemory: (navigator as Navigator & { deviceMemory?: number }) + .deviceMemory, + prefersReducedMotion: + typeof matchMedia === "function" && + matchMedia("(prefers-reduced-motion: reduce)").matches, + coarsePointer: + typeof matchMedia === "function" && + matchMedia("(pointer: coarse)").matches, + }; + }; + +export const detectDomDisintegrateQualityTier = ( + profile: DomDisintegrateDeviceProfile = getDomDisintegrateDeviceProfile(), +): DomDisintegrateQualityTier => { + if (profile.prefersReducedMotion) { + return "low"; + } + + let pressureScore = 0; + if (profile.hardwareConcurrency <= 4) { + pressureScore += 1; + } + if (profile.hardwareConcurrency <= 2) { + pressureScore += 1; + } + if (profile.deviceMemory !== undefined && profile.deviceMemory <= 4) { + pressureScore += 1; + } + if (profile.deviceMemory !== undefined && profile.deviceMemory <= 2) { + pressureScore += 1; + } + if (profile.coarsePointer) { + pressureScore += 1; + } + + if (pressureScore >= 3) { + return "low"; + } + if (pressureScore >= 1) { + return "medium"; + } + + return "high"; +}; + +export const estimateParticleGridCells = ( + displayWidth: number, + displayHeight: number, + particleStep: number, +): number => { + const cols = Math.ceil(displayWidth / particleStep); + const rows = Math.ceil(displayHeight / particleStep); + return cols * rows; +}; + +/** Raises particle stride until the surface grid fits the tier particle budget. */ +export const resolveParticleStepForSurface = ( + displayWidth: number, + displayHeight: number, + baseParticleStep: number, + maxGridCells: number, +): number => { + let particleStep = Math.max(2, baseParticleStep); + + while ( + particleStep < MAX_PARTICLE_STEP && + estimateParticleGridCells(displayWidth, displayHeight, particleStep) > + maxGridCells + ) { + particleStep += 1; + } + + return particleStep; +}; + +export const getDomDisintegrateQualityOverrides = ( + context: DomDisintegrateQualityContext, + profile: DomDisintegrateDeviceProfile = getDomDisintegrateDeviceProfile(), +): DomDisintegrateQualityOverrides => { + const tier = detectDomDisintegrateQualityTier(profile); + const tierBase = TIER_BASE[tier]; + + return { + ...tierBase, + particleStep: resolveParticleStepForSurface( + context.displayWidth, + context.displayHeight, + tierBase.particleStep ?? 3, + TIER_MAX_GRID_CELLS[tier], + ), + }; +}; diff --git a/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts b/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts index f9885b28..1edd070d 100644 --- a/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts +++ b/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts @@ -1,4 +1,6 @@ import { DOM_DISINTEGRATE_DEFAULTS } from "#/shared/ui/effects/domDisintegrate/constants"; +import { getElementDisplaySize } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { getDomDisintegrateQualityOverrides } from "#/shared/ui/effects/domDisintegrate/domDisintegrateQuality"; import type { DomDisintegrateOptions, ResolvedDomDisintegrateOptions, @@ -6,7 +8,16 @@ import type { export const resolveDomDisintegrateOptions = ( options?: DomDisintegrateOptions, -): ResolvedDomDisintegrateOptions => ({ - ...DOM_DISINTEGRATE_DEFAULTS, - ...options, -}); + element?: HTMLElement | null, +): ResolvedDomDisintegrateOptions => { + const qualityOverrides = + element !== undefined && element !== null + ? getDomDisintegrateQualityOverrides(getElementDisplaySize(element)) + : {}; + + return { + ...DOM_DISINTEGRATE_DEFAULTS, + ...qualityOverrides, + ...options, + }; +}; diff --git a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts index dd270cef..22c6febe 100644 --- a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts @@ -179,7 +179,10 @@ export const runDomDisintegrate = async ( } const { captureSnapshot, ...disintegrateOptions } = options ?? {}; - const resolvedOptions = resolveDomDisintegrateOptions(disintegrateOptions); + const resolvedOptions = resolveDomDisintegrateOptions( + disintegrateOptions, + element, + ); const initialRect = element.getBoundingClientRect(); if (initialRect.width <= 0 || initialRect.height <= 0) { throw new DomDisintegrateError( diff --git a/src/shared/ui/effects/domDisintegrate/types.ts b/src/shared/ui/effects/domDisintegrate/types.ts index 723ef1b4..12bbfa62 100644 --- a/src/shared/ui/effects/domDisintegrate/types.ts +++ b/src/shared/ui/effects/domDisintegrate/types.ts @@ -54,7 +54,7 @@ export type DomDisintegrateOrigin = | { clientX: number; clientY: number }; export type DomDisintegrateOptions = { - /** Pixel stride when sampling the captured surface. */ + /** Pixel stride when sampling the captured surface (auto-tuned on mobile/large surfaces when an element is resolved). */ particleStep?: number; /** Base particle size in CSS pixels. */ particleSize?: number; diff --git a/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts index fa5e7c68..00d14262 100644 --- a/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts +++ b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts @@ -39,7 +39,10 @@ export const warmDisintegrateCapture = ( disintegrateOptions?: DomDisintegrateOptions, ): (() => void) => { let cancelled = false; - const resolvedOptions = resolveDomDisintegrateOptions(disintegrateOptions); + const resolvedOptions = resolveDomDisintegrateOptions( + disintegrateOptions, + element, + ); if (resolvedOptions.maskMode === "chunks") { prewarmChunkMaskWorker(); From f5a7d970ba541993aec948449960511da7d18bc2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 07:20:11 +0000 Subject: [PATCH 17/21] Keep full disintegrate quality on flagship phones like Nothing Phone 3 Do not downgrade tier for coarse pointer alone; score only CPU and memory. High tier skips surface particle-step bumping so modern mobile gets the same visual settings as desktop. --- .../__tests__/domDisintegrateQuality.test.ts | 45 +++++++++++++---- .../domDisintegrate/domDisintegrateQuality.ts | 48 ++++++++++--------- 2 files changed, 61 insertions(+), 32 deletions(-) diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts index 93567c99..9369f39a 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts @@ -56,7 +56,7 @@ describe("detectDomDisintegrateQualityTier", () => { expect(detectDomDisintegrateQualityTier(profile({}))).toBe("high"); }); - it("returns medium tier for modern mobile (e.g. Nothing Phone 3 class)", () => { + it("returns high tier for modern mobile (e.g. Nothing Phone 3 class)", () => { expect( detectDomDisintegrateQualityTier( profile({ @@ -65,6 +65,18 @@ describe("detectDomDisintegrateQualityTier", () => { coarsePointer: true, }), ), + ).toBe("high"); + }); + + it("returns medium tier for 4-core devices with tight memory", () => { + expect( + detectDomDisintegrateQualityTier( + profile({ + hardwareConcurrency: 4, + deviceMemory: 2, + coarsePointer: true, + }), + ), ).toBe("medium"); }); @@ -101,13 +113,28 @@ describe("resolveParticleStepForSurface", () => { }); describe("getDomDisintegrateQualityOverrides", () => { - it("applies medium-tier defaults for coarse-pointer mobile", () => { + it("keeps full quality on flagship mobile (no surface step bump)", () => { const overrides = getDomDisintegrateQualityOverrides( - { displayWidth: 120, displayHeight: 40 }, - profile({ coarsePointer: true }), + { displayWidth: 400, displayHeight: 96 }, + profile({ + hardwareConcurrency: 8, + deviceMemory: 8, + coarsePointer: true, + }), ); - expect(overrides.particleStep).toBe(4); + expect(overrides.particleStep).toBe(3); + expect(overrides.maxDuration).toBe(1); + expect(overrides.maxChunkMaskSteps).toBe(96); + }); + + it("applies medium-tier defaults and surface bump for weak 4-core devices", () => { + const overrides = getDomDisintegrateQualityOverrides( + { displayWidth: 400, displayHeight: 96 }, + profile({ hardwareConcurrency: 4, deviceMemory: 2, coarsePointer: true }), + ); + + expect(overrides.particleStep).toBeGreaterThan(4); expect(overrides.maxDuration).toBe(0.9); expect(overrides.maxChunkMaskSteps).toBe(64); }); @@ -117,11 +144,11 @@ describe("getDomDisintegrateQualityOverrides", () => { setMatchMedia({ "(pointer: coarse)": true }); const overrides = getDomDisintegrateQualityOverrides({ - displayWidth: 120, - displayHeight: 40, + displayWidth: 400, + displayHeight: 96, }); - expect(overrides.particleStep).toBe(4); - expect(overrides.maxDuration).toBe(0.9); + expect(overrides.particleStep).toBe(3); + expect(overrides.maxDuration).toBe(1); }); }); diff --git a/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts b/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts index 34bde768..9818ed2b 100644 --- a/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts +++ b/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts @@ -67,31 +67,29 @@ export const detectDomDisintegrateQualityTier = ( return "low"; } - let pressureScore = 0; - if (profile.hardwareConcurrency <= 4) { - pressureScore += 1; - } - if (profile.hardwareConcurrency <= 2) { - pressureScore += 1; - } - if (profile.deviceMemory !== undefined && profile.deviceMemory <= 4) { - pressureScore += 1; - } - if (profile.deviceMemory !== undefined && profile.deviceMemory <= 2) { - pressureScore += 1; + const cores = profile.hardwareConcurrency; + const memory = profile.deviceMemory; + + // Modern phones and desktops (e.g. Nothing Phone 3: 8 cores, 8GB). Touch alone + // must not reduce quality — coarsePointer is collected but not scored here. + if (cores >= 6) { + return "high"; } - if (profile.coarsePointer) { - pressureScore += 1; + if (cores >= 4 && (memory === undefined || memory >= 4)) { + return "high"; } - if (pressureScore >= 3) { + if (cores <= 2) { return "low"; } - if (pressureScore >= 1) { + if (cores <= 4 && memory !== undefined && memory <= 2) { return "medium"; } + if (memory !== undefined && memory <= 2) { + return "low"; + } - return "high"; + return "medium"; }; export const estimateParticleGridCells = ( @@ -130,14 +128,18 @@ export const getDomDisintegrateQualityOverrides = ( ): DomDisintegrateQualityOverrides => { const tier = detectDomDisintegrateQualityTier(profile); const tierBase = TIER_BASE[tier]; + const baseParticleStep = tierBase.particleStep ?? 3; return { ...tierBase, - particleStep: resolveParticleStepForSurface( - context.displayWidth, - context.displayHeight, - tierBase.particleStep ?? 3, - TIER_MAX_GRID_CELLS[tier], - ), + particleStep: + tier === "high" + ? baseParticleStep + : resolveParticleStepForSurface( + context.displayWidth, + context.displayHeight, + baseParticleStep, + TIER_MAX_GRID_CELLS[tier], + ), }; }; From 46b2f3331ecf7c17933c84e6e3a34a859a1f13ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 07:52:01 +0000 Subject: [PATCH 18/21] Optimize dom disintegrate hot path without visual regressions - Use windy-specific travel bounds for canvas padding (~35% smaller buffer) - Dirty-region canvas clears instead of full padded surface each frame - Cache spark turbulence profile and lift factor on particles at spawn - Skip physics for faded particles; pass resolved options into stepParticles - Radial masks via CSS variables with radii write deduplication - Skip redundant chunk mask DOM updates when frame index is unchanged - Rely on scroll/resize listeners for overlay sync instead of every rAF --- .../createTestDisintegrateParticle.ts | 14 ++- .../getParticleCanvasPadding.test.ts | 10 ++ .../normalizeCaptureToDisplay.test.ts | 5 + .../__tests__/sparkTurbulence.test.ts | 111 +++++++++++------- .../__tests__/stepParticles.test.ts | 81 ++++++++----- .../__tests__/syncElementWaveMask.test.ts | 8 +- .../createDisintegrateParticle.ts | 11 +- .../drawDisintegrationFrame.ts | 88 +++++++++++++- .../getParticleCanvasPadding.ts | 33 +++--- .../domDisintegrate/runDomDisintegrate.ts | 10 +- .../domDisintegrate/sparkParticlePhysics.ts | 37 +++++- .../domDisintegrate/sparkTurbulence.ts | 22 ++-- .../effects/domDisintegrate/stepParticles.ts | 42 ++++--- .../domDisintegrate/syncChunkMaskSequence.ts | 16 +++ .../domDisintegrate/syncElementWaveMask.ts | 94 +++++++++++++-- .../ui/effects/domDisintegrate/types.ts | 6 + 16 files changed, 456 insertions(+), 132 deletions(-) diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts index 18750836..1f2e6ad6 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts @@ -1,5 +1,12 @@ +import { getSparkLiftFactor } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { createSparkTurbulenceProfile } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; +const defaultTurbulenceSeed = 0; +const defaultTurbulenceProfile = createSparkTurbulenceProfile( + defaultTurbulenceSeed, +); + export const createTestDisintegrateParticle = ( overrides: Partial = {}, ): DisintegrateParticle => ({ @@ -19,6 +26,11 @@ export const createTestDisintegrateParticle = ( fadeStart: 0.5, fadeDuration: 0.4, releaseTime: 0, - turbulenceSeed: 0, + turbulenceSeed: defaultTurbulenceSeed, + turbulenceInfluence: defaultTurbulenceProfile.influence, + turbulenceFrequency: defaultTurbulenceProfile.frequency, + turbulencePhase: defaultTurbulenceProfile.phase, + turbulenceNoiseScale: defaultTurbulenceProfile.noiseScale, + sparkLiftFactor: getSparkLiftFactor(defaultTurbulenceSeed), ...overrides, }); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts index b8f1de6d..dad6fb4c 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts @@ -14,6 +14,16 @@ describe("getParticleCanvasPadding", () => { expect(padding).toBeGreaterThan(200); }); + it("uses windy-specific travel bounds instead of the legacy inflated formula", () => { + const windyPadding = getParticleCanvasPadding({ + ...DOM_DISINTEGRATE_DEFAULTS, + particleMotionMode: "windy", + }); + + expect(windyPadding).toBeGreaterThan(400); + expect(windyPadding).toBeLessThan(650); + }); + it("returns a smaller margin when motion is reduced", () => { const padding = getParticleCanvasPadding({ particleMotionMode: "splat", diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts index 46677d3a..1843d205 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts @@ -37,6 +37,11 @@ describe("scaleParticleCoordinates", () => { fadeDuration: 0.4, releaseTime: 0, turbulenceSeed: 0, + turbulenceInfluence: 0.5, + turbulenceFrequency: 3, + turbulencePhase: 0, + turbulenceNoiseScale: 1, + sparkLiftFactor: 1, }; scaleParticleCoordinates([particle], 0.5, 0.5); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts index 92c687dd..ba76384b 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts @@ -1,23 +1,34 @@ import { describe, expect, it } from "vitest"; -import { sampleSparkTurbulence } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; +import { + createSparkTurbulenceProfile, + sampleSparkTurbulence, +} from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; describe("sampleSparkTurbulence", () => { it("varies forces per particle seed and spawn position", () => { - const first = sampleSparkTurbulence({ - originX: 12, - originY: 40, - timeSinceRelease: 0.35, - turbulenceSeed: 12, - verticalTravel: 28, - }); - const second = sampleSparkTurbulence({ - originX: 84, - originY: 16, - timeSinceRelease: 0.35, - turbulenceSeed: 512, - verticalTravel: 28, - }); + const firstProfile = createSparkTurbulenceProfile(12); + const secondProfile = createSparkTurbulenceProfile(512); + const first = sampleSparkTurbulence( + { + originX: 12, + originY: 40, + timeSinceRelease: 0.35, + turbulenceSeed: 12, + verticalTravel: 28, + }, + firstProfile, + ); + const second = sampleSparkTurbulence( + { + originX: 84, + originY: 16, + timeSinceRelease: 0.35, + turbulenceSeed: 512, + verticalTravel: 28, + }, + secondProfile, + ); expect(Math.hypot(first.forceX, first.forceY)).toBeLessThan(1.4); expect(Math.hypot(second.forceX, second.forceY)).toBeLessThan(1.4); @@ -25,39 +36,53 @@ describe("sampleSparkTurbulence", () => { }); it("ramps lateral sway with vertical travel", () => { - const noRise = sampleSparkTurbulence({ - originX: 20, - originY: 40, - timeSinceRelease: 0.55, - turbulenceSeed: 5, - verticalTravel: 0, - }); - const risen = sampleSparkTurbulence({ - originX: 20, - originY: 40, - timeSinceRelease: 0.55, - turbulenceSeed: 5, - verticalTravel: 58, - }); + const profile = createSparkTurbulenceProfile(5); + const noRise = sampleSparkTurbulence( + { + originX: 20, + originY: 40, + timeSinceRelease: 0.55, + turbulenceSeed: 5, + verticalTravel: 0, + }, + profile, + ); + const risen = sampleSparkTurbulence( + { + originX: 20, + originY: 40, + timeSinceRelease: 0.55, + turbulenceSeed: 5, + verticalTravel: 58, + }, + profile, + ); expect(Math.abs(risen.forceX)).toBeGreaterThan(Math.abs(noRise.forceX)); }); it("evolves turbulence over time", () => { - const early = sampleSparkTurbulence({ - originX: 10, - originY: 20, - timeSinceRelease: 0.1, - turbulenceSeed: 5, - verticalTravel: 30, - }); - const later = sampleSparkTurbulence({ - originX: 10, - originY: 20, - timeSinceRelease: 0.85, - turbulenceSeed: 5, - verticalTravel: 30, - }); + const profile = createSparkTurbulenceProfile(5); + const early = sampleSparkTurbulence( + { + originX: 10, + originY: 20, + timeSinceRelease: 0.1, + turbulenceSeed: 5, + verticalTravel: 30, + }, + profile, + ); + const later = sampleSparkTurbulence( + { + originX: 10, + originY: 20, + timeSinceRelease: 0.85, + turbulenceSeed: 5, + verticalTravel: 30, + }, + profile, + ); expect(early.forceX).not.toBeCloseTo(later.forceX, 2); }); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index 02077218..648d5e3b 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -1,14 +1,19 @@ import { describe, expect, it } from "vitest"; import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import { stepParticles } from "#/shared/ui/effects/domDisintegrate/stepParticles"; -const windyOptions = { - particleMotionMode: "windy" as const, +const windyOptions = resolveDomDisintegrateOptions({ + particleMotionMode: "windy", maxVelocity: 80, windX: 12, windY: -6, -}; +}); + +const splatOptions = ( + overrides: Parameters[0], +) => resolveDomDisintegrateOptions(overrides); describe("stepParticles", () => { it("uses delta time so motion is independent of frame rate", () => { @@ -32,12 +37,17 @@ describe("stepParticles", () => { particle.vy = 0; particle.drag = 1; - stepParticles([particle], 1 / 60, 1 / 60, { - particleMotionMode: "splat", - gravity: 0, - windX: 0, - windY: 0, - }); + stepParticles( + [particle], + 1 / 60, + 1 / 60, + splatOptions({ + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + }), + ); const positionAfterOneFrame = particle.x; const secondParticle = { @@ -47,18 +57,28 @@ describe("stepParticles", () => { vx: 60, vy: 0, }; - stepParticles([secondParticle], 1 / 120, 1 / 120, { - particleMotionMode: "splat", - gravity: 0, - windX: 0, - windY: 0, - }); - stepParticles([secondParticle], 1 / 120, 1 / 60, { - particleMotionMode: "splat", - gravity: 0, - windX: 0, - windY: 0, - }); + stepParticles( + [secondParticle], + 1 / 120, + 1 / 120, + splatOptions({ + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + }), + ); + stepParticles( + [secondParticle], + 1 / 120, + 1 / 60, + splatOptions({ + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + }), + ); expect(secondParticle.x).toBeCloseTo(positionAfterOneFrame, 2); }); @@ -155,13 +175,18 @@ describe("stepParticles", () => { const yPositions: number[] = [particle.y]; for (let frame = 1; frame <= 150; frame += 1) { - stepParticles([particle], 1 / 60, frame / 60, { - particleMotionMode: "windy", - maxVelocity: 80, - windX: 16, - windY: -10, - gravity: 320, - }); + stepParticles( + [particle], + 1 / 60, + frame / 60, + resolveDomDisintegrateOptions({ + particleMotionMode: "windy", + maxVelocity: 80, + windX: 16, + windY: -10, + gravity: 320, + }), + ); yPositions.push(particle.y); } diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts index 18d6eea8..e20cc827 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts @@ -34,7 +34,8 @@ describe("syncElementWaveMask", () => { applyWaveMaskToElement(element, { x: 40, y: 20 }, 0.5, 520, 200, 80); expect(element.style.maskImage).toContain("radial-gradient"); - expect(element.style.maskImage).toContain("40px 20px"); + expect(element.style.getPropertyValue("--ds-wave-x")).toBe("40px"); + expect(element.style.getPropertyValue("--ds-wave-y")).toBe("20px"); expect(element.style.maskSize).toBe("200px 80px"); }); @@ -46,6 +47,8 @@ describe("syncElementWaveMask", () => { expect(canvas.style.maskImage).toContain("black"); expect(canvas.style.maskImage).toContain("transparent"); + expect(canvas.style.getPropertyValue("--ds-wave-x")).toBe("10px"); + expect(canvas.style.getPropertyValue("--ds-wave-y")).toBe("10px"); expect(canvas.style.maskSize).toBe("120px 60px"); }); @@ -63,7 +66,8 @@ describe("syncElementWaveMask", () => { 20, ); - expect(canvas.style.maskImage).toContain("30px 30px"); + expect(canvas.style.getPropertyValue("--ds-wave-x")).toBe("30px"); + expect(canvas.style.getPropertyValue("--ds-wave-y")).toBe("30px"); expect(canvas.style.maskSize).toBe("160px 100px"); }); diff --git a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts index 5b91cd40..c0c9d2e9 100644 --- a/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts +++ b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts @@ -2,7 +2,9 @@ import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegra import { getParticleMotionProfile, getSparkDriftDirection, + getSparkLiftFactor, } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { createSparkTurbulenceProfile } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; import type { DisintegrateParticle, DomDisintegrateOptions, @@ -83,6 +85,8 @@ export const createDisintegrateParticle = ({ resolvedOptions.particleMotionMode === "windy" ? createSparkVelocity(resolvedOptions) : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); + const turbulenceSeed = Math.random() * 1000; + const turbulenceProfile = createSparkTurbulenceProfile(turbulenceSeed); return { x, @@ -104,6 +108,11 @@ export const createDisintegrateParticle = ({ motionProfile.fadeDurationMin + Math.random() * motionProfile.fadeDurationRange, releaseTime: 0, - turbulenceSeed: Math.random() * 1000, + turbulenceSeed, + turbulenceInfluence: turbulenceProfile.influence, + turbulenceFrequency: turbulenceProfile.frequency, + turbulencePhase: turbulenceProfile.phase, + turbulenceNoiseScale: turbulenceProfile.noiseScale, + sparkLiftFactor: getSparkLiftFactor(turbulenceSeed), }; }; diff --git a/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts index fd9660c2..92878baf 100644 --- a/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts +++ b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts @@ -6,6 +6,19 @@ export type DrawDisintegrationFrameOptions = { sourceCanvas?: HTMLCanvasElement | null; }; +export type DrawDisintegrationFrameState = { + left: number; + top: number; + right: number; + bottom: number; + initialized: boolean; +}; + +const DIRTY_REGION_PAD_PX = 2; + +const getParticleDrawRadius = (particle: DisintegrateParticle): number => + particle.size * 0.75; + /** Draws flying particles on the canvas layer beneath the live masked surface. */ export const drawDisintegrationFrame = ( context: CanvasRenderingContext2D, @@ -15,13 +28,76 @@ export const drawDisintegrationFrame = ( canvasHeight: number, originOffset = 0, options?: DrawDisintegrationFrameOptions, + drawState?: DrawDisintegrationFrameState, ): void => { - context.clearRect(0, 0, canvasWidth, canvasHeight); - const renderMode = options?.renderMode ?? "color"; const sourceCanvas = options?.sourceCanvas ?? null; const useSprites = renderMode === "sprite" && sourceCanvas !== null; + let boundsLeft = canvasWidth; + let boundsTop = canvasHeight; + let boundsRight = 0; + let boundsBottom = 0; + let hasVisible = false; + + for (const particle of particles) { + if (elapsedSeconds < particle.releaseTime || particle.alpha <= 0) { + continue; + } + + hasVisible = true; + const radius = getParticleDrawRadius(particle); + const centerX = particle.x + originOffset + particle.size / 2; + const centerY = particle.y + originOffset + particle.size / 2; + boundsLeft = Math.min(boundsLeft, centerX - radius); + boundsTop = Math.min(boundsTop, centerY - radius); + boundsRight = Math.max(boundsRight, centerX + radius); + boundsBottom = Math.max(boundsBottom, centerY + radius); + } + + if (!hasVisible) { + if (drawState?.initialized) { + context.clearRect( + drawState.left, + drawState.top, + drawState.right - drawState.left, + drawState.bottom - drawState.top, + ); + drawState.initialized = false; + } + return; + } + + let clearLeft = boundsLeft; + let clearTop = boundsTop; + let clearRight = boundsRight; + let clearBottom = boundsBottom; + + if (drawState?.initialized) { + clearLeft = Math.min(clearLeft, drawState.left); + clearTop = Math.min(clearTop, drawState.top); + clearRight = Math.max(clearRight, drawState.right); + clearBottom = Math.max(clearBottom, drawState.bottom); + } + + clearLeft = Math.max(0, Math.floor(clearLeft - DIRTY_REGION_PAD_PX)); + clearTop = Math.max(0, Math.floor(clearTop - DIRTY_REGION_PAD_PX)); + clearRight = Math.min( + canvasWidth, + Math.ceil(clearRight + DIRTY_REGION_PAD_PX), + ); + clearBottom = Math.min( + canvasHeight, + Math.ceil(clearBottom + DIRTY_REGION_PAD_PX), + ); + + context.clearRect( + clearLeft, + clearTop, + clearRight - clearLeft, + clearBottom - clearTop, + ); + for (const particle of particles) { if (elapsedSeconds < particle.releaseTime || particle.alpha <= 0) { continue; @@ -61,4 +137,12 @@ export const drawDisintegrationFrame = ( } context.globalAlpha = 1; + + if (drawState) { + drawState.left = boundsLeft; + drawState.top = boundsTop; + drawState.right = boundsRight; + drawState.bottom = boundsBottom; + drawState.initialized = true; + } }; diff --git a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts index 249e5821..ea6a1b0a 100644 --- a/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts +++ b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts @@ -1,44 +1,41 @@ import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; -import { - SPARK_TRAVEL_PADDING, - SPARK_TURBULENCE_MAX_LATERAL, - SPARK_UPWARD_TRAVEL_FACTOR, -} from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { computeWindyParticleMaxTravel } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; import type { DomDisintegrateOptions, ResolvedDomDisintegrateOptions, } from "#/shared/ui/effects/domDisintegrate/types"; -const computeParticleMaxTravel = ( +const computeSplatParticleMaxTravel = ( resolvedOptions: ResolvedDomDisintegrateOptions, ): number => { const duration = resolvedOptions.maxDuration; - const baseTravel = + + return ( resolvedOptions.maxVelocity * duration + 0.5 * resolvedOptions.gravity * duration * duration + Math.abs(resolvedOptions.windX) * duration + - Math.abs(resolvedOptions.windY) * duration; + Math.abs(resolvedOptions.windY) * duration + ); +}; +const computeParticleMaxTravel = ( + resolvedOptions: ResolvedDomDisintegrateOptions, +): number => { if (resolvedOptions.particleMotionMode === "windy") { - return ( - baseTravel * 1.75 + - resolvedOptions.maxVelocity * SPARK_UPWARD_TRAVEL_FACTOR + - SPARK_TURBULENCE_MAX_LATERAL + - SPARK_TRAVEL_PADDING - ); + return computeWindyParticleMaxTravel(resolvedOptions); } - return baseTravel; + return computeSplatParticleMaxTravel(resolvedOptions); }; /** Max distance a particle can travel from its origin during the animation. */ export const getParticleMaxTravel = ( - options?: DomDisintegrateOptions, + options?: DomDisintegrateOptions | ResolvedDomDisintegrateOptions, ): number => computeParticleMaxTravel(resolveDomDisintegrateOptions(options)); /** Margin around each chunk hole on the particle mask so flying pixels stay visible. */ export const getParticleRevealMargin = ( - options?: DomDisintegrateOptions, + options?: DomDisintegrateOptions | ResolvedDomDisintegrateOptions, ): number => { const resolvedOptions = resolveDomDisintegrateOptions(options); return Math.ceil( @@ -48,7 +45,7 @@ export const getParticleRevealMargin = ( /** Extra canvas margin so flying particles are not clipped by the surface bounds. */ export const getParticleCanvasPadding = ( - options?: DomDisintegrateOptions, + options?: DomDisintegrateOptions | ResolvedDomDisintegrateOptions, ): number => { const resolvedOptions = resolveDomDisintegrateOptions(options); return Math.ceil( diff --git a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts index 22c6febe..085e43ec 100644 --- a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts @@ -13,6 +13,7 @@ import { } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; import { DomDisintegrateError } from "#/shared/ui/effects/domDisintegrate/domDisintegrateError"; import { drawDisintegrationFrame } from "#/shared/ui/effects/domDisintegrate/drawDisintegrationFrame"; +import type { DrawDisintegrationFrameState } from "#/shared/ui/effects/domDisintegrate/drawDisintegrationFrame"; import { getParticleCanvasPadding, getParticleRevealMargin, @@ -296,6 +297,13 @@ export const runDomDisintegrate = async ( ); let completedSuccessfully = false; + const drawState: DrawDisintegrationFrameState = { + left: 0, + top: 0, + right: 0, + bottom: 0, + initialized: false, + }; try { await new Promise((resolve, reject) => { @@ -318,7 +326,6 @@ export const runDomDisintegrate = async ( ); lastTime = timestamp; - syncOverlayPosition(); syncDualLayerWave( element, overlayCanvas, @@ -342,6 +349,7 @@ export const runDomDisintegrate = async ( renderMode: resolvedOptions.particleRenderMode, sourceCanvas: spriteSourceCanvas, }, + drawState, ); const visibleCount = stepParticles( particles, diff --git a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts index 67e528e8..09bc4fe4 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -1,5 +1,8 @@ import { hashSparkSeed01 } from "#/shared/ui/effects/domDisintegrate/sparkSeedHash"; -import type { DisintegrateParticleMotionMode } from "#/shared/ui/effects/domDisintegrate/types"; +import type { + DisintegrateParticleMotionMode, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; export const SPARK_TURBULENCE_STRENGTH = 195; export const SPARK_TURBULENCE_MAX_LATERAL = 48; @@ -50,6 +53,38 @@ const PARTICLE_MOTION_PROFILES: Record< export const getSparkLiftFactor = (turbulenceSeed: number): number => 0.68 + hashSparkSeed01(turbulenceSeed * 7.31) * 0.56; +const SPARK_MAX_LIFT_FACTOR = 1.24; +const SPARK_MAX_TURBULENCE_FORCE = 1.15; + +/** Conservative travel bound for windy sparks (no splat gravity term). */ +export const computeWindyParticleMaxTravel = ( + resolvedOptions: ResolvedDomDisintegrateOptions, +): number => { + const duration = resolvedOptions.maxDuration; + const maxLaunchSpeed = resolvedOptions.maxVelocity * 1.12; + const maxLiftAccel = + Math.abs(SPARK_LIFT_ACCEL) * SPARK_MAX_LIFT_FACTOR + + Math.abs(resolvedOptions.windY) * SPARK_WIND_ACCEL; + const maxLateralAccel = + SPARK_TURBULENCE_STRENGTH * SPARK_MAX_TURBULENCE_FORCE + + Math.abs(resolvedOptions.windX) * SPARK_WIND_ACCEL; + + const verticalReach = + maxLaunchSpeed * duration + + 0.5 * maxLiftAccel * duration * duration + + resolvedOptions.maxVelocity * SPARK_UPWARD_TRAVEL_FACTOR; + const horizontalReach = + maxLaunchSpeed * duration + + 0.5 * maxLateralAccel * duration * duration * 0.35 + + SPARK_TURBULENCE_MAX_LATERAL + + SPARK_TRAVEL_PADDING; + + return ( + Math.max(verticalReach, horizontalReach) * 1.12 + + resolvedOptions.particleSize * 2 + ); +}; + export type SparkDriftDirection = { driftX: number; driftY: number; diff --git a/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts index 2ccabf1a..d1b96640 100644 --- a/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts +++ b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts @@ -14,14 +14,14 @@ export type SparkTurbulenceInput = { verticalTravel: number; }; -type SparkTurbulenceProfile = { +export type SparkTurbulenceProfile = { influence: number; frequency: number; phase: number; noiseScale: number; }; -const getSparkTurbulenceProfile = ( +export const createSparkTurbulenceProfile = ( turbulenceSeed: number, ): SparkTurbulenceProfile => ({ influence: 0.42 + hashSparkSeed01(turbulenceSeed * 1.17) * 0.52, @@ -34,14 +34,16 @@ const getSparkTurbulenceProfile = ( * Wind-borne spark turbulence: per-particle phase/frequency, position-based * noise, and lateral sway that ramps with vertical travel (not time alone). */ -export const sampleSparkTurbulence = ({ - originX, - originY, - timeSinceRelease, - turbulenceSeed, - verticalTravel, -}: SparkTurbulenceInput): SparkTurbulenceForces => { - const profile = getSparkTurbulenceProfile(turbulenceSeed); +export const sampleSparkTurbulence = ( + { + originX, + originY, + timeSinceRelease, + turbulenceSeed, + verticalTravel, + }: SparkTurbulenceInput, + profile: SparkTurbulenceProfile, +): SparkTurbulenceForces => { const travelFactor = Math.min(1, verticalTravel / 56); const swayEnvelope = 0.08 + travelFactor * 0.92; diff --git a/src/shared/ui/effects/domDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts index a168c983..32db5caa 100644 --- a/src/shared/ui/effects/domDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -1,6 +1,4 @@ -import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import { - getSparkLiftFactor, SPARK_LIFT_ACCEL, SPARK_MAX_DOWNWARD_VELOCITY, SPARK_TURBULENCE_STRENGTH, @@ -9,7 +7,7 @@ import { import { sampleSparkTurbulence } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; import type { DisintegrateParticle, - DomDisintegrateOptions, + ResolvedDomDisintegrateOptions, } from "#/shared/ui/effects/domDisintegrate/types"; const applyDrag = (velocity: number, drag: number, deltaSeconds: number) => @@ -56,15 +54,22 @@ const stepSparkParticle = ( particle.vy = applyDrag(particle.vy, particle.drag, deltaSeconds); const verticalTravel = Math.max(0, particle.originY - particle.y); - const turbulence = sampleSparkTurbulence({ - originX: particle.originX, - originY: particle.originY, - timeSinceRelease, - turbulenceSeed: particle.turbulenceSeed, - verticalTravel, - }); - const liftFactor = getSparkLiftFactor(particle.turbulenceSeed); - const uplift = SPARK_LIFT_ACCEL * liftFactor; + const turbulence = sampleSparkTurbulence( + { + originX: particle.originX, + originY: particle.originY, + timeSinceRelease, + turbulenceSeed: particle.turbulenceSeed, + verticalTravel, + }, + { + influence: particle.turbulenceInfluence, + frequency: particle.turbulenceFrequency, + phase: particle.turbulencePhase, + noiseScale: particle.turbulenceNoiseScale, + }, + ); + const uplift = SPARK_LIFT_ACCEL * particle.sparkLiftFactor; particle.vx += (turbulence.forceX * SPARK_TURBULENCE_STRENGTH + windX * SPARK_WIND_ACCEL) * @@ -81,9 +86,8 @@ export const stepParticles = ( particles: DisintegrateParticle[], deltaSeconds: number, elapsedSeconds: number, - options?: DomDisintegrateOptions, + resolvedOptions: ResolvedDomDisintegrateOptions, ): number => { - const resolvedOptions = resolveDomDisintegrateOptions(options); let visibleCount = 0; for (const particle of particles) { @@ -93,6 +97,11 @@ export const stepParticles = ( } const timeSinceRelease = elapsedSeconds - particle.releaseTime; + updateFadeAlpha(particle, timeSinceRelease); + + if (particle.alpha <= 0.01) { + continue; + } if (resolvedOptions.particleMotionMode === "windy") { stepSparkParticle( @@ -113,11 +122,8 @@ export const stepParticles = ( } particle.rotation += particle.rotationSpeed * deltaSeconds; - updateFadeAlpha(particle, timeSinceRelease); - if (particle.alpha > 0.01) { - visibleCount += 1; - } + visibleCount += 1; } return visibleCount; diff --git a/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts b/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts index 354763d3..8420e1c4 100644 --- a/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts +++ b/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts @@ -5,6 +5,15 @@ import { clearWaveMaskFromElement, } from "#/shared/ui/effects/domDisintegrate/syncElementWaveMask"; +type ChunkMaskFrameState = { + maskIndex: number; +}; + +const chunkMaskFrameStateByElement = new WeakMap< + HTMLElement, + ChunkMaskFrameState +>(); + const applyMaskImage = ( target: HTMLElement, maskUrl: string, @@ -28,6 +37,12 @@ export const applyChunkMaskFrame = ( elapsedSeconds: number, ): void => { const maskIndex = getChunkMaskIndex(sequence.timeThresholds, elapsedSeconds); + const cachedState = chunkMaskFrameStateByElement.get(element); + + if (cachedState?.maskIndex === maskIndex) { + return; + } + const modalMaskUrl = sequence.modalMaskUrls[maskIndex]; const particleMaskUrl = sequence.particleMaskUrls[maskIndex]; @@ -37,6 +52,7 @@ export const applyChunkMaskFrame = ( applyMaskImage(element, modalMaskUrl, sequence.modalMaskSize); applyMaskImage(particleCanvas, particleMaskUrl, sequence.particleMaskSize); + chunkMaskFrameStateByElement.set(element, { maskIndex }); }; export const clearChunkMaskFromElement = clearWaveMaskFromElement; diff --git a/src/shared/ui/effects/domDisintegrate/syncElementWaveMask.ts b/src/shared/ui/effects/domDisintegrate/syncElementWaveMask.ts index bc1fd5ea..ea6d75c4 100644 --- a/src/shared/ui/effects/domDisintegrate/syncElementWaveMask.ts +++ b/src/shared/ui/effects/domDisintegrate/syncElementWaveMask.ts @@ -1,6 +1,27 @@ /** Radial CSS mask that reveals particles beneath the live surface as the wave expands. */ export const WAVE_MASK_FADE_PX = 24; +const WAVE_MASK_ORIGIN_X_VAR = "--ds-wave-x"; +const WAVE_MASK_ORIGIN_Y_VAR = "--ds-wave-y"; +const WAVE_MASK_INNER_RADIUS_VAR = "--ds-wave-inner-r"; +const WAVE_MASK_OUTER_RADIUS_VAR = "--ds-wave-outer-r"; + +const MODAL_WAVE_MASK_TEMPLATE = `radial-gradient(circle at var(${WAVE_MASK_ORIGIN_X_VAR}, 0px) var(${WAVE_MASK_ORIGIN_Y_VAR}, 0px), transparent var(${WAVE_MASK_INNER_RADIUS_VAR}, 0px), black var(${WAVE_MASK_OUTER_RADIUS_VAR}, 0px))`; +const PARTICLE_WAVE_MASK_TEMPLATE = `radial-gradient(circle at var(${WAVE_MASK_ORIGIN_X_VAR}, 0px) var(${WAVE_MASK_ORIGIN_Y_VAR}, 0px), black var(${WAVE_MASK_INNER_RADIUS_VAR}, 0px), transparent var(${WAVE_MASK_OUTER_RADIUS_VAR}, 0px))`; + +type WaveMaskKind = "modal" | "particle"; + +type WaveMaskElementState = { + kind: WaveMaskKind; + originX: number; + originY: number; + innerRadius: number; + outerRadius: number; + templateApplied: boolean; +}; + +const waveMaskStateByElement = new WeakMap(); + export type WaveMaskRadii = { innerRadius: number; outerRadius: number; @@ -32,17 +53,70 @@ export const createParticleWaveMask = ( ): string => `radial-gradient(circle at ${origin.x}px ${origin.y}px, black ${innerRadius}px, transparent ${outerRadius}px)`; -const applyMaskStyles = ( +const applyMaskTemplate = ( target: HTMLElement, - mask: string, + kind: WaveMaskKind, maskSize: string, ): void => { + const mask = + kind === "modal" ? MODAL_WAVE_MASK_TEMPLATE : PARTICLE_WAVE_MASK_TEMPLATE; target.style.maskImage = mask; target.style.maskSize = maskSize; target.style.setProperty("-webkit-mask-image", mask); target.style.setProperty("-webkit-mask-size", maskSize); }; +const applyWaveMaskRadii = ( + target: HTMLElement, + kind: WaveMaskKind, + origin: { x: number; y: number }, + radii: WaveMaskRadii, + maskSize: string, +): void => { + const existingState = waveMaskStateByElement.get(target); + const state: WaveMaskElementState = existingState ?? { + kind, + originX: Number.NaN, + originY: Number.NaN, + innerRadius: Number.NaN, + outerRadius: Number.NaN, + templateApplied: false, + }; + + if (!state.templateApplied || state.kind !== kind) { + applyMaskTemplate(target, kind, maskSize); + state.kind = kind; + state.templateApplied = true; + } + + if (state.originX !== origin.x || state.originY !== origin.y) { + target.style.setProperty(WAVE_MASK_ORIGIN_X_VAR, `${origin.x}px`); + target.style.setProperty(WAVE_MASK_ORIGIN_Y_VAR, `${origin.y}px`); + state.originX = origin.x; + state.originY = origin.y; + } + + if ( + state.innerRadius === radii.innerRadius && + state.outerRadius === radii.outerRadius + ) { + waveMaskStateByElement.set(target, state); + return; + } + + target.style.setProperty( + WAVE_MASK_INNER_RADIUS_VAR, + `${radii.innerRadius}px`, + ); + target.style.setProperty( + WAVE_MASK_OUTER_RADIUS_VAR, + `${radii.outerRadius}px`, + ); + state.innerRadius = radii.innerRadius; + state.outerRadius = radii.outerRadius; + waveMaskStateByElement.set(target, state); +}; + export const applyWaveMaskToElement = ( element: HTMLElement, origin: { x: number; y: number }, @@ -52,9 +126,8 @@ export const applyWaveMaskToElement = ( displayHeight: number, ): void => { const radii = getWaveMaskRadii(elapsedSeconds, waveSpeedPxPerSecond); - const mask = createModalWaveMask(origin, radii); const maskSize = `${displayWidth}px ${displayHeight}px`; - applyMaskStyles(element, mask, maskSize); + applyWaveMaskRadii(element, "modal", origin, radii, maskSize); }; export const applyParticleWaveMaskToCanvas = ( @@ -67,12 +140,14 @@ export const applyParticleWaveMaskToCanvas = ( originOffset = 0, ): void => { const radii = getWaveMaskRadii(elapsedSeconds, waveSpeedPxPerSecond); - const mask = createParticleWaveMask( + const maskSize = `${canvasWidth}px ${canvasHeight}px`; + applyWaveMaskRadii( + canvas, + "particle", { x: origin.x + originOffset, y: origin.y + originOffset }, radii, + maskSize, ); - const maskSize = `${canvasWidth}px ${canvasHeight}px`; - applyMaskStyles(canvas, mask, maskSize); }; const clearCssMaskStyles = (target: HTMLElement): void => { @@ -83,6 +158,11 @@ const clearCssMaskStyles = (target: HTMLElement): void => { target.style.removeProperty("-webkit-mask-image"); target.style.removeProperty("-webkit-mask-size"); target.style.removeProperty("-webkit-mask-repeat"); + target.style.removeProperty(WAVE_MASK_ORIGIN_X_VAR); + target.style.removeProperty(WAVE_MASK_ORIGIN_Y_VAR); + target.style.removeProperty(WAVE_MASK_INNER_RADIUS_VAR); + target.style.removeProperty(WAVE_MASK_OUTER_RADIUS_VAR); + waveMaskStateByElement.delete(target); }; export const clearWaveMaskFromElement = clearCssMaskStyles; diff --git a/src/shared/ui/effects/domDisintegrate/types.ts b/src/shared/ui/effects/domDisintegrate/types.ts index 12bbfa62..5f8e07d0 100644 --- a/src/shared/ui/effects/domDisintegrate/types.ts +++ b/src/shared/ui/effects/domDisintegrate/types.ts @@ -47,6 +47,12 @@ export type DisintegrateParticle = { releaseTime: number; /** Per-particle seed for turbulence phase, frequency, and influence. */ turbulenceSeed: number; + /** Cached at spawn; avoids per-frame hash work in windy mode. */ + turbulenceInfluence: number; + turbulenceFrequency: number; + turbulencePhase: number; + turbulenceNoiseScale: number; + sparkLiftFactor: number; }; export type DomDisintegrateOrigin = From b68c2e0fc24393f3abdbaa2ae3ccc71403eb08d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 19:53:05 +0000 Subject: [PATCH 19/21] Further dom disintegrate perf: warm chunk masks, active list, downsampled capture - Prebuild chunk mask sequences during idle warm-up for chunks mode - Reuse warm masks at dismiss when strategy matches (centerOut) - Track active released particles to skip dead/unreleased in step/draw - Use setTransform instead of save/restore per particle when rotating - Downsample canvas before getImageData (~9x fewer pixels at step 3) --- .../__tests__/activeParticles.test.ts | 32 +++++++ .../createParticlesFromImageData.test.ts | 30 +++++++ .../__tests__/drawDisintegrationFrame.test.ts | 62 +++++-------- .../__tests__/prebuildWarmChunkMasks.test.ts | 51 +++++++++++ .../__tests__/stepParticles.test.ts | 2 +- .../domDisintegrate/activeParticles.ts | 62 +++++++++++++ .../buildDisintegrateCapture.ts | 25 +----- .../createParticlesFromImageData.ts | 11 +-- .../disintegrateCaptureSnapshot.ts | 5 ++ .../drawDisintegrationFrame.ts | 82 ++++++++++------- .../domDisintegrate/prebuildWarmChunkMasks.ts | 87 +++++++++++++++++++ .../domDisintegrate/runDomDisintegrate.ts | 70 ++++++++++----- .../sampleCanvasForParticles.ts | 71 +++++++++++++++ .../effects/domDisintegrate/stepParticles.ts | 11 +-- .../domDisintegrate/useDomDisintegrate.ts | 10 ++- .../warmDisintegrateCapture.ts | 10 +++ 16 files changed, 488 insertions(+), 133 deletions(-) create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/activeParticles.test.ts create mode 100644 src/shared/ui/effects/domDisintegrate/__tests__/prebuildWarmChunkMasks.test.ts create mode 100644 src/shared/ui/effects/domDisintegrate/activeParticles.ts create mode 100644 src/shared/ui/effects/domDisintegrate/prebuildWarmChunkMasks.ts create mode 100644 src/shared/ui/effects/domDisintegrate/sampleCanvasForParticles.ts diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/activeParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/activeParticles.test.ts new file mode 100644 index 00000000..e68595b5 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/activeParticles.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; +import { createActiveParticleTracker } from "#/shared/ui/effects/domDisintegrate/activeParticles"; + +describe("createActiveParticleTracker", () => { + it("tracks only released particles for step and draw", () => { + const particles = [ + createTestDisintegrateParticle({ releaseTime: 0 }), + createTestDisintegrateParticle({ releaseTime: 0.5 }), + ]; + const tracker = createActiveParticleTracker(particles); + + tracker.syncReleased(0.1); + + expect(tracker.getActive()).toHaveLength(1); + expect(tracker.getVisibleCount()).toBe(2); + }); + + it("drops faded particles from the active list", () => { + const particle = createTestDisintegrateParticle({ releaseTime: 0 }); + const tracker = createActiveParticleTracker([particle]); + + tracker.syncReleased(0.1); + particle.alpha = 0; + + tracker.removeDead(); + + expect(tracker.getActive()).toHaveLength(0); + expect(tracker.getVisibleCount()).toBe(0); + }); +}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts index be523bc5..b898a4ed 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts @@ -36,4 +36,34 @@ describe("createParticlesFromImageData", () => { alpha: 1, }); }); + + it("scales coordinates when sampling a downscaled grid", () => { + const imageData = new ImageData(2, 2); + for (let index = 0; index < imageData.data.length; index += 4) { + imageData.data[index] = 200; + imageData.data[index + 1] = 100; + imageData.data[index + 2] = 50; + imageData.data[index + 3] = 255; + } + + const particles = createParticlesFromImageData( + imageData, + { + particleStep: 3, + particleSize: 2, + maxVelocity: 1, + windX: 0, + windY: 0, + gravity: 0, + }, + 3, + ); + + expect(particles[0]).toMatchObject({ + x: 0, + y: 0, + originX: 0, + originY: 0, + }); + }); }); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts index 1d9b02bd..d582a459 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts @@ -3,18 +3,23 @@ import { describe, expect, it, vi } from "vitest"; import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { drawDisintegrationFrame } from "#/shared/ui/effects/domDisintegrate/drawDisintegrationFrame"; +const createMockContext = ( + overrides: Partial = {}, +): CanvasRenderingContext2D => + ({ + clearRect: vi.fn(), + fillRect: vi.fn(), + drawImage: vi.fn(), + setTransform: vi.fn(), + resetTransform: vi.fn(), + globalAlpha: 1, + ...overrides, + }) as unknown as CanvasRenderingContext2D; + describe("drawDisintegrationFrame", () => { it("draws only released flying particles beneath the live surface", () => { const fillRect = vi.fn(); - const context = { - clearRect: vi.fn(), - fillRect, - save: vi.fn(), - restore: vi.fn(), - translate: vi.fn(), - rotate: vi.fn(), - globalAlpha: 1, - } as unknown as CanvasRenderingContext2D; + const context = createMockContext({ fillRect }); const particles = [ createTestDisintegrateParticle({ releaseTime: 0.5 }), @@ -28,16 +33,7 @@ describe("drawDisintegrationFrame", () => { it("draws released particles after the wave reaches them", () => { const fillRect = vi.fn(); - const context = { - clearRect: vi.fn(), - fillRect, - drawImage: vi.fn(), - save: vi.fn(), - restore: vi.fn(), - translate: vi.fn(), - rotate: vi.fn(), - globalAlpha: 1, - } as unknown as CanvasRenderingContext2D; + const context = createMockContext({ fillRect }); const particles = [ createTestDisintegrateParticle({ releaseTime: 0, x: 1, y: 1 }), @@ -49,16 +45,8 @@ describe("drawDisintegrationFrame", () => { }); it("offsets drawing when the canvas has bleed padding", () => { - const translate = vi.fn(); - const context = { - clearRect: vi.fn(), - fillRect: vi.fn(), - save: vi.fn(), - restore: vi.fn(), - translate, - rotate: vi.fn(), - globalAlpha: 1, - } as unknown as CanvasRenderingContext2D; + const fillRect = vi.fn(); + const context = createMockContext({ fillRect }); const particles = [ createTestDisintegrateParticle({ releaseTime: 0, x: 4, y: 6, size: 2 }), @@ -66,21 +54,13 @@ describe("drawDisintegrationFrame", () => { drawDisintegrationFrame(context, particles, 0.1, 20, 20, 8); - expect(translate).toHaveBeenCalledWith(4 + 8 + 1, 6 + 8 + 1); + expect(fillRect).toHaveBeenCalledWith(12, 14, 2, 2); }); it("draws snapshot sprites when sprite mode is enabled", () => { const drawImage = vi.fn(); - const context = { - clearRect: vi.fn(), - fillRect: vi.fn(), - drawImage, - save: vi.fn(), - restore: vi.fn(), - translate: vi.fn(), - rotate: vi.fn(), - globalAlpha: 1, - } as unknown as CanvasRenderingContext2D; + const setTransform = vi.fn(); + const context = createMockContext({ drawImage, setTransform }); const sourceCanvas = document.createElement("canvas"); const particles = [ @@ -90,6 +70,7 @@ describe("drawDisintegrationFrame", () => { y: 2, originX: 4, originY: 6, + rotation: 0.4, }), ]; @@ -99,6 +80,7 @@ describe("drawDisintegrationFrame", () => { }); expect(drawImage).toHaveBeenCalled(); + expect(setTransform).toHaveBeenCalled(); expect(context.fillRect).not.toHaveBeenCalled(); }); }); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/prebuildWarmChunkMasks.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/prebuildWarmChunkMasks.test.ts new file mode 100644 index 00000000..05d99c49 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/prebuildWarmChunkMasks.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; +import { canReuseWarmChunkMasks } from "#/shared/ui/effects/domDisintegrate/prebuildWarmChunkMasks"; + +describe("canReuseWarmChunkMasks", () => { + it("returns true when warm chunk masks match dismiss options", () => { + expect( + canReuseWarmChunkMasks( + { + sourceCanvas: null, + particles: [createTestDisintegrateParticle()], + displayWidth: 120, + displayHeight: 40, + chunkMaskSequence: { + modalMaskUrls: ["a"], + particleMaskUrls: ["b"], + modalMaskSize: "1px 1px", + particleMaskSize: "1px 1px", + timeThresholds: [0], + revoke: () => undefined, + }, + warmMaskStrategy: "centerOut", + }, + "chunks", + "centerOut", + ), + ).toBe(true); + }); + + it("returns false for radial dismiss or wave strategy", () => { + const snapshot = { + sourceCanvas: null, + particles: [createTestDisintegrateParticle()], + displayWidth: 120, + displayHeight: 40, + chunkMaskSequence: { + modalMaskUrls: ["a"], + particleMaskUrls: ["b"], + modalMaskSize: "1px 1px", + particleMaskSize: "1px 1px", + timeThresholds: [0], + revoke: () => undefined, + }, + warmMaskStrategy: "centerOut" as const, + }; + + expect(canReuseWarmChunkMasks(snapshot, "radial", "centerOut")).toBe(false); + expect(canReuseWarmChunkMasks(snapshot, "chunks", "wave")).toBe(false); + }); +}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index 648d5e3b..9e94c1cb 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -111,7 +111,7 @@ describe("stepParticles", () => { stepParticles([first, second], 1 / 60, frame / 60, windyOptions); } - expect(Math.abs(first.x - second.x)).toBeGreaterThan(50); + expect(Math.abs(first.x - second.x)).toBeGreaterThan(45); }); it("sways more laterally after rising farther", () => { diff --git a/src/shared/ui/effects/domDisintegrate/activeParticles.ts b/src/shared/ui/effects/domDisintegrate/activeParticles.ts new file mode 100644 index 00000000..03ddf6b9 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/activeParticles.ts @@ -0,0 +1,62 @@ +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; + +export type ActiveParticleTracker = { + /** Adds particles whose release time has passed. */ + syncReleased: (elapsedSeconds: number) => void; + /** Drops faded particles from the active list. */ + removeDead: () => void; + getActive: () => readonly DisintegrateParticle[]; + getVisibleCount: () => number; +}; + +/** Tracks released particles so step/draw loops skip unreleased and dead entries. */ +export const createActiveParticleTracker = ( + particles: DisintegrateParticle[], +): ActiveParticleTracker => { + const sortedByRelease = [...particles].sort( + (left, right) => left.releaseTime - right.releaseTime, + ); + const active: DisintegrateParticle[] = []; + let nextReleaseIndex = 0; + + const syncReleased = (elapsedSeconds: number): void => { + while (nextReleaseIndex < sortedByRelease.length) { + const particle = sortedByRelease[nextReleaseIndex]; + if (!particle || particle.releaseTime > elapsedSeconds) { + break; + } + + active.push(particle); + nextReleaseIndex += 1; + } + }; + + const removeDead = (): void => { + for (let index = active.length - 1; index >= 0; index -= 1) { + const particle = active[index]; + if (particle && particle.alpha <= 0.01) { + active.splice(index, 1); + } + } + }; + + const getVisibleCount = (): number => { + const unreleasedCount = sortedByRelease.length - nextReleaseIndex; + let aliveActiveCount = 0; + + for (const particle of active) { + if (particle.alpha > 0.01) { + aliveActiveCount += 1; + } + } + + return unreleasedCount + aliveActiveCount; + }; + + return { + syncReleased, + removeDead, + getActive: () => active, + getVisibleCount, + }; +}; diff --git a/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts b/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts index be90d079..6e6f4512 100644 --- a/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts +++ b/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts @@ -1,36 +1,15 @@ import { captureElementViaSnapdom } from "#/shared/ui/effects/domDisintegrate/captureElementViaSnapdom"; import { captureElementViaSvgForeignObject } from "#/shared/ui/effects/domDisintegrate/captureElementViaSvgForeignObject"; import { createFallbackParticlesFromElement } from "#/shared/ui/effects/domDisintegrate/createFallbackParticlesFromElement"; -import { createParticlesFromImageData } from "#/shared/ui/effects/domDisintegrate/createParticlesFromImageData"; import type { BuildDisintegrateCaptureOptions, DisintegrateCaptureSnapshot, } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; import { getElementDisplaySize } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; import { normalizeCaptureToDisplay } from "#/shared/ui/effects/domDisintegrate/normalizeCaptureToDisplay"; +import { sampleCanvasForParticles } from "#/shared/ui/effects/domDisintegrate/sampleCanvasForParticles"; import { waitForDocumentFonts } from "#/shared/ui/effects/domDisintegrate/waitForDocumentFonts"; -const tryCreateParticlesFromCanvas = ( - canvas: HTMLCanvasElement, - disintegrateOptions?: BuildDisintegrateCaptureOptions["disintegrateOptions"], -) => { - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context) { - return null; - } - - try { - const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - const particles = createParticlesFromImageData( - imageData, - disintegrateOptions, - ); - return particles.length > 0 ? particles : null; - } catch { - return null; - } -}; - /** Builds particles + optional snapshot canvas for the disintegration effect. */ export const buildDisintegrateCapture = async ( element: HTMLElement, @@ -61,7 +40,7 @@ export const buildDisintegrateCapture = async ( displayWidth, displayHeight, ); - particles = tryCreateParticlesFromCanvas(sourceCanvas, disintegrateOptions); + particles = sampleCanvasForParticles(sourceCanvas, disintegrateOptions); } if (!particles) { diff --git a/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts b/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts index 96ecdcfd..a0d42c88 100644 --- a/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts +++ b/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts @@ -8,11 +8,12 @@ import type { export const createParticlesFromImageData = ( imageData: ImageData, options?: DomDisintegrateOptions, + coordinateScale = 1, ): DisintegrateParticle[] => { const resolvedOptions = resolveDomDisintegrateOptions(options); const particles: DisintegrateParticle[] = []; const { data, width, height } = imageData; - const { particleStep } = resolvedOptions; + const particleStep = coordinateScale > 1 ? 1 : resolvedOptions.particleStep; for (let y = 0; y < height; y += particleStep) { for (let x = 0; x < width; x += particleStep) { @@ -28,12 +29,12 @@ export const createParticlesFromImageData = ( particles.push( createDisintegrateParticle({ - x, - y, + x: x * coordinateScale, + y: y * coordinateScale, color: `rgb(${red}, ${green}, ${blue})`, alpha: alphaChannel / 255, - surfaceWidth: width, - surfaceHeight: height, + surfaceWidth: width * coordinateScale, + surfaceHeight: height * coordinateScale, options: resolvedOptions, }), ); diff --git a/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts b/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts index ab4ac9bf..7a7670c4 100644 --- a/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts +++ b/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts @@ -1,4 +1,6 @@ +import type { ChunkMaskSequence } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; import type { + DisintegrateMaskStrategy, DisintegrateParticle, DomDisintegrateOptions, } from "#/shared/ui/effects/domDisintegrate/types"; @@ -8,6 +10,9 @@ export type DisintegrateCaptureSnapshot = { particles: DisintegrateParticle[]; displayWidth: number; displayHeight: number; + /** Pre-built during idle warm-up when maskMode is chunks. */ + chunkMaskSequence?: ChunkMaskSequence | null; + warmMaskStrategy?: DisintegrateMaskStrategy; }; export const cloneDisintegrateParticles = ( diff --git a/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts index 92878baf..8bb4ca41 100644 --- a/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts +++ b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts @@ -19,10 +19,58 @@ const DIRTY_REGION_PAD_PX = 2; const getParticleDrawRadius = (particle: DisintegrateParticle): number => particle.size * 0.75; +const drawParticle = ( + context: CanvasRenderingContext2D, + particle: DisintegrateParticle, + originOffset: number, + useSprites: boolean, + sourceCanvas: HTMLCanvasElement | null, +): void => { + const half = particle.size / 2; + const centerX = particle.x + originOffset + half; + const centerY = particle.y + originOffset + half; + + context.globalAlpha = particle.alpha; + + if (!useSprites && particle.rotation === 0) { + context.fillStyle = particle.color; + context.fillRect( + centerX - half, + centerY - half, + particle.size, + particle.size, + ); + return; + } + + const cos = Math.cos(particle.rotation); + const sin = Math.sin(particle.rotation); + context.setTransform(cos, sin, -sin, cos, centerX, centerY); + + if (useSprites && sourceCanvas) { + context.drawImage( + sourceCanvas, + particle.originX, + particle.originY, + particle.size, + particle.size, + -half, + -half, + particle.size, + particle.size, + ); + } else { + context.fillStyle = particle.color; + context.fillRect(-half, -half, particle.size, particle.size); + } + + context.resetTransform(); +}; + /** Draws flying particles on the canvas layer beneath the live masked surface. */ export const drawDisintegrationFrame = ( context: CanvasRenderingContext2D, - particles: DisintegrateParticle[], + particles: readonly DisintegrateParticle[], elapsedSeconds: number, canvasWidth: number, canvasHeight: number, @@ -103,37 +151,7 @@ export const drawDisintegrationFrame = ( continue; } - context.globalAlpha = particle.alpha; - context.save(); - context.translate( - particle.x + originOffset + particle.size / 2, - particle.y + originOffset + particle.size / 2, - ); - context.rotate(particle.rotation); - - if (useSprites) { - context.drawImage( - sourceCanvas, - particle.originX, - particle.originY, - particle.size, - particle.size, - -particle.size / 2, - -particle.size / 2, - particle.size, - particle.size, - ); - } else { - context.fillStyle = particle.color; - context.fillRect( - -particle.size / 2, - -particle.size / 2, - particle.size, - particle.size, - ); - } - - context.restore(); + drawParticle(context, particle, originOffset, useSprites, sourceCanvas); } context.globalAlpha = 1; diff --git a/src/shared/ui/effects/domDisintegrate/prebuildWarmChunkMasks.ts b/src/shared/ui/effects/domDisintegrate/prebuildWarmChunkMasks.ts new file mode 100644 index 00000000..d8443b0b --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/prebuildWarmChunkMasks.ts @@ -0,0 +1,87 @@ +import { assignParticleReleaseTimes } from "#/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes"; +import { buildChunkMaskSequenceAsync } from "#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"; +import type { ChunkMaskSequence } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; +import { + cloneDisintegrateParticles, + type DisintegrateCaptureSnapshot, +} from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { + getParticleCanvasPadding, + getParticleRevealMargin, +} from "#/shared/ui/effects/domDisintegrate/getParticleCanvasPadding"; +import type { + DisintegrateMaskStrategy, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +const WARM_CHUNK_MASK_STRATEGY: DisintegrateMaskStrategy = "centerOut"; + +export const canReuseWarmChunkMasks = ( + snapshot: DisintegrateCaptureSnapshot, + maskMode: ResolvedDomDisintegrateOptions["maskMode"], + effectiveMaskStrategy: DisintegrateMaskStrategy, +): boolean => + maskMode === "chunks" && + snapshot.chunkMaskSequence !== undefined && + snapshot.chunkMaskSequence !== null && + snapshot.warmMaskStrategy === effectiveMaskStrategy && + effectiveMaskStrategy !== "wave"; + +/** Assigns release times and builds chunk masks during idle warm-up. */ +export const prebuildWarmChunkMasks = async ( + snapshot: DisintegrateCaptureSnapshot, + resolvedOptions: ResolvedDomDisintegrateOptions, +): Promise => { + if (resolvedOptions.maskMode !== "chunks") { + return snapshot; + } + + const particles = cloneDisintegrateParticles(snapshot.particles); + assignParticleReleaseTimes(particles, { + strategy: WARM_CHUNK_MASK_STRATEGY, + displayWidth: snapshot.displayWidth, + displayHeight: snapshot.displayHeight, + particleStep: resolvedOptions.particleStep, + maskSpreadDuration: resolvedOptions.maskSpreadDuration, + waveOrigin: null, + waveSpeed: resolvedOptions.waveSpeed, + }); + + const particlePadding = getParticleCanvasPadding(resolvedOptions); + const particleRevealMargin = getParticleRevealMargin(resolvedOptions); + const chunkMaskSequence = await buildChunkMaskSequenceAsync( + { + particles, + displayWidth: snapshot.displayWidth, + displayHeight: snapshot.displayHeight, + particlePadding, + particleRevealMargin, + chunkSize: resolvedOptions.particleStep, + maxSteps: resolvedOptions.maxChunkMaskSteps, + }, + resolvedOptions.useChunkMaskWorker, + ); + + if (!chunkMaskSequence) { + return { + ...snapshot, + particles, + warmMaskStrategy: WARM_CHUNK_MASK_STRATEGY, + }; + } + + return { + ...snapshot, + particles, + chunkMaskSequence, + warmMaskStrategy: WARM_CHUNK_MASK_STRATEGY, + }; +}; + +export const revokeWarmChunkMasks = ( + snapshot: DisintegrateCaptureSnapshot | null | undefined, +): void => { + snapshot?.chunkMaskSequence?.revoke(); +}; + +export type { ChunkMaskSequence }; diff --git a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts index 085e43ec..6378e807 100644 --- a/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts @@ -1,4 +1,5 @@ import { prefersReducedMotion } from "#/shared/lib/prefersReducedMotion"; +import { createActiveParticleTracker } from "#/shared/ui/effects/domDisintegrate/activeParticles"; import { assignParticleReleaseTimes, resolveEffectiveMaskStrategy, @@ -24,6 +25,7 @@ import { subscribeToViewportChanges, syncFixedOverlayToElement, } from "#/shared/ui/effects/domDisintegrate/overlayPosition"; +import { canReuseWarmChunkMasks } from "#/shared/ui/effects/domDisintegrate/prebuildWarmChunkMasks"; import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import { resolveRelativeOrigin } from "#/shared/ui/effects/domDisintegrate/resolveRelativeOrigin"; import { stepParticles } from "#/shared/ui/effects/domDisintegrate/stepParticles"; @@ -201,7 +203,20 @@ export const runDomDisintegrate = async ( disintegrateOptions: resolvedOptions, }); + const { displayWidth, displayHeight, sourceCanvas } = snapshot; + const relativeOrigin = resolveRelativeOrigin(element, options?.origin); + const effectiveMaskStrategy = resolveEffectiveMaskStrategy( + disintegrateOptions?.maskStrategy, + relativeOrigin !== null, + ); + const reuseWarmChunkMasks = canReuseWarmChunkMasks( + snapshot, + resolvedOptions.maskMode, + effectiveMaskStrategy, + ); + const particles = cloneDisintegrateParticles(snapshot.particles); + if (particles.length === 0) { throw new DomDisintegrateError( "no_particles", @@ -209,32 +224,36 @@ export const runDomDisintegrate = async ( ); } - const { displayWidth, displayHeight, sourceCanvas } = snapshot; - const relativeOrigin = resolveRelativeOrigin(element, options?.origin); - const effectiveMaskStrategy = resolveEffectiveMaskStrategy( - disintegrateOptions?.maskStrategy, - relativeOrigin !== null, - ); - const maxReleaseTime = assignParticleReleaseTimes(particles, { - strategy: effectiveMaskStrategy, - displayWidth, - displayHeight, - particleStep: resolvedOptions.particleStep, - maskSpreadDuration: resolvedOptions.maskSpreadDuration, - waveOrigin: relativeOrigin, - waveSpeed: resolvedOptions.waveSpeed, - }); + let maxReleaseTime = 0; + if (!reuseWarmChunkMasks) { + maxReleaseTime = assignParticleReleaseTimes(particles, { + strategy: effectiveMaskStrategy, + displayWidth, + displayHeight, + particleStep: resolvedOptions.particleStep, + maskSpreadDuration: resolvedOptions.maskSpreadDuration, + waveOrigin: relativeOrigin, + waveSpeed: resolvedOptions.waveSpeed, + }); + } else { + for (const particle of particles) { + maxReleaseTime = Math.max(maxReleaseTime, particle.releaseTime); + } + } const totalDurationSeconds = maxReleaseTime + resolvedOptions.maxDuration; const particlePadding = getParticleCanvasPadding(resolvedOptions); const particleRevealMargin = getParticleRevealMargin(resolvedOptions); const maskState: DualLayerMaskState = { - chunkMaskSequence: null, + chunkMaskSequence: reuseWarmChunkMasks + ? (snapshot.chunkMaskSequence ?? null) + : null, startedRadialFallback: false, }; let isAnimationActive = true; + let ownsChunkMaskSequence = !reuseWarmChunkMasks; - if (resolvedOptions.maskMode === "chunks") { + if (resolvedOptions.maskMode === "chunks" && !reuseWarmChunkMasks) { void buildChunkMaskSequenceAsync( { particles, @@ -258,9 +277,12 @@ export const runDomDisintegrate = async ( } maskState.chunkMaskSequence = sequence; + ownsChunkMaskSequence = true; }); } + const particleTracker = createActiveParticleTracker(particles); + const canvasWidth = displayWidth + particlePadding * 2; const canvasHeight = displayHeight + particlePadding * 2; const particleZIndex = Math.max(1, resolvedOptions.zIndex - 5); @@ -338,9 +360,11 @@ export const runDomDisintegrate = async ( particlePadding, maskState, ); + particleTracker.syncReleased(elapsedSeconds); + const activeParticles = particleTracker.getActive(); drawDisintegrationFrame( overlayContext, - particles, + activeParticles, elapsedSeconds, canvasWidth, canvasHeight, @@ -351,12 +375,14 @@ export const runDomDisintegrate = async ( }, drawState, ); - const visibleCount = stepParticles( - particles, + stepParticles( + activeParticles, deltaSeconds, elapsedSeconds, resolvedOptions, ); + particleTracker.removeDead(); + const visibleCount = particleTracker.getVisibleCount(); if (visibleCount === 0 || elapsedSeconds >= totalDurationSeconds) { completedSuccessfully = true; @@ -376,7 +402,9 @@ export const runDomDisintegrate = async ( }); } finally { isAnimationActive = false; - maskState.chunkMaskSequence?.revoke(); + if (ownsChunkMaskSequence) { + maskState.chunkMaskSequence?.revoke(); + } maskState.chunkMaskSequence = null; restoreElement({ restoreOpacity: !completedSuccessfully }); unsubscribeViewportChanges(); diff --git a/src/shared/ui/effects/domDisintegrate/sampleCanvasForParticles.ts b/src/shared/ui/effects/domDisintegrate/sampleCanvasForParticles.ts new file mode 100644 index 00000000..37d25058 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/sampleCanvasForParticles.ts @@ -0,0 +1,71 @@ +import { createParticlesFromImageData } from "#/shared/ui/effects/domDisintegrate/createParticlesFromImageData"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import type { + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +/** + * Samples the canvas on a particle grid using a downscaled read so getImageData + * touches one pixel per particle cell instead of the full surface. + */ +export const sampleCanvasForParticles = ( + canvas: HTMLCanvasElement, + options?: DomDisintegrateOptions, +): DisintegrateParticle[] | null => { + const resolvedOptions = resolveDomDisintegrateOptions(options); + const { particleStep } = resolvedOptions; + const sampleColumns = Math.max(1, Math.ceil(canvas.width / particleStep)); + const sampleRows = Math.max(1, Math.ceil(canvas.height / particleStep)); + + if ( + sampleColumns === canvas.width && + sampleRows === canvas.height && + particleStep === 1 + ) { + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + return null; + } + + try { + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const particles = createParticlesFromImageData( + imageData, + resolvedOptions, + ); + return particles.length > 0 ? particles : null; + } catch { + return null; + } + } + + const sampleCanvas = document.createElement("canvas"); + sampleCanvas.width = sampleColumns; + sampleCanvas.height = sampleRows; + + const sampleContext = sampleCanvas.getContext("2d", { + willReadFrequently: true, + }); + if (!sampleContext) { + return null; + } + + try { + sampleContext.drawImage(canvas, 0, 0, sampleColumns, sampleRows); + const imageData = sampleContext.getImageData( + 0, + 0, + sampleColumns, + sampleRows, + ); + const particles = createParticlesFromImageData( + imageData, + resolvedOptions, + particleStep, + ); + return particles.length > 0 ? particles : null; + } catch { + return null; + } +}; diff --git a/src/shared/ui/effects/domDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts index 32db5caa..c86100cc 100644 --- a/src/shared/ui/effects/domDisintegrate/stepParticles.ts +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -83,16 +83,13 @@ const stepSparkParticle = ( /** Advances particle physics using delta time (stable on high-refresh displays). */ export const stepParticles = ( - particles: DisintegrateParticle[], + particles: readonly DisintegrateParticle[], deltaSeconds: number, elapsedSeconds: number, resolvedOptions: ResolvedDomDisintegrateOptions, -): number => { - let visibleCount = 0; - +): void => { for (const particle of particles) { if (elapsedSeconds < particle.releaseTime) { - visibleCount += 1; continue; } @@ -122,9 +119,5 @@ export const stepParticles = ( } particle.rotation += particle.rotationSpeed * deltaSeconds; - - visibleCount += 1; } - - return visibleCount; }; diff --git a/src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts b/src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts index 1ea46e33..d4d6eaef 100644 --- a/src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts @@ -4,7 +4,10 @@ import type { DisintegrateCaptureSnapshot } from "#/shared/ui/effects/domDisinte import { DomDisintegrateError } from "#/shared/ui/effects/domDisintegrate/domDisintegrateError"; import { runDomDisintegrate } from "#/shared/ui/effects/domDisintegrate/runDomDisintegrate"; import type { RunDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/runDomDisintegrate"; -import { warmDisintegrateCapture } from "#/shared/ui/effects/domDisintegrate/warmDisintegrateCapture"; +import { + revokeWarmChunkMasks, + warmDisintegrateCapture, +} from "#/shared/ui/effects/domDisintegrate/warmDisintegrateCapture"; type UseDomDisintegrateResult = { /** Attach to the visual surface that should crumble away. */ @@ -26,6 +29,7 @@ export const useDomDisintegrate = (): UseDomDisintegrateResult => { const cancelWarmRef = useRef<(() => void) | null>(null); const invalidateCapture = useCallback(() => { + revokeWarmChunkMasks(captureCacheRef.current); captureCacheRef.current = null; const element = elementRef.current; if (!element) { @@ -39,8 +43,9 @@ export const useDomDisintegrate = (): UseDomDisintegrateResult => { const targetRef = useCallback((node: HTMLDivElement | null) => { cancelWarmRef.current?.(); cancelWarmRef.current = null; - elementRef.current = node; + revokeWarmChunkMasks(captureCacheRef.current); captureCacheRef.current = null; + elementRef.current = node; if (node) { // Chunk mask workers are created lazily when maskMode is "chunks". @@ -100,6 +105,7 @@ export const useDomDisintegrate = (): UseDomDisintegrateResult => { }); } finally { isAnimatingRef.current = false; + revokeWarmChunkMasks(captureCacheRef.current); captureCacheRef.current = null; } }, diff --git a/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts index 00d14262..c37603ec 100644 --- a/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts +++ b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts @@ -3,6 +3,10 @@ import type { RefObject } from "react"; import { prewarmChunkMaskWorker } from "#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"; import { buildDisintegrateCapture } from "#/shared/ui/effects/domDisintegrate/buildDisintegrateCapture"; import type { DisintegrateCaptureSnapshot } from "#/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot"; +import { + prebuildWarmChunkMasks, + revokeWarmChunkMasks, +} from "#/shared/ui/effects/domDisintegrate/prebuildWarmChunkMasks"; import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; import type { DomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/types"; @@ -57,9 +61,13 @@ export const warmDisintegrateCapture = ( mode: "quality", disintegrateOptions: resolvedOptions, }) + .then((snapshot) => prebuildWarmChunkMasks(snapshot, resolvedOptions)) .then((snapshot) => { if (!cancelled) { + revokeWarmChunkMasks(cacheRef.current); cacheRef.current = snapshot; + } else { + revokeWarmChunkMasks(snapshot); } }) .catch(() => { @@ -72,3 +80,5 @@ export const warmDisintegrateCapture = ( cancelIdle(idleHandle); }; }; + +export { revokeWarmChunkMasks }; From 471d6688340dc45c82eefaf5b63428073463e3f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 07:55:19 +0000 Subject: [PATCH 20/21] test(dom-disintegrate): stabilize windy turbulence divergence assertion Use identical starting state and only vary turbulence seed/profile so random launch velocities cannot make the separation threshold flaky. --- .../__tests__/stepParticles.test.ts | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts index 9e94c1cb..b43562c2 100644 --- a/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import { getSparkLiftFactor } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { createSparkTurbulenceProfile } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; import { stepParticles } from "#/shared/ui/effects/domDisintegrate/stepParticles"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; const windyOptions = resolveDomDisintegrateOptions({ particleMotionMode: "windy", @@ -15,6 +18,40 @@ const splatOptions = ( overrides: Parameters[0], ) => resolveDomDisintegrateOptions(overrides); +const createDeterministicWindyParticle = ( + turbulenceSeed: number, +): DisintegrateParticle => { + const profile = createSparkTurbulenceProfile(turbulenceSeed); + const particle = createDisintegrateParticle({ + x: 50, + y: 40, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 120, + surfaceHeight: 60, + options: windyOptions, + }); + + particle.x = 50; + particle.y = 40; + particle.originX = 50; + particle.originY = 40; + particle.vx = 8; + particle.vy = -35; + particle.drag = 0.97; + particle.releaseTime = 0; + particle.fadeStart = 1; + particle.fadeDuration = 10; + particle.turbulenceSeed = turbulenceSeed; + particle.turbulenceInfluence = profile.influence; + particle.turbulenceFrequency = profile.frequency; + particle.turbulencePhase = profile.phase; + particle.turbulenceNoiseScale = profile.noiseScale; + particle.sparkLiftFactor = getSparkLiftFactor(turbulenceSeed); + + return particle; +}; + describe("stepParticles", () => { it("uses delta time so motion is independent of frame rate", () => { const particle = createDisintegrateParticle({ @@ -84,34 +121,14 @@ describe("stepParticles", () => { }); it("diverges windy particles with different turbulence seeds", () => { - const first = createDisintegrateParticle({ - x: 20, - y: 40, - color: "rgb(255, 255, 255)", - alpha: 1, - surfaceWidth: 120, - surfaceHeight: 60, - options: windyOptions, - }); - const second = createDisintegrateParticle({ - x: 90, - y: 40, - color: "rgb(255, 255, 255)", - alpha: 1, - surfaceWidth: 120, - surfaceHeight: 60, - options: windyOptions, - }); - first.releaseTime = 0; - second.releaseTime = 0; - first.turbulenceSeed = 11; - second.turbulenceSeed = 907; + const first = createDeterministicWindyParticle(11); + const second = createDeterministicWindyParticle(907); for (let frame = 1; frame <= 120; frame += 1) { stepParticles([first, second], 1 / 60, frame / 60, windyOptions); } - expect(Math.abs(first.x - second.x)).toBeGreaterThan(45); + expect(Math.abs(first.x - second.x)).toBeGreaterThan(15); }); it("sways more laterally after rising farther", () => { From 9b9ec2f0d9269514ec3ad92270d910dbff252504 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 08:03:39 +0000 Subject: [PATCH 21/21] feat(cookie-consent): use splat particle motion for dismiss effect Prefer the original radial burst over windy sparks for the banner disintegrate animation. --- .../cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx | 1 + .../ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx b/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx index 5a75ed37..03155765 100644 --- a/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx +++ b/src/features/cookieConsent/ui/CookieConsentBannerWithDismissEffect.tsx @@ -68,6 +68,7 @@ export const CookieConsentBannerWithDismissEffect: React.FC< clientY: event.clientY, }, maskMode: "radial", + particleMotionMode: "splat", zIndex: 1200, }); } catch (error) { diff --git a/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx b/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx index e9e569a1..cd557e1e 100644 --- a/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx +++ b/src/features/cookieConsent/ui/__tests__/CookieConsentBannerWithDismissEffect.test.tsx @@ -95,6 +95,7 @@ describe("CookieConsentBannerWithDismissEffect", () => { expect(disintegrateMock).toHaveBeenCalledWith( expect.objectContaining({ maskMode: "radial", + particleMotionMode: "splat", origin: expect.objectContaining({ clientX: expect.any(Number), clientY: expect.any(Number),