diff --git a/.cursor/commands/deep-code-review.md b/.cursor/commands/deep-code-review.md deleted file mode 100644 index a0f1ac5f..00000000 --- a/.cursor/commands/deep-code-review.md +++ /dev/null @@ -1,3 +0,0 @@ -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. diff --git a/.cursor/skills/deep-code-review/SKILL.md b/.cursor/skills/deep-code-review/SKILL.md new file mode 100644 index 00000000..7b3cde9a --- /dev/null +++ b/.cursor/skills/deep-code-review/SKILL.md @@ -0,0 +1,78 @@ +--- +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 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.** 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 + +Copy and track progress: + +``` +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 +``` + +**Scope** = current branch changes, open PR diff, or files the user named. Do not expand scope unless needed to fix a finding safely. + +## Fix immediately (non-negotiable) + +Implement before finishing — never defer these to a follow-up: + +| 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 | + +**Low** (subjective style, optional refactors): report only, unless trivial (<5 min). When unsure between medium and low → **medium, fix it**. + +## Review focus + +- **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 + +## Verification + +- `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 + +## Output template + +```markdown +## Fixes applied +- [Dead code / dedup / behavior] — what changed and why + +## Left unchanged +- [Low-priority or out-of-scope item] — brief rationale + +## Verification +- Tests: … +- Lint: … +``` + +Use code citations for non-obvious changes. Keep prose concise. + +## Example + +**Finding:** Two modules duplicate the same `hash01` helper; an exported `prewarmWorker()` is never called. + +**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 hash helper. Wired dead prewarm export into idle setup. Clarified misleading option JSDoc. +> +> **Verification** — Targeted tests pass; ESLint clean on touched files. diff --git a/.cursorrules b/.cursorrules index 6d26adc4..31d436e9 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/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 2ea0c33e..a0c0f82b 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). +**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 ### Services overview 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..03155765 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, @@ -69,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 cb9f1c4f..cd557e1e 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(), @@ -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), 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__/applyWaveOrigin.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts new file mode 100644 index 00000000..328d97ae --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/applyWaveOrigin.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; +import { applyWaveOrigin } from "#/shared/ui/effects/domDisintegrate/applyWaveOrigin"; + +describe("applyWaveOrigin", () => { + it("delays particles farther from the origin", () => { + 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); + + expect(particles[0]?.releaseTime).toBeGreaterThanOrEqual(0); + expect(particles[0]?.releaseTime).toBeLessThan(0.05); + expect(particles[1]?.releaseTime).toBeGreaterThan(2.5); + expect(maxReleaseTime).toBe(particles[1]?.releaseTime); + }); +}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts similarity index 69% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts index bbdc5ba0..50d10911 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/assignParticleReleaseTimes.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/assignParticleReleaseTimes.test.ts @@ -1,32 +1,10 @@ import { describe, expect, it } from "vitest"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { assignParticleReleaseTimes, resolveEffectiveMaskStrategy, -} from "#/shared/ui/effects/thanosDisintegrate/assignParticleReleaseTimes"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; - -const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ - 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, - ...overrides, -}); +} from "#/shared/ui/effects/domDisintegrate/assignParticleReleaseTimes"; describe("resolveEffectiveMaskStrategy", () => { it("defaults to wave when a click origin is present", () => { @@ -45,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, { @@ -65,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/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts similarity index 80% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/buildChunkMaskSequenceAsync.test.ts index 985940d2..48ebeb82 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 { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; const workerInstances: MockWorker[] = []; @@ -52,32 +52,10 @@ class MockWorker { vi.stubGlobal("Worker", MockWorker as unknown as typeof Worker); const { buildChunkMaskSequenceAsync, terminateChunkMaskWorker } = - await import("#/shared/ui/effects/thanosDisintegrate/buildChunkMaskSequenceAsync"); - -const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ - 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, - ...overrides, -}); + await import("#/shared/ui/effects/domDisintegrate/buildChunkMaskSequenceAsync"); 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/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 75% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts index 48116cc8..c7b7214d 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/createChunkMaskSequence.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createChunkMaskSequence.test.ts @@ -1,43 +1,21 @@ import { describe, expect, it, vi } from "vitest"; +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; import { buildChunkMaskThresholds, createChunkMaskSequence, getChunkMaskIndex, revealParticleChunk, revealParticleMaskBleedZone, -} from "#/shared/ui/effects/thanosDisintegrate/createChunkMaskSequence"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; - -const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ - 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, - ...overrides, -}); +} from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; 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); @@ -55,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__/createDisintegrateParticle.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts new file mode 100644 index 00000000..e3a241ff --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createDisintegrateParticle.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; + +const baseInput = { + x: 50, + y: 25, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 100, + surfaceHeight: 50, +}; + +describe("createDisintegrateParticle", () => { + it("biases splat particles away from the surface center", () => { + const particle = createDisintegrateParticle({ + ...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("launches windy particles upward like fire sparks", () => { + const particles = Array.from({ length: 24 }, () => + createDisintegrateParticle({ + ...baseInput, + options: { + particleMotionMode: "windy", + maxVelocity: 100, + windX: 14, + windY: -8, + }, + }), + ); + + const averageVelocityY = + particles.reduce((sum, particle) => sum + particle.vy, 0) / + particles.length; + const averageSpeed = + particles.reduce( + (sum, particle) => sum + Math.hypot(particle.vx, particle.vy), + 0, + ) / particles.length; + + 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/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 57% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/createParticlesFromImageData.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/createParticlesFromImageData.test.ts index cea72154..b898a4ed 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", () => { @@ -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__/createTestDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts new file mode 100644 index 00000000..1f2e6ad6 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle.ts @@ -0,0 +1,36 @@ +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 => ({ + 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: 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__/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/domDisintegrate/__tests__/domDisintegrateQuality.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts new file mode 100644 index 00000000..9369f39a --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/domDisintegrateQuality.test.ts @@ -0,0 +1,154 @@ +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 high tier for modern mobile (e.g. Nothing Phone 3 class)", () => { + expect( + detectDomDisintegrateQualityTier( + profile({ + hardwareConcurrency: 8, + deviceMemory: 8, + 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"); + }); + + 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("keeps full quality on flagship mobile (no surface step bump)", () => { + const overrides = getDomDisintegrateQualityOverrides( + { displayWidth: 400, displayHeight: 96 }, + profile({ + hardwareConcurrency: 8, + deviceMemory: 8, + coarsePointer: true, + }), + ); + + 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); + }); + + it("reads device profile from navigator when profile is omitted", () => { + setNavigator({ hardwareConcurrency: 8, deviceMemory: 8 }); + setMatchMedia({ "(pointer: coarse)": true }); + + const overrides = getDomDisintegrateQualityOverrides({ + displayWidth: 400, + displayHeight: 96, + }); + + expect(overrides.particleStep).toBe(3); + expect(overrides.maxDuration).toBe(1); + }); +}); diff --git a/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts new file mode 100644 index 00000000..d582a459 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/drawDisintegrationFrame.test.ts @@ -0,0 +1,86 @@ +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 = createMockContext({ fillRect }); + + const particles = [ + createTestDisintegrateParticle({ releaseTime: 0.5 }), + createTestDisintegrateParticle({ releaseTime: 0.2, x: 1, y: 1 }), + ]; + + drawDisintegrationFrame(context, particles, 0, 6, 6); + + expect(fillRect).not.toHaveBeenCalled(); + }); + + it("draws released particles after the wave reaches them", () => { + const fillRect = vi.fn(); + const context = createMockContext({ fillRect }); + + const particles = [ + createTestDisintegrateParticle({ releaseTime: 0, x: 1, y: 1 }), + ]; + + drawDisintegrationFrame(context, particles, 0.1, 6, 6); + + expect(fillRect).toHaveBeenCalled(); + }); + + it("offsets drawing when the canvas has bleed padding", () => { + const fillRect = vi.fn(); + const context = createMockContext({ fillRect }); + + const particles = [ + createTestDisintegrateParticle({ releaseTime: 0, x: 4, y: 6, size: 2 }), + ]; + + drawDisintegrationFrame(context, particles, 0.1, 20, 20, 8); + + expect(fillRect).toHaveBeenCalledWith(12, 14, 2, 2); + }); + + it("draws snapshot sprites when sprite mode is enabled", () => { + const drawImage = vi.fn(); + const setTransform = vi.fn(); + const context = createMockContext({ drawImage, setTransform }); + + const sourceCanvas = document.createElement("canvas"); + const particles = [ + createTestDisintegrateParticle({ + releaseTime: 0, + x: 2, + y: 2, + originX: 4, + originY: 6, + rotation: 0.4, + }), + ]; + + drawDisintegrationFrame(context, particles, 0.1, 20, 20, 0, { + renderMode: "sprite", + sourceCanvas, + }); + + expect(drawImage).toHaveBeenCalled(); + expect(setTransform).toHaveBeenCalled(); + expect(context.fillRect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts similarity index 57% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts index aae506db..dad6fb4c 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/getParticleCanvasPadding.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/getParticleCanvasPadding.test.ts @@ -1,21 +1,32 @@ 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); }); + 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", maxVelocity: 40, gravity: 40, maxDuration: 0.5, @@ -27,7 +38,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/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts new file mode 100644 index 00000000..f91c060e --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { createTestDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"; +import { getWaveDisintegrationProgress } from "#/shared/ui/effects/domDisintegrate/getWaveDisintegrationProgress"; + +describe("getWaveDisintegrationProgress", () => { + it("ramps from 0 before the wave to 1 after all particles release", () => { + 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/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 67% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/normalizeCaptureToDisplay.test.ts index 4067b6b3..1843d205 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, @@ -36,6 +36,12 @@ describe("scaleParticleCoordinates", () => { fadeStart: 0.5, 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/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/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/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/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/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 77% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts index 6708f7a6..78d6428c 100644 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/runThanosDisintegrate.test.ts +++ b/src/shared/ui/effects/domDisintegrate/__tests__/runDomDisintegrate.test.ts @@ -4,44 +4,30 @@ 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"); - -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, -}); + 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 { createTestDisintegrateParticle } = + await import("#/shared/ui/effects/domDisintegrate/__tests__/createTestDisintegrateParticle"); + +const createParticle = () => + createTestDisintegrateParticle({ color: "rgb(100, 120, 140)" }); const createElement = () => { const element = document.createElement("div"); @@ -61,7 +47,7 @@ const createElement = () => { return element; }; -describe("runThanosDisintegrate", () => { +describe("runDomDisintegrate", () => { afterEach(() => { document.body.innerHTML = ""; vi.clearAllMocks(); @@ -78,19 +64,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, @@ -98,9 +84,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), }); @@ -108,7 +94,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, @@ -117,10 +103,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 () => { @@ -134,7 +120,7 @@ describe("runThanosDisintegrate", () => { }; await expect( - runThanosDisintegrate(element, { + runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, maskMode: "chunks", @@ -146,7 +132,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", @@ -164,7 +150,7 @@ describe("runThanosDisintegrate", () => { displayHeight: 48, }; - const animation = runThanosDisintegrate(element, { + const animation = runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, maskMode: "chunks", @@ -205,7 +191,7 @@ describe("runThanosDisintegrate", () => { displayHeight: 48, }; - const animation = runThanosDisintegrate(element, { + const animation = runDomDisintegrate(element, { captureSnapshot: snapshot, origin: { x: 60, y: 24 }, maskMode: "chunks", @@ -221,9 +207,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", @@ -254,7 +240,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/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__/sparkTurbulence.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts new file mode 100644 index 00000000..ba76384b --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/sparkTurbulence.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + createSparkTurbulenceProfile, + sampleSparkTurbulence, +} from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; + +describe("sampleSparkTurbulence", () => { + it("varies forces per particle seed and spawn position", () => { + 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); + expect(first.forceX).not.toBeCloseTo(second.forceX, 2); + }); + + it("ramps lateral sway with vertical travel", () => { + 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 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 new file mode 100644 index 00000000..b43562c2 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/__tests__/stepParticles.test.ts @@ -0,0 +1,215 @@ +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", + maxVelocity: 80, + windX: 12, + windY: -6, +}); + +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({ + x: 10, + y: 10, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 100, + surfaceHeight: 40, + options: { + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + maxVelocity: 0, + }, + }); + particle.releaseTime = 0; + particle.vx = 60; + particle.vy = 0; + particle.drag = 1; + + stepParticles( + [particle], + 1 / 60, + 1 / 60, + splatOptions({ + particleMotionMode: "splat", + gravity: 0, + windX: 0, + windY: 0, + }), + ); + const positionAfterOneFrame = particle.x; + + const secondParticle = { + ...particle, + x: 10, + y: 10, + vx: 60, + vy: 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); + }); + + it("diverges windy particles with different turbulence seeds", () => { + 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(15); + }); + + it("sways more laterally after rising farther", () => { + const particle = createDisintegrateParticle({ + x: 20, + y: 40, + color: "rgb(255, 255, 255)", + alpha: 1, + surfaceWidth: 120, + surfaceHeight: 60, + options: windyOptions, + }); + particle.releaseTime = 0; + particle.vx = 4; + particle.vy = -42; + particle.drag = 0.97; + particle.turbulenceSeed = 33; + + 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; + } + } + + 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, + resolveDomDisintegrateOptions({ + 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/thanosDisintegrate/__tests__/syncElementWaveMask.test.ts b/src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts similarity index 82% rename from src/shared/ui/effects/thanosDisintegrate/__tests__/syncElementWaveMask.test.ts rename to src/shared/ui/effects/domDisintegrate/__tests__/syncElementWaveMask.test.ts index 66695f3a..e20cc827 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(() => { @@ -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/__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/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/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/domDisintegrate/buildDisintegrateCapture.ts b/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts new file mode 100644 index 00000000..6e6f4512 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/buildDisintegrateCapture.ts @@ -0,0 +1,60 @@ +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 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"; + +/** Builds particles + optional snapshot canvas for the disintegration effect. */ +export const buildDisintegrateCapture = async ( + element: HTMLElement, + { mode, disintegrateOptions }: BuildDisintegrateCaptureOptions, +): Promise => { + if (mode === "quality") { + await waitForDocumentFonts(); + } + + const { displayWidth, displayHeight } = getElementDisplaySize(element); + + let rawCanvas: HTMLCanvasElement | null = null; + try { + rawCanvas = + mode === "quality" + ? await captureElementViaSnapdom(element) + : await captureElementViaSvgForeignObject(element); + } catch { + rawCanvas = null; + } + + let sourceCanvas: HTMLCanvasElement | null = null; + let particles = null; + + if (rawCanvas) { + sourceCanvas = normalizeCaptureToDisplay( + rawCanvas, + displayWidth, + displayHeight, + ); + particles = sampleCanvasForParticles(sourceCanvas, disintegrateOptions); + } + + if (!particles) { + sourceCanvas = null; + particles = createFallbackParticlesFromElement( + element, + disintegrateOptions, + ); + } + + return { + sourceCanvas, + particles, + displayWidth, + displayHeight, + }; +}; 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/domDisintegrate/constants.ts b/src/shared/ui/effects/domDisintegrate/constants.ts new file mode 100644 index 00000000..7ea47908 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/constants.ts @@ -0,0 +1,19 @@ +import type { ResolvedDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/types"; + +export const DOM_DISINTEGRATE_DEFAULTS = { + particleStep: 3, + particleSize: 2, + maxVelocity: 165, + windX: 14, + windY: -8, + gravity: 240, + maxDuration: 1.0, + zIndex: 9999, + waveSpeed: 700, + maskMode: "chunks", + maxChunkMaskSteps: 96, + maskSpreadDuration: 0.6, + particleRenderMode: "color", + useChunkMaskWorker: true, + particleMotionMode: "windy", +} 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/domDisintegrate/createDisintegrateParticle.ts b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts new file mode 100644 index 00000000..c0c9d2e9 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/createDisintegrateParticle.ts @@ -0,0 +1,118 @@ +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import { + getParticleMotionProfile, + getSparkDriftDirection, + getSparkLiftFactor, +} from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { createSparkTurbulenceProfile } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; +import type { + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +type CreateParticleInput = { + x: number; + y: number; + color: string; + alpha: number; + surfaceWidth: number; + surfaceHeight: number; + options?: DomDisintegrateOptions; +}; + +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; + const deltaY = y - centerY; + const distanceFromCenter = Math.hypot(deltaX, deltaY); + const baseAngle = + distanceFromCenter > 0.5 + ? Math.atan2(deltaY, deltaX) + : Math.random() * Math.PI * 2; + const spread = (Math.random() - 0.5) * 1.2; + 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 createSparkVelocity = ( + resolvedOptions: ReturnType, +): { vx: number; vy: number } => { + 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 + + (Math.random() - 0.5) * resolvedOptions.maxVelocity * 0.16, + vy: + Math.sin(launchAngle) * speed - + resolvedOptions.maxVelocity * (0.03 + Math.random() * 0.1), + }; +}; + +/** Creates one particle with outward-biased velocity, drag, rotation, and fade timing. */ +export const createDisintegrateParticle = ({ + x, + y, + color, + alpha, + surfaceWidth, + surfaceHeight, + options, +}: CreateParticleInput): DisintegrateParticle => { + const resolvedOptions = resolveDomDisintegrateOptions(options); + const motionProfile = getParticleMotionProfile( + resolvedOptions.particleMotionMode, + ); + const velocity = + resolvedOptions.particleMotionMode === "windy" + ? createSparkVelocity(resolvedOptions) + : createSplatVelocity(x, y, surfaceWidth, surfaceHeight, resolvedOptions); + const turbulenceSeed = Math.random() * 1000; + const turbulenceProfile = createSparkTurbulenceProfile(turbulenceSeed); + + return { + x, + y, + originX: x, + originY: y, + 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) * 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, + turbulenceInfluence: turbulenceProfile.influence, + turbulenceFrequency: turbulenceProfile.frequency, + turbulencePhase: turbulenceProfile.phase, + turbulenceNoiseScale: turbulenceProfile.noiseScale, + sparkLiftFactor: getSparkLiftFactor(turbulenceSeed), + }; +}; 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/domDisintegrate/createParticlesFromImageData.ts b/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts new file mode 100644 index 00000000..a0d42c88 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/createParticlesFromImageData.ts @@ -0,0 +1,45 @@ +import { createDisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/createDisintegrateParticle"; +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import type { + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +export const createParticlesFromImageData = ( + imageData: ImageData, + options?: DomDisintegrateOptions, + coordinateScale = 1, +): DisintegrateParticle[] => { + const resolvedOptions = resolveDomDisintegrateOptions(options); + const particles: DisintegrateParticle[] = []; + const { data, width, height } = imageData; + const particleStep = coordinateScale > 1 ? 1 : resolvedOptions.particleStep; + + for (let y = 0; y < height; y += particleStep) { + for (let x = 0; x < width; x += particleStep) { + const index = (y * width + x) * 4; + const alphaChannel = data[index + 3] ?? 0; + if (alphaChannel < 12) { + continue; + } + + const red = data[index] ?? 0; + const green = data[index + 1] ?? 0; + const blue = data[index + 2] ?? 0; + + particles.push( + createDisintegrateParticle({ + x: x * coordinateScale, + y: y * coordinateScale, + color: `rgb(${red}, ${green}, ${blue})`, + alpha: alphaChannel / 255, + surfaceWidth: width * coordinateScale, + surfaceHeight: height * coordinateScale, + options: resolvedOptions, + }), + ); + } + } + + return particles; +}; diff --git a/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts b/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts new file mode 100644 index 00000000..7a7670c4 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/disintegrateCaptureSnapshot.ts @@ -0,0 +1,52 @@ +import type { ChunkMaskSequence } from "#/shared/ui/effects/domDisintegrate/createChunkMaskSequence"; +import type { + DisintegrateMaskStrategy, + DisintegrateParticle, + DomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +export type DisintegrateCaptureSnapshot = { + sourceCanvas: HTMLCanvasElement | null; + particles: DisintegrateParticle[]; + displayWidth: number; + displayHeight: number; + /** Pre-built during idle warm-up when maskMode is chunks. */ + chunkMaskSequence?: ChunkMaskSequence | null; + warmMaskStrategy?: DisintegrateMaskStrategy; +}; + +export const cloneDisintegrateParticles = ( + particles: DisintegrateParticle[], +): DisintegrateParticle[] => particles.map((particle) => ({ ...particle })); + +export const isDisintegrateCaptureSnapshotValid = ( + snapshot: DisintegrateCaptureSnapshot, + element: HTMLElement, +): boolean => { + const rect = element.getBoundingClientRect(); + const width = Math.max(1, Math.round(rect.width)); + const height = Math.max(1, Math.round(rect.height)); + + return ( + snapshot.displayWidth === width && + snapshot.displayHeight === height && + snapshot.particles.length > 0 + ); +}; + +export const getElementDisplaySize = ( + element: HTMLElement, +): { displayWidth: number; displayHeight: number } => { + const rect = element.getBoundingClientRect(); + return { + displayWidth: Math.max(1, Math.round(rect.width)), + displayHeight: Math.max(1, Math.round(rect.height)), + }; +}; + +export type BuildDisintegrateCaptureMode = "fast" | "quality"; + +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/domDisintegrate/domDisintegrateQuality.ts b/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts new file mode 100644 index 00000000..9818ed2b --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/domDisintegrateQuality.ts @@ -0,0 +1,145 @@ +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"; + } + + 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 (cores >= 4 && (memory === undefined || memory >= 4)) { + return "high"; + } + + if (cores <= 2) { + return "low"; + } + if (cores <= 4 && memory !== undefined && memory <= 2) { + return "medium"; + } + if (memory !== undefined && memory <= 2) { + return "low"; + } + + return "medium"; +}; + +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]; + const baseParticleStep = tierBase.particleStep ?? 3; + + return { + ...tierBase, + particleStep: + tier === "high" + ? baseParticleStep + : resolveParticleStepForSurface( + context.displayWidth, + context.displayHeight, + baseParticleStep, + TIER_MAX_GRID_CELLS[tier], + ), + }; +}; diff --git a/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts new file mode 100644 index 00000000..8bb4ca41 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/drawDisintegrationFrame.ts @@ -0,0 +1,166 @@ +import type { DisintegrateParticleRenderMode } from "#/shared/ui/effects/domDisintegrate/types"; +import type { DisintegrateParticle } from "#/shared/ui/effects/domDisintegrate/types"; + +export type DrawDisintegrationFrameOptions = { + renderMode?: DisintegrateParticleRenderMode; + 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; + +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: readonly DisintegrateParticle[], + elapsedSeconds: number, + canvasWidth: number, + canvasHeight: number, + originOffset = 0, + options?: DrawDisintegrationFrameOptions, + drawState?: DrawDisintegrationFrameState, +): void => { + 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; + } + + drawParticle(context, particle, originOffset, useSprites, sourceCanvas); + } + + 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 new file mode 100644 index 00000000..ea6a1b0a --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/getParticleCanvasPadding.ts @@ -0,0 +1,55 @@ +import { resolveDomDisintegrateOptions } from "#/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions"; +import { computeWindyParticleMaxTravel } from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import type { + DomDisintegrateOptions, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +const computeSplatParticleMaxTravel = ( + resolvedOptions: ResolvedDomDisintegrateOptions, +): number => { + const duration = resolvedOptions.maxDuration; + + return ( + resolvedOptions.maxVelocity * duration + + 0.5 * resolvedOptions.gravity * duration * duration + + Math.abs(resolvedOptions.windX) * duration + + Math.abs(resolvedOptions.windY) * duration + ); +}; + +const computeParticleMaxTravel = ( + resolvedOptions: ResolvedDomDisintegrateOptions, +): number => { + if (resolvedOptions.particleMotionMode === "windy") { + return computeWindyParticleMaxTravel(resolvedOptions); + } + + return computeSplatParticleMaxTravel(resolvedOptions); +}; + +/** Max distance a particle can travel from its origin during the animation. */ +export const getParticleMaxTravel = ( + 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 | ResolvedDomDisintegrateOptions, +): number => { + const resolvedOptions = resolveDomDisintegrateOptions(options); + return Math.ceil( + computeParticleMaxTravel(resolvedOptions) + resolvedOptions.particleSize, + ); +}; + +/** Extra canvas margin so flying particles are not clipped by the surface bounds. */ +export const getParticleCanvasPadding = ( + options?: DomDisintegrateOptions | ResolvedDomDisintegrateOptions, +): number => { + const resolvedOptions = resolveDomDisintegrateOptions(options); + return Math.ceil( + computeParticleMaxTravel(resolvedOptions) + + 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/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/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..1edd070d --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/resolveDomDisintegrateOptions.ts @@ -0,0 +1,23 @@ +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, +} from "#/shared/ui/effects/domDisintegrate/types"; + +export const resolveDomDisintegrateOptions = ( + options?: DomDisintegrateOptions, + 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/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 68% rename from src/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate.ts rename to src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts index e63e9de3..6378e807 100644 --- a/src/shared/ui/effects/thanosDisintegrate/runThanosDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/runDomDisintegrate.ts @@ -1,59 +1,62 @@ import { prefersReducedMotion } from "#/shared/lib/prefersReducedMotion"; +import { createActiveParticleTracker } from "#/shared/ui/effects/domDisintegrate/activeParticles"; 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 type { DrawDisintegrationFrameState } 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 { 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"; 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 +108,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,69 +170,90 @@ const clearActiveMasks = ( }; /** - * Plays a Thanos-style 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 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, + element, + ); 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); - if (particles.length === 0) { - throw new ThanosDisintegrateError( - "no_particles", - "Cannot disintegrate a surface with no sample particles.", - ); - } - 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, - }); + const reuseWarmChunkMasks = canReuseWarmChunkMasks( + snapshot, + resolvedOptions.maskMode, + effectiveMaskStrategy, + ); + + const particles = cloneDisintegrateParticles(snapshot.particles); + + if (particles.length === 0) { + throw new DomDisintegrateError( + "no_particles", + "Cannot disintegrate a surface with no sample particles.", + ); + } + + 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, @@ -253,9 +277,12 @@ export const runThanosDisintegrate = 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); @@ -273,7 +300,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.", ); @@ -292,6 +319,13 @@ export const runThanosDisintegrate = async ( ); let completedSuccessfully = false; + const drawState: DrawDisintegrationFrameState = { + left: 0, + top: 0, + right: 0, + bottom: 0, + initialized: false, + }; try { await new Promise((resolve, reject) => { @@ -314,7 +348,6 @@ export const runThanosDisintegrate = async ( ); lastTime = timestamp; - syncOverlayPosition(); syncDualLayerWave( element, overlayCanvas, @@ -327,9 +360,11 @@ export const runThanosDisintegrate = async ( particlePadding, maskState, ); + particleTracker.syncReleased(elapsedSeconds); + const activeParticles = particleTracker.getActive(); drawDisintegrationFrame( overlayContext, - particles, + activeParticles, elapsedSeconds, canvasWidth, canvasHeight, @@ -338,13 +373,16 @@ export const runThanosDisintegrate = async ( renderMode: resolvedOptions.particleRenderMode, sourceCanvas: spriteSourceCanvas, }, + drawState, ); - const visibleCount = stepParticles( - particles, + stepParticles( + activeParticles, deltaSeconds, elapsedSeconds, resolvedOptions, ); + particleTracker.removeDead(); + const visibleCount = particleTracker.getVisibleCount(); if (visibleCount === 0 || elapsedSeconds >= totalDurationSeconds) { completedSuccessfully = true; @@ -364,7 +402,9 @@ export const runThanosDisintegrate = 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/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/domDisintegrate/sparkParticlePhysics.ts b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts new file mode 100644 index 00000000..09bc4fe4 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/sparkParticlePhysics.ts @@ -0,0 +1,117 @@ +import { hashSparkSeed01 } from "#/shared/ui/effects/domDisintegrate/sparkSeedHash"; +import type { + DisintegrateParticleMotionMode, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +export const SPARK_TURBULENCE_STRENGTH = 195; +export const SPARK_TURBULENCE_MAX_LATERAL = 48; +/** 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; + dragRange: number; + rotationSpeed: number; + fadeStartMin: number; + fadeStartRange: number; + fadeDurationMin: number; + fadeDurationRange: number; +}; + +const PARTICLE_MOTION_PROFILES: Record< + DisintegrateParticleMotionMode, + 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.966, + dragRange: 0.02, + rotationSpeed: 11, + fadeStartMin: 0.38, + fadeStartRange: 0.24, + fadeDurationMin: 0.5, + fadeDurationRange: 0.38, + }, +}; + +/** Per-particle lift variation while sharing the same global wind field. */ +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; +}; + +/** + * 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/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 new file mode 100644 index 00000000..d1b96640 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/sparkTurbulence.ts @@ -0,0 +1,75 @@ +import { hashSparkSeed01 } from "#/shared/ui/effects/domDisintegrate/sparkSeedHash"; + +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; +}; + +export type SparkTurbulenceProfile = { + influence: number; + frequency: number; + phase: number; + noiseScale: number; +}; + +export const createSparkTurbulenceProfile = ( + turbulenceSeed: number, +): SparkTurbulenceProfile => ({ + 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, +}); + +/** + * 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, + profile: SparkTurbulenceProfile, +): SparkTurbulenceForces => { + 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: 0, + }; +}; diff --git a/src/shared/ui/effects/domDisintegrate/stepParticles.ts b/src/shared/ui/effects/domDisintegrate/stepParticles.ts new file mode 100644 index 00000000..c86100cc --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/stepParticles.ts @@ -0,0 +1,123 @@ +import { + SPARK_LIFT_ACCEL, + SPARK_MAX_DOWNWARD_VELOCITY, + SPARK_TURBULENCE_STRENGTH, + SPARK_WIND_ACCEL, +} from "#/shared/ui/effects/domDisintegrate/sparkParticlePhysics"; +import { sampleSparkTurbulence } from "#/shared/ui/effects/domDisintegrate/sparkTurbulence"; +import type { + DisintegrateParticle, + ResolvedDomDisintegrateOptions, +} from "#/shared/ui/effects/domDisintegrate/types"; + +const applyDrag = (velocity: number, drag: number, deltaSeconds: number) => + velocity * Math.pow(drag, deltaSeconds * 60); + +const updateFadeAlpha = ( + particle: DisintegrateParticle, + timeSinceRelease: number, +): void => { + const progress = timeSinceRelease / particle.fadeDuration; + if (progress <= particle.fadeStart) { + particle.alpha = particle.baseAlpha; + return; + } + + const fadeProgress = + (progress - particle.fadeStart) / Math.max(0.001, 1 - particle.fadeStart); + particle.alpha = particle.baseAlpha * Math.max(0, 1 - fadeProgress); +}; + +const stepSplatParticle = ( + particle: DisintegrateParticle, + 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 stepSparkParticle = ( + particle: DisintegrateParticle, + deltaSeconds: number, + timeSinceRelease: number, + windX: number, + windY: number, +): void => { + particle.vx = applyDrag(particle.vx, particle.drag, deltaSeconds); + 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, + }, + { + 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) * + 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; +}; + +/** Advances particle physics using delta time (stable on high-refresh displays). */ +export const stepParticles = ( + particles: readonly DisintegrateParticle[], + deltaSeconds: number, + elapsedSeconds: number, + resolvedOptions: ResolvedDomDisintegrateOptions, +): void => { + for (const particle of particles) { + if (elapsedSeconds < particle.releaseTime) { + continue; + } + + const timeSinceRelease = elapsedSeconds - particle.releaseTime; + updateFadeAlpha(particle, timeSinceRelease); + + if (particle.alpha <= 0.01) { + continue; + } + + if (resolvedOptions.particleMotionMode === "windy") { + stepSparkParticle( + particle, + deltaSeconds, + timeSinceRelease, + resolvedOptions.windX, + resolvedOptions.windY, + ); + } else { + stepSplatParticle( + particle, + deltaSeconds, + resolvedOptions.windX, + resolvedOptions.windY, + resolvedOptions.gravity, + ); + } + + particle.rotation += particle.rotationSpeed * deltaSeconds; + } +}; diff --git a/src/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence.ts b/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts similarity index 69% rename from src/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence.ts rename to src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts index 426c165c..8420e1c4 100644 --- a/src/shared/ui/effects/thanosDisintegrate/syncChunkMaskSequence.ts +++ b/src/shared/ui/effects/domDisintegrate/syncChunkMaskSequence.ts @@ -1,9 +1,18 @@ -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"; + +type ChunkMaskFrameState = { + maskIndex: number; +}; + +const chunkMaskFrameStateByElement = new WeakMap< + HTMLElement, + ChunkMaskFrameState +>(); const applyMaskImage = ( target: HTMLElement, @@ -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 new file mode 100644 index 00000000..ea6d75c4 --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/syncElementWaveMask.ts @@ -0,0 +1,170 @@ +/** 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; +}; + +export const getWaveMaskRadii = ( + elapsedSeconds: number, + waveSpeedPxPerSecond: number, +): WaveMaskRadii => { + const waveRadius = elapsedSeconds * waveSpeedPxPerSecond; + + return { + innerRadius: Math.max(0, waveRadius - WAVE_MASK_FADE_PX), + outerRadius: waveRadius + WAVE_MASK_FADE_PX, + }; +}; + +/** Modal: transparent inside the wave (dissolved), opaque outside (glass remains). */ +export const createModalWaveMask = ( + origin: { x: number; y: number }, + { innerRadius, outerRadius }: WaveMaskRadii, +): string => + `radial-gradient(circle at ${origin.x}px ${origin.y}px, transparent ${innerRadius}px, black ${outerRadius}px)`; + +/** Particles: visible inside the wave, hidden outside (inverse of the modal mask). */ +export const createParticleWaveMask = ( + origin: { x: number; y: number }, + { innerRadius, outerRadius }: WaveMaskRadii, +): string => + `radial-gradient(circle at ${origin.x}px ${origin.y}px, black ${innerRadius}px, transparent ${outerRadius}px)`; + +const applyMaskTemplate = ( + target: HTMLElement, + 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 }, + elapsedSeconds: number, + waveSpeedPxPerSecond: number, + displayWidth: number, + displayHeight: number, +): void => { + const radii = getWaveMaskRadii(elapsedSeconds, waveSpeedPxPerSecond); + const maskSize = `${displayWidth}px ${displayHeight}px`; + applyWaveMaskRadii(element, "modal", origin, radii, maskSize); +}; + +export const applyParticleWaveMaskToCanvas = ( + canvas: HTMLCanvasElement, + origin: { x: number; y: number }, + elapsedSeconds: number, + waveSpeedPxPerSecond: number, + canvasWidth: number, + canvasHeight: number, + originOffset = 0, +): void => { + const radii = getWaveMaskRadii(elapsedSeconds, waveSpeedPxPerSecond); + const maskSize = `${canvasWidth}px ${canvasHeight}px`; + applyWaveMaskRadii( + canvas, + "particle", + { x: origin.x + originOffset, y: origin.y + originOffset }, + radii, + maskSize, + ); +}; + +const clearCssMaskStyles = (target: HTMLElement): void => { + target.style.maskImage = ""; + target.style.maskSize = ""; + target.style.maskRepeat = ""; + target.style.imageRendering = ""; + 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; + +export const clearParticleWaveMaskFromCanvas = clearCssMaskStyles; diff --git a/src/shared/ui/effects/thanosDisintegrate/types.ts b/src/shared/ui/effects/domDisintegrate/types.ts similarity index 60% rename from src/shared/ui/effects/thanosDisintegrate/types.ts rename to src/shared/ui/effects/domDisintegrate/types.ts index e3b964ef..5f8e07d0 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,9 +18,12 @@ export type ThanosMaskStrategy = | "splitHorizontal" | "splitVertical"; -export type ThanosParticleRenderMode = "color" | "sprite"; +export type DisintegrateParticleRenderMode = "color" | "sprite"; -export type ThanosParticle = { +/** Outward burst ("splat") or fire-spark flutter with buoyant rise ("windy"). */ +export type DisintegrateParticleMotionMode = "splat" | "windy"; + +export type DisintegrateParticle = { x: number; y: number; /** Snapshot grid position used to punch holes in the captured surface. */ @@ -42,48 +45,58 @@ export type ThanosParticle = { fadeDuration: number; /** Seconds from animation start when this particle begins to crumble. */ 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 ThanosDisintegrateOrigin = +export type DomDisintegrateOrigin = | { x: number; y: number } | { clientX: number; clientY: number }; -export type ThanosDisintegrateOptions = { - /** Pixel stride when sampling the captured surface. */ +export type DomDisintegrateOptions = { + /** 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; /** 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; /** 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?: 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 64% rename from src/shared/ui/effects/thanosDisintegrate/useThanosDisintegrate.ts rename to src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts index f4795491..d4d6eaef 100644 --- a/src/shared/ui/effects/thanosDisintegrate/useThanosDisintegrate.ts +++ b/src/shared/ui/effects/domDisintegrate/useDomDisintegrate.ts @@ -1,31 +1,35 @@ 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"; - -type UseThanosDisintegrateResult = { +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 { + revokeWarmChunkMasks, + warmDisintegrateCapture, +} from "#/shared/ui/effects/domDisintegrate/warmDisintegrateCapture"; + +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); const invalidateCapture = useCallback(() => { + revokeWarmChunkMasks(captureCacheRef.current); captureCacheRef.current = null; const element = elementRef.current; if (!element) { @@ -33,18 +37,19 @@ export const useThanosDisintegrate = (): UseThanosDisintegrateResult => { } cancelWarmRef.current?.(); - cancelWarmRef.current = warmThanosCapture(element, captureCacheRef); + cancelWarmRef.current = warmDisintegrateCapture(element, captureCacheRef); }, []); 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". - cancelWarmRef.current = warmThanosCapture(node, captureCacheRef); + cancelWarmRef.current = warmDisintegrateCapture(node, captureCacheRef); } }, []); @@ -79,14 +84,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,12 +99,13 @@ export const useThanosDisintegrate = (): UseThanosDisintegrateResult => { isAnimatingRef.current = true; try { - await runThanosDisintegrate(element, { + await runDomDisintegrate(element, { ...options, captureSnapshot: captureCacheRef.current, }); } finally { isAnimatingRef.current = false; + revokeWarmChunkMasks(captureCacheRef.current); captureCacheRef.current = null; } }, 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/domDisintegrate/warmDisintegrateCapture.ts b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts new file mode 100644 index 00000000..c37603ec --- /dev/null +++ b/src/shared/ui/effects/domDisintegrate/warmDisintegrateCapture.ts @@ -0,0 +1,84 @@ +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"; + +type IdleRequestCallback = (deadline: IdleDeadline) => void; + +const scheduleIdle = ( + callback: IdleRequestCallback, + timeoutMs: number, +): number => { + if (typeof requestIdleCallback === "function") { + return requestIdleCallback(callback, { timeout: timeoutMs }); + } + + return setTimeout(() => { + callback({ + didTimeout: true, + timeRemaining: () => 0, + }); + }, 0) as unknown as number; +}; + +const cancelIdle = (handle: number): void => { + if (typeof cancelIdleCallback === "function") { + cancelIdleCallback(handle); + } else { + clearTimeout(handle); + } +}; + +/** Pre-captures in idle time so dismiss does not block on SnapDOM. */ +export const warmDisintegrateCapture = ( + element: HTMLElement, + cacheRef: RefObject, + disintegrateOptions?: DomDisintegrateOptions, +): (() => void) => { + let cancelled = false; + const resolvedOptions = resolveDomDisintegrateOptions( + disintegrateOptions, + element, + ); + + if (resolvedOptions.maskMode === "chunks") { + prewarmChunkMaskWorker(); + } + + const idleHandle = scheduleIdle(() => { + if (cancelled) { + return; + } + + void buildDisintegrateCapture(element, { + mode: "quality", + disintegrateOptions: resolvedOptions, + }) + .then((snapshot) => prebuildWarmChunkMasks(snapshot, resolvedOptions)) + .then((snapshot) => { + if (!cancelled) { + revokeWarmChunkMasks(cacheRef.current); + cacheRef.current = snapshot; + } else { + revokeWarmChunkMasks(snapshot); + } + }) + .catch(() => { + // Warm-up is best-effort; dismiss falls back to a fast capture. + }); + }, 250); + + return () => { + cancelled = true; + cancelIdle(idleHandle); + }; +}; + +export { revokeWarmChunkMasks }; diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts deleted file mode 100644 index 7a507fa9..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/applyWaveOrigin.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { applyWaveOrigin } from "#/shared/ui/effects/thanosDisintegrate/applyWaveOrigin"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; - -const createParticle = (x: number, y: number): ThanosParticle => ({ - 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, -}); - -describe("applyWaveOrigin", () => { - it("delays particles farther from the origin", () => { - const particles = [createParticle(0, 0), createParticle(30, 0)]; - - const maxReleaseTime = applyWaveOrigin(particles, { x: 0, y: 0 }, 10); - - expect(particles[0]?.releaseTime).toBeGreaterThanOrEqual(0); - expect(particles[0]?.releaseTime).toBeLessThan(0.05); - expect(particles[1]?.releaseTime).toBeGreaterThan(2.5); - expect(maxReleaseTime).toBe(particles[1]?.releaseTime); - }); -}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts deleted file mode 100644 index 34633b6f..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/drawDisintegrationFrame.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { drawDisintegrationFrame } from "#/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; - -const createParticle = ( - overrides: Partial = {}, -): ThanosParticle => ({ - 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, - ...overrides, -}); - -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 particles = [ - createParticle({ releaseTime: 0.5 }), - createParticle({ releaseTime: 0.2, x: 1, y: 1 }), - ]; - - drawDisintegrationFrame(context, particles, 0, 6, 6); - - expect(fillRect).not.toHaveBeenCalled(); - }); - - 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 particles = [createParticle({ releaseTime: 0, x: 1, y: 1 })]; - - drawDisintegrationFrame(context, particles, 0.1, 6, 6); - - expect(fillRect).toHaveBeenCalled(); - }); - - 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 particles = [createParticle({ releaseTime: 0, x: 4, y: 6, size: 2 })]; - - drawDisintegrationFrame(context, particles, 0.1, 20, 20, 8); - - expect(translate).toHaveBeenCalledWith(4 + 8 + 1, 6 + 8 + 1); - }); - - 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 sourceCanvas = document.createElement("canvas"); - const particles = [ - createParticle({ releaseTime: 0, x: 2, y: 2, originX: 4, originY: 6 }), - ]; - - drawDisintegrationFrame(context, particles, 0.1, 20, 20, 0, { - renderMode: "sprite", - sourceCanvas, - }); - - expect(drawImage).toHaveBeenCalled(); - expect(context.fillRect).not.toHaveBeenCalled(); - }); -}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts deleted file mode 100644 index 05054476..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/getWaveDisintegrationProgress.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getWaveDisintegrationProgress } from "#/shared/ui/effects/thanosDisintegrate/getWaveDisintegrationProgress"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; - -const createParticle = (releaseTime: number): ThanosParticle => ({ - 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, -}); - -describe("getWaveDisintegrationProgress", () => { - it("ramps from 0 before the wave to 1 after all particles release", () => { - const particles = [createParticle(0), createParticle(0.5)]; - - expect(getWaveDisintegrationProgress(particles, 0)).toBe(0); - expect(getWaveDisintegrationProgress(particles, 1)).toBe(1); - }); -}); diff --git a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts b/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts deleted file mode 100644 index f2d22960..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/__tests__/stepParticles.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/createThanosParticle"; -import { stepParticles } from "#/shared/ui/effects/thanosDisintegrate/stepParticles"; - -describe("stepParticles", () => { - it("uses delta time so motion is independent of frame rate", () => { - const particle = createThanosParticle({ - x: 10, - y: 10, - color: "rgb(255, 255, 255)", - alpha: 1, - surfaceWidth: 100, - surfaceHeight: 40, - options: { - gravity: 0, - windX: 0, - windY: 0, - maxVelocity: 0, - }, - }); - particle.releaseTime = 0; - particle.vx = 60; - particle.vy = 0; - particle.drag = 1; - - stepParticles([particle], 1 / 60, 1 / 60); - const positionAfterOneFrame = particle.x; - - const secondParticle = { - ...particle, - x: 10, - y: 10, - vx: 60, - vy: 0, - }; - stepParticles([secondParticle], 1 / 120, 1 / 120); - stepParticles([secondParticle], 1 / 120, 1 / 60); - - expect(secondParticle.x).toBeCloseTo(positionAfterOneFrame, 2); - }); -}); 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/buildThanosCapture.ts b/src/shared/ui/effects/thanosDisintegrate/buildThanosCapture.ts deleted file mode 100644 index a380fb98..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/buildThanosCapture.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 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"; - -const tryCreateParticlesFromCanvas = ( - canvas: HTMLCanvasElement, - disintegrateOptions?: BuildThanosCaptureOptions["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 buildThanosCapture = async ( - element: HTMLElement, - { mode, disintegrateOptions }: BuildThanosCaptureOptions, -): Promise => { - if (mode === "quality") { - await waitForDocumentFonts(); - } - - const { displayWidth, displayHeight } = getElementDisplaySize(element); - - let rawCanvas: HTMLCanvasElement | null = null; - try { - rawCanvas = - mode === "quality" - ? await captureElementViaSnapdom(element) - : await captureElementViaSvgForeignObject(element); - } catch { - rawCanvas = null; - } - - let sourceCanvas: HTMLCanvasElement | null = null; - let particles = null; - - if (rawCanvas) { - sourceCanvas = normalizeCaptureToDisplay( - rawCanvas, - displayWidth, - displayHeight, - ); - particles = tryCreateParticlesFromCanvas(sourceCanvas, disintegrateOptions); - } - - if (!particles) { - sourceCanvas = null; - particles = createFallbackParticlesFromElement( - element, - disintegrateOptions, - ); - } - - return { - sourceCanvas, - particles, - displayWidth, - displayHeight, - }; -}; diff --git a/src/shared/ui/effects/thanosDisintegrate/constants.ts b/src/shared/ui/effects/thanosDisintegrate/constants.ts deleted file mode 100644 index e8bd93d1..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/constants.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ResolvedThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/types"; - -export const THANOS_DISINTEGRATE_DEFAULTS = { - particleStep: 3, - particleSize: 2, - maxVelocity: 165, - windX: 20, - windY: -14, - gravity: 180, - maxDuration: 1.0, - zIndex: 9999, - waveSpeed: 700, - maskMode: "chunks", - maxChunkMaskSteps: 96, - maskSpreadDuration: 0.6, - particleRenderMode: "color", - useChunkMaskWorker: true, -} as const satisfies ResolvedThanosDisintegrateOptions; diff --git a/src/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData.ts b/src/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData.ts deleted file mode 100644 index 5d9236db..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/createParticlesFromImageData.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/createThanosParticle"; -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; - -export const createParticlesFromImageData = ( - imageData: ImageData, - options?: ThanosDisintegrateOptions, -): ThanosParticle[] => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); - const particles: ThanosParticle[] = []; - const { data, width, height } = imageData; - const { particleStep } = resolvedOptions; - - for (let y = 0; y < height; y += particleStep) { - for (let x = 0; x < width; x += particleStep) { - const index = (y * width + x) * 4; - const alphaChannel = data[index + 3] ?? 0; - if (alphaChannel < 12) { - continue; - } - - const red = data[index] ?? 0; - const green = data[index + 1] ?? 0; - const blue = data[index + 2] ?? 0; - - particles.push( - createThanosParticle({ - x, - y, - color: `rgb(${red}, ${green}, ${blue})`, - alpha: alphaChannel / 255, - surfaceWidth: width, - surfaceHeight: height, - options: resolvedOptions, - }), - ); - } - } - - return particles; -}; diff --git a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts b/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts deleted file mode 100644 index 46638fb1..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/createThanosParticle.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; - -type CreateParticleInput = { - x: number; - y: number; - color: string; - alpha: number; - surfaceWidth: number; - surfaceHeight: number; - 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 centerX = surfaceWidth / 2; - const centerY = surfaceHeight / 2; - const deltaX = x - centerX; - const deltaY = y - centerY; - const distanceFromCenter = Math.hypot(deltaX, deltaY); - const baseAngle = - distanceFromCenter > 0.5 - ? Math.atan2(deltaY, deltaX) - : Math.random() * Math.PI * 2; - const spread = (Math.random() - 0.5) * 1.2; - const angle = baseAngle + spread; - const speed = resolvedOptions.maxVelocity * (0.45 + Math.random() * 0.75); - - return { - x, - y, - originX: x, - originY: y, - vx: Math.cos(angle) * speed + resolvedOptions.windX, - vy: Math.sin(angle) * speed + resolvedOptions.windY, - 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, - fadeStart: 0.45 + Math.random() * 0.25, - fadeDuration: 0.3 + Math.random() * 0.35, - releaseTime: 0, - }; -}; diff --git a/src/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame.ts b/src/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame.ts deleted file mode 100644 index ca8dcac9..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/drawDisintegrationFrame.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ThanosParticleRenderMode } from "#/shared/ui/effects/thanosDisintegrate/types"; -import type { ThanosParticle } from "#/shared/ui/effects/thanosDisintegrate/types"; - -export type DrawDisintegrationFrameOptions = { - renderMode?: ThanosParticleRenderMode; - sourceCanvas?: HTMLCanvasElement | null; -}; - -/** Draws flying particles on the canvas layer beneath the live masked surface. */ -export const drawDisintegrationFrame = ( - context: CanvasRenderingContext2D, - particles: ThanosParticle[], - elapsedSeconds: number, - canvasWidth: number, - canvasHeight: number, - originOffset = 0, - options?: DrawDisintegrationFrameOptions, -): void => { - context.clearRect(0, 0, canvasWidth, canvasHeight); - - const renderMode = options?.renderMode ?? "color"; - const sourceCanvas = options?.sourceCanvas ?? null; - const useSprites = renderMode === "sprite" && sourceCanvas !== null; - - for (const particle of particles) { - if (elapsedSeconds < particle.releaseTime || particle.alpha <= 0) { - 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(); - } - - context.globalAlpha = 1; -}; diff --git a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts b/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts deleted file mode 100644 index 6f64f8b4..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/getParticleCanvasPadding.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import type { ThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/types"; - -/** Max distance a particle can travel from its origin during the animation. */ -export const getParticleMaxTravel = ( - options?: ThanosDisintegrateOptions, -): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); - const duration = resolvedOptions.maxDuration; - return ( - resolvedOptions.maxVelocity * duration + - 0.5 * resolvedOptions.gravity * duration * duration + - Math.abs(resolvedOptions.windX) * duration + - Math.abs(resolvedOptions.windY) * duration - ); -}; - -/** Margin around each chunk hole on the particle mask so flying pixels stay visible. */ -export const getParticleRevealMargin = ( - options?: ThanosDisintegrateOptions, -): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); - return Math.ceil( - getParticleMaxTravel(resolvedOptions) + resolvedOptions.particleSize, - ); -}; - -/** Extra canvas margin so flying particles are not clipped by the surface bounds. */ -export const getParticleCanvasPadding = ( - options?: ThanosDisintegrateOptions, -): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); - return Math.ceil( - getParticleMaxTravel(resolvedOptions) + resolvedOptions.particleSize * 4, - ); -}; diff --git a/src/shared/ui/effects/thanosDisintegrate/index.ts b/src/shared/ui/effects/thanosDisintegrate/index.ts deleted file mode 100644 index 2722edcc..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/index.ts +++ /dev/null @@ -1,19 +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, - 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/stepParticles.ts b/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts deleted file mode 100644 index 958e014f..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/stepParticles.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { resolveThanosDisintegrateOptions } from "#/shared/ui/effects/thanosDisintegrate/resolveThanosDisintegrateOptions"; -import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; - -const applyDrag = (velocity: number, drag: number, deltaSeconds: number) => - velocity * Math.pow(drag, deltaSeconds * 60); - -const updateFadeAlpha = ( - particle: ThanosParticle, - timeSinceRelease: number, -): void => { - const progress = timeSinceRelease / particle.fadeDuration; - if (progress <= particle.fadeStart) { - particle.alpha = particle.baseAlpha; - return; - } - - const fadeProgress = - (progress - particle.fadeStart) / Math.max(0.001, 1 - particle.fadeStart); - particle.alpha = particle.baseAlpha * Math.max(0, 1 - fadeProgress); -}; - -/** Advances particle physics using delta time (stable on high-refresh displays). */ -export const stepParticles = ( - particles: ThanosParticle[], - deltaSeconds: number, - elapsedSeconds: number, - options?: ThanosDisintegrateOptions, -): number => { - const resolvedOptions = resolveThanosDisintegrateOptions(options); - let visibleCount = 0; - - for (const particle of particles) { - if (elapsedSeconds < particle.releaseTime) { - visibleCount += 1; - continue; - } - - 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; - particle.rotation += particle.rotationSpeed * deltaSeconds; - updateFadeAlpha(particle, timeSinceRelease); - - if (particle.alpha > 0.01) { - visibleCount += 1; - } - } - - return visibleCount; -}; diff --git a/src/shared/ui/effects/thanosDisintegrate/syncElementWaveMask.ts b/src/shared/ui/effects/thanosDisintegrate/syncElementWaveMask.ts deleted file mode 100644 index bc1fd5ea..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/syncElementWaveMask.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** Radial CSS mask that reveals particles beneath the live surface as the wave expands. */ -export const WAVE_MASK_FADE_PX = 24; - -export type WaveMaskRadii = { - innerRadius: number; - outerRadius: number; -}; - -export const getWaveMaskRadii = ( - elapsedSeconds: number, - waveSpeedPxPerSecond: number, -): WaveMaskRadii => { - const waveRadius = elapsedSeconds * waveSpeedPxPerSecond; - - return { - innerRadius: Math.max(0, waveRadius - WAVE_MASK_FADE_PX), - outerRadius: waveRadius + WAVE_MASK_FADE_PX, - }; -}; - -/** Modal: transparent inside the wave (dissolved), opaque outside (glass remains). */ -export const createModalWaveMask = ( - origin: { x: number; y: number }, - { innerRadius, outerRadius }: WaveMaskRadii, -): string => - `radial-gradient(circle at ${origin.x}px ${origin.y}px, transparent ${innerRadius}px, black ${outerRadius}px)`; - -/** Particles: visible inside the wave, hidden outside (inverse of the modal mask). */ -export const createParticleWaveMask = ( - origin: { x: number; y: number }, - { innerRadius, outerRadius }: WaveMaskRadii, -): string => - `radial-gradient(circle at ${origin.x}px ${origin.y}px, black ${innerRadius}px, transparent ${outerRadius}px)`; - -const applyMaskStyles = ( - target: HTMLElement, - mask: string, - maskSize: string, -): void => { - target.style.maskImage = mask; - target.style.maskSize = maskSize; - target.style.setProperty("-webkit-mask-image", mask); - target.style.setProperty("-webkit-mask-size", maskSize); -}; - -export const applyWaveMaskToElement = ( - element: HTMLElement, - origin: { x: number; y: number }, - elapsedSeconds: number, - waveSpeedPxPerSecond: number, - displayWidth: number, - displayHeight: number, -): void => { - const radii = getWaveMaskRadii(elapsedSeconds, waveSpeedPxPerSecond); - const mask = createModalWaveMask(origin, radii); - const maskSize = `${displayWidth}px ${displayHeight}px`; - applyMaskStyles(element, mask, maskSize); -}; - -export const applyParticleWaveMaskToCanvas = ( - canvas: HTMLCanvasElement, - origin: { x: number; y: number }, - elapsedSeconds: number, - waveSpeedPxPerSecond: number, - canvasWidth: number, - canvasHeight: number, - originOffset = 0, -): void => { - const radii = getWaveMaskRadii(elapsedSeconds, waveSpeedPxPerSecond); - const mask = createParticleWaveMask( - { x: origin.x + originOffset, y: origin.y + originOffset }, - radii, - ); - const maskSize = `${canvasWidth}px ${canvasHeight}px`; - applyMaskStyles(canvas, mask, maskSize); -}; - -const clearCssMaskStyles = (target: HTMLElement): void => { - target.style.maskImage = ""; - target.style.maskSize = ""; - target.style.maskRepeat = ""; - target.style.imageRendering = ""; - target.style.removeProperty("-webkit-mask-image"); - target.style.removeProperty("-webkit-mask-size"); - target.style.removeProperty("-webkit-mask-repeat"); -}; - -export const clearWaveMaskFromElement = clearCssMaskStyles; - -export const clearParticleWaveMaskFromCanvas = clearCssMaskStyles; diff --git a/src/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot.ts b/src/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot.ts deleted file mode 100644 index d62786f1..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/thanosCaptureSnapshot.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { - ThanosDisintegrateOptions, - ThanosParticle, -} from "#/shared/ui/effects/thanosDisintegrate/types"; - -export type ThanosCaptureSnapshot = { - sourceCanvas: HTMLCanvasElement | null; - particles: ThanosParticle[]; - displayWidth: number; - displayHeight: number; -}; - -export const cloneThanosParticles = ( - particles: ThanosParticle[], -): ThanosParticle[] => particles.map((particle) => ({ ...particle })); - -export const isThanosCaptureSnapshotValid = ( - snapshot: ThanosCaptureSnapshot, - element: HTMLElement, -): boolean => { - const rect = element.getBoundingClientRect(); - const width = Math.max(1, Math.round(rect.width)); - const height = Math.max(1, Math.round(rect.height)); - - return ( - snapshot.displayWidth === width && - snapshot.displayHeight === height && - snapshot.particles.length > 0 - ); -}; - -export const getElementDisplaySize = ( - element: HTMLElement, -): { displayWidth: number; displayHeight: number } => { - const rect = element.getBoundingClientRect(); - return { - displayWidth: Math.max(1, Math.round(rect.width)), - displayHeight: Math.max(1, Math.round(rect.height)), - }; -}; - -export type BuildThanosCaptureMode = "fast" | "quality"; - -export type BuildThanosCaptureOptions = { - mode: BuildThanosCaptureMode; - disintegrateOptions?: ThanosDisintegrateOptions; -}; 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/src/shared/ui/effects/thanosDisintegrate/warmThanosCapture.ts b/src/shared/ui/effects/thanosDisintegrate/warmThanosCapture.ts deleted file mode 100644 index b6ee8bd8..00000000 --- a/src/shared/ui/effects/thanosDisintegrate/warmThanosCapture.ts +++ /dev/null @@ -1,66 +0,0 @@ -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"; - -type IdleRequestCallback = (deadline: IdleDeadline) => void; - -const scheduleIdle = ( - callback: IdleRequestCallback, - timeoutMs: number, -): number => { - if (typeof requestIdleCallback === "function") { - return requestIdleCallback(callback, { timeout: timeoutMs }); - } - - return setTimeout(() => { - callback({ - didTimeout: true, - timeRemaining: () => 0, - }); - }, 0) as unknown as number; -}; - -const cancelIdle = (handle: number): void => { - if (typeof cancelIdleCallback === "function") { - cancelIdleCallback(handle); - } else { - clearTimeout(handle); - } -}; - -/** Pre-captures in idle time so dismiss does not block on SnapDOM. */ -export const warmThanosCapture = ( - element: HTMLElement, - cacheRef: RefObject, - disintegrateOptions?: ThanosDisintegrateOptions, -): (() => void) => { - let cancelled = false; - const resolvedOptions = resolveThanosDisintegrateOptions(disintegrateOptions); - - const idleHandle = scheduleIdle(() => { - if (cancelled) { - return; - } - - void buildThanosCapture(element, { - mode: "quality", - disintegrateOptions: resolvedOptions, - }) - .then((snapshot) => { - if (!cancelled) { - cacheRef.current = snapshot; - } - }) - .catch(() => { - // Warm-up is best-effort; dismiss falls back to a fast capture. - }); - }, 250); - - return () => { - cancelled = true; - cancelIdle(idleHandle); - }; -}; 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