From 336aa64e606cbaff9f87cd0d166adff62b41e171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 17 Sep 2026 16:03:11 -0400 Subject: [PATCH] feat(registry): blur an element by marking it, not by calling a function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attachMotionBlur(el, tl) has an ordering contract: it has to run after every tween is defined and before the composition registers its timeline. Getting it wrong makes the blur silently do nothing, which is the worst shape a failure can take in a renderer nobody watches. An attribute has no order to get wrong. data-hf-motion-blur on any element is now the contract. Empty means defaults; anything else is a JSON object of the same options. The element finds its own timeline: its nearest [data-composition-id] ancestor names the key, and the snippet reads window.__timelines[key]. attachMotionBlur stays exported for a target built after the page has settled. claim(el) is the only thing that attaches, and two triggers call it. An accessor on window.__timelines wraps the registry in a Proxy whose set trap sweeps on every per-key registration, which is what attaches before the renderer captures frame zero. A bounded poll, 32 ms by 250 ticks, reads the registry directly and needs no cooperation from whoever wrote it. The poll is not belt and braces: a registry reference captured before the snippet installs bypasses the trap outright, and a composition that mounts its DOM after registering its timeline has no marked element to sweep at trap time. It runs its whole budget every time rather than stopping the first tick nothing is pending, because on an ordinary page the top-level target is claimed on the registration write, so an early exit kills the poll one tick in, before any late composition arrives. Every refusal is by name, once, and never silent: a value that is not JSON, a value that parses but is not an options object, an option name that is not one of the four, an option value that is not a finite number, a target inside another target, and a second timeline registered under one key. Parsing runs before the timeline lookup, so a malformed value is reported as malformed rather than as a composition that never registered. Two properties of the primitive this change made load-bearing: - Frame rate is resolved from the target's own composition root, not from the first root in the document. The document-wide lookup was survivable while every caller could pass fps explicitly; the declarative form has no per-call argument. Two compositions on one page at different rates now each get their own shutter window, and one call naming both says it cannot. - An attach is all-or-nothing. The mark, the copies and the resize observer go in together and come back out together, so a throw partway through leaves the element untouched rather than marked blurred with copies in the page and no tracker driving them. The sweep is also bounded per element, because the write trap runs it inside the author's own registration statement. Copies are stripped of data-hf-motion-blur, data-composition-id and data-fps, on the clone and every descendant: the first would make a copy a target of its own, and the other two would let a copy win the fps lookup, since copies are inserted before the original. skills/hyperframes-animation/references/motion-blur.md is the agent-facing half, mirroring how colour grading is surfaced. It leads with when NOT to blur, because the attribute makes blurring everything trivial and a smear on motion that was never fast enough reads as a soft, cheap render. Co-Authored-By: Miguel Ángel --- docs/catalog/components/motion-blur.mdx | 494 ++++++++++--- docs/catalog/components/shutter-slam.mdx | 421 +++++++++-- .../catalog/components/motion-blur.json | 2 +- .../catalog/components/shutter-slam.json | 2 +- .../motionBlurShutter.browser.test.ts | 664 +++++++++++++++++- registry/components/motion-blur/demo.html | 447 +++++++++--- .../components/motion-blur/motion-blur.html | 472 +++++++++++-- registry/components/shutter-slam/demo.html | 419 +++++++++-- .../components/shutter-slam/shutter-slam.html | 421 +++++++++-- registry/examples/motion-blur/index.html | 420 +++++++++-- skills-manifest.json | 4 +- skills/hyperframes-animation/SKILL.md | 1 + .../references/motion-blur.md | 133 ++++ 13 files changed, 3368 insertions(+), 532 deletions(-) create mode 100644 skills/hyperframes-animation/references/motion-blur.md diff --git a/docs/catalog/components/motion-blur.mdx b/docs/catalog/components/motion-blur.mdx index d510e853130..265debc0017 100644 --- a/docs/catalog/components/motion-blur.mdx +++ b/docs/catalog/components/motion-blur.mdx @@ -26,8 +26,9 @@ That writes one file: `compositions/components/motion-blur.html`. ``` @@ -435,19 +751,31 @@ That writes one file: `compositions/components/motion-blur.html`. ## Usage -Paste the snippet into your composition, then call `attachMotionBlur()` after your GSAP tweens and before registering `window.__timelines`. +Paste the snippet into your composition, then put `data-hf-motion-blur` on any element your timeline animates. Nothing else to call, and no order to get wrong. + +```html +
+ +
+ + +
+
+``` + +The element finds its own timeline: its nearest `[data-composition-id]` ancestor names the key, and the snippet reads `window.__timelines[key]`. An element whose composition never registers a timeline says so in the console instead of rendering sharp in silence. + +`attachMotionBlur()` stays available for a target built after the page has settled. It has an ordering contract the attribute does not: call it after every tween is defined, so the timeline's final duration is known. ```html tl.set(document.body, {}, DATA_DURATION); -attachMotionBlur("#my-box", tl, { +attachMotionBlur("#late-box", tl, { shutterAngle: 720, // degrees of the frame interval the shutter is open samplesPerFrame: 16, // sub-intervals of the window; 17 duplicates at 1/16 each }); - -window.__timelines = window.__timelines || {}; -window.__timelines["my-composition"] = tl; ``` ## How it works diff --git a/docs/catalog/components/shutter-slam.mdx b/docs/catalog/components/shutter-slam.mdx index d32bb6ddcc6..aeed58fd7af 100644 --- a/docs/catalog/components/shutter-slam.mdx +++ b/docs/catalog/components/shutter-slam.mdx @@ -154,16 +154,48 @@ you paste it into. \n \n \n \n \n
\n Motion Blur — Velocity Showcase\n 17 duplicates at 1/16 · 720° shutter · smear = speed × shutter time\n
\n\n
\n
3.0seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
DRIFT
\n
slow
\n
\n\n
\n
1.5seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
GLIDE
\n
medium
\n
\n\n
\n
0.7seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
RUSH
\n
fast
\n
\n\n
\n
\n 0.35seconds\n
\n
shape
\n
text
\n
\n
\n
\n
\n
BLAST
\n
faster
\n
\n\n
\n
0.2seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
WARP
\n
extreme
\n
\n\n \n \n \n\n"} \ No newline at end of file +{"html":"\n\n \n \n \n Motion Blur — Demo\n \n \n \n \n \n
\n Motion Blur — Velocity Showcase\n 17 duplicates at 1/16 · 720° shutter · smear = speed × shutter time\n
\n\n
\n
3.0seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
DRIFT
\n
slow
\n
\n\n
\n
1.5seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
GLIDE
\n
medium
\n
\n\n
\n
0.7seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
RUSH
\n
fast
\n
\n\n
\n
\n 0.35seconds\n
\n
shape
\n
text
\n
\n
\n
\n
\n
BLAST
\n
faster
\n
\n\n
\n
0.2seconds
\n
shape
\n
text
\n
\n
\n
\n
\n
WARP
\n
extreme
\n
\n\n \n \n \n\n"} \ No newline at end of file diff --git a/docs/public/catalog/components/shutter-slam.json b/docs/public/catalog/components/shutter-slam.json index eed8f790702..41a086083d3 100644 --- a/docs/public/catalog/components/shutter-slam.json +++ b/docs/public/catalog/components/shutter-slam.json @@ -1 +1 @@ -{"html":"\n\n\n \n \n \n \n\n\n
\n
\n \n\n
\n
\n
\n
\n
\n\n \n \n \n
\n
\n \n\n"} \ No newline at end of file +{"html":"\n\n\n \n \n \n \n\n\n
\n
\n \n\n
\n
\n
\n
\n
\n\n \n \n \n
\n
\n \n\n"} \ No newline at end of file diff --git a/packages/cli/src/registry/motionBlurShutter.browser.test.ts b/packages/cli/src/registry/motionBlurShutter.browser.test.ts index 25e5498d8d4..5bfc106efdb 100644 --- a/packages/cli/src/registry/motionBlurShutter.browser.test.ts +++ b/packages/cli/src/registry/motionBlurShutter.browser.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import reference from "./__fixtures__/motion-blur-ae-reference.json"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); @@ -13,22 +13,18 @@ function readRepoFile(relativePath: string): string { const snippetHtml = readRepoFile("registry/components/motion-blur/motion-blur.html"); -/** First line of the snippet's IIFE body, which runs to the `};` closing it at the same indent. */ -const BODY_FIRST_LINE = "if (!window._hfMbUid) window._hfMbUid = 0;"; +const SNIPPET_START = "/* SHUTTER_SNIPPET_START */"; +const SNIPPET_END = "/* SHUTTER_SNIPPET_END */"; -/** The snippet's IIFE body verbatim, located by the indent of its own first line, so a copy - * nested in a component template is found as readily as one in a demo plate. */ +/** The snippet verbatim, between its own sentinel comments. In a copy the snippet shares one + * IIFE with that composition's timeline code, so no brace or closer can tell the two apart: + * the delimiters have to be written down. */ function snippetSource(source: string): string { - const escaped = BODY_FIRST_LINE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = new RegExp(`^([ ]*)${escaped}$`, "m").exec(source); - const indent = match?.[1]; - if (match === null || indent === undefined) { - throw new Error("could not locate the snippet body"); - } - const endMarker = `\n${indent}};`; - const end = source.indexOf(endMarker, match.index); - if (end < 0) throw new Error("could not locate the end of the snippet body"); - return source.slice(match.index, end + endMarker.length); + const start = source.indexOf(SNIPPET_START); + if (start < 0) throw new Error(`could not locate ${SNIPPET_START}`); + const end = source.indexOf(SNIPPET_END, start); + if (end < 0) throw new Error(`could not locate ${SNIPPET_END}`); + return source.slice(start, end + SNIPPET_END.length); } /** Runs of code lines joined, because oxfmt wraps a statement differently at each nesting depth. @@ -91,9 +87,18 @@ function installSnippet(): void { new Function(body)(); } -function makeTimeline(): { tl: Timeline; currentTime: () => number; fire: () => void } { +interface Fake { + tl: Timeline; + currentTime: () => number; + fire: () => void; + /** How many tracker tweens the snippet installed. A call that blurs nothing installs none. */ + trackers: () => number; +} + +function makeTimeline(onTo?: () => void): Fake { let now = FRAME_TIME_S; let onUpdate: (() => void) | undefined; + let trackers = 0; const tl: Timeline = { time(value?: number) { if (value === undefined) return now; @@ -102,11 +107,13 @@ function makeTimeline(): { tl: Timeline; currentTime: () => number; fire: () => }, duration: () => DURATION_S, to(_target, vars) { + onTo?.(); + trackers += 1; onUpdate = vars.onUpdate; return tl; }, }; - return { tl, currentTime: () => now, fire: () => onUpdate?.() }; + return { tl, currentTime: () => now, fire: () => onUpdate?.(), trackers: () => trackers }; } /** @@ -131,20 +138,27 @@ function installComputedStyle( }) as unknown as CSSStyleDeclaration) as typeof globalThis.getComputedStyle; } -/** Captures the observers the snippet installs so a test can fire a resize itself. */ -function installResizeObserver(): { resize: () => void } { - const callbacks: Array<() => void> = []; +/** Captures the observers the snippet installs so a test can fire a resize itself, and + * counts the ones still connected: an observer left on an element nothing blurs keeps + * walking N+1 detached subtrees on every resize. */ +function installResizeObserver(): { resize: () => void; live: () => number } { + const callbacks = new Set<() => void>(); (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + private readonly callback: () => void; constructor(callback: () => void) { - callbacks.push(callback); + this.callback = callback; + callbacks.add(callback); } observe(): void {} - disconnect(): void {} + disconnect(): void { + callbacks.delete(this.callback); + } }; return { resize: () => { for (const callback of callbacks) callback(); }, + live: () => callbacks.size, }; } @@ -183,7 +197,7 @@ async function attach( fire(); await Promise.resolve(); - const group = document.querySelector("[data-hf-motion-blur]"); + const group = document.querySelector("[data-hf-motion-blur-group]"); if (!group) throw new Error("motion-blur group was not created"); return { group, word, copies: [...group.children] as HTMLElement[], fire, reattach }; } @@ -219,10 +233,24 @@ function copyPitches(offsets: number[]): number[] { const originalGetComputedStyle = globalThis.getComputedStyle; -afterEach(() => { +beforeEach(() => { + // The snippet polls the registry on a timer for seconds. Fake timers let a test reach the + // end of that budget, and let this file drain one test's poll before the next starts. + vi.useFakeTimers(); +}); + +afterEach(async () => { document.body.innerHTML = ""; + // Each install leaves a poll running. A pending one would otherwise fire inside a later + // test against that test's document, so it is drained here over an emptied body. + await vi.runAllTimersAsync(); + vi.useRealTimers(); globalThis.getComputedStyle = originalGetComputedStyle; delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver; + // The registration hook and its one-shot guard live on window, so without this the + // second test in the file would run against the first test's accessor. + delete (window as unknown as { _hfMbHooked?: boolean })._hfMbHooked; + delete (window as unknown as { __timelines?: unknown }).__timelines; }); describe("motion-blur snippet copies", () => { @@ -349,7 +377,7 @@ describe("motion-blur drivers", () => { const { reattach } = await attach(); reattach(); - expect(document.querySelectorAll("[data-hf-motion-blur]")).toHaveLength(1); + expect(document.querySelectorAll("[data-hf-motion-blur-group]")).toHaveLength(1); }); it("re-reads the copies' styles when the element's box changes", async () => { @@ -381,3 +409,589 @@ describe("motion-blur drivers", () => { expect(group.style.display).toBe("none"); }); }); + +/** A composition with one declaratively marked target, and the pieces to drive it. */ +interface Declared { + word: HTMLElement; + root: HTMLElement; + /** The composition's own timeline, for a test that has to call the imperative form. */ + tl: Timeline; + trackers: () => number; + register: (key?: string, value?: unknown) => void; + fire: () => void; + groups: () => HTMLElement[]; + settle: () => Promise; +} + +const COMPOSITION = "declarative-composition"; +/** The snippet's own budget, 250 ticks of 32 ms, plus one tick of slack. */ +const POLL_BUDGET_MS = 250 * 32 + 32; + +function declare( + attribute: string | null, + compositionId: string = COMPOSITION, + rootFps: number | null = null, +): Declared { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", compositionId); + if (rootFps !== null) root.setAttribute("data-fps", String(rootFps)); + document.body.appendChild(root); + const word = document.createElement("div"); + word.id = "word"; + if (attribute !== null) word.setAttribute("data-hf-motion-blur", attribute); + Object.defineProperty(word, "offsetWidth", { value: WORD_WIDTH }); + Object.defineProperty(word, "offsetHeight", { value: WORD_HEIGHT }); + Object.defineProperty(word, "offsetLeft", { value: 437 }); + Object.defineProperty(word, "offsetTop", { value: 442 }); + root.appendChild(word); + + const { tl, currentTime, fire, trackers } = makeTimeline(); + installComputedStyle( + word, + currentTime, + translating, + () => "1", + () => PERSPECTIVE, + ); + installSnippet(); + + const host = window as unknown as { __timelines?: Record }; + return { + word, + root, + tl, + trackers, + fire, + // The authoring boilerplate verbatim, self-assignment included: those two writes are + // what the snippet has to intercept. + register: (key: string = compositionId, value: unknown = tl) => { + host.__timelines = host.__timelines ?? {}; + (host.__timelines as Record)[key] = value; + }, + groups: () => [...document.querySelectorAll("[data-hf-motion-blur-group]")], + // Runs the snippet's whole polling budget, which is how the unclaimed-target warning + // is reached. Fake timers, because the real budget is seconds of wall clock. + settle: async () => { + await vi.advanceTimersByTimeAsync(POLL_BUDGET_MS); + }, + }; +} + +describe("motion-blur declarative attribute", () => { + it("attaches on timeline registration, with no attachMotionBlur call", async () => { + // The ordering contract is the whole point: registering the timeline is the moment the + // author has finished building it, so the attribute has no order to get wrong. + const target = declare(""); + expect(target.groups()).toHaveLength(0); + + target.register(); + target.fire(); + await Promise.resolve(); + + const [group] = target.groups(); + expect(group?.children).toHaveLength(COPIES); + expect(group?.style.display).toBe(""); + }); + + it("reads its options out of the attribute", async () => { + const target = declare('{"samplesPerFrame": 4}'); + target.register(); + target.fire(); + await Promise.resolve(); + + expect(target.groups()[0]?.children).toHaveLength(5); + }); + + it("leaves a target alone when another composition registers", async () => { + // One page can hold several compositions, and a target belongs to the nearest one. + const target = declare(""); + target.register("some-other-composition"); + + expect(target.groups()).toHaveLength(0); + }); + + it("leaves a target alone when the registered value is not a timeline", async () => { + const target = declare(""); + target.register(COMPOSITION, { notATimeline: true }); + + expect(target.groups()).toHaveLength(0); + }); + + it("does not sweep its own copies as targets", async () => { + // A copy is cloned from the target, attribute included, so without stripping it the + // sweep would attach a second group inside the first one's. + const target = declare(""); + target.register(); + target.fire(); + await Promise.resolve(); + target.register(); + + expect(target.groups()).toHaveLength(1); + const [group] = target.groups(); + for (const copy of [...(group?.children ?? [])]) { + expect(copy.hasAttribute("data-hf-motion-blur")).toBe(false); + } + }); + + it("blurs once when the attribute and attachMotionBlur name the same element", async () => { + const target = declare(""); + target.register(); + const attachBlur = ( + window as unknown as { attachMotionBlur: (s: string, t: unknown, o: unknown) => void } + ).attachMotionBlur; + attachBlur("#word", makeTimeline().tl, { fps: FPS }); + + expect(target.groups()).toHaveLength(1); + }); + + it("warns instead of silently rendering sharp when no composition claims a target", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare("", "a-composition-that-never-registers"); + await target.settle(); + + expect(target.groups()).toHaveLength(0); + expect(warn).toHaveBeenCalledOnce(); + expect(String(warn.mock.calls[0]?.[0])).toContain( + "no composition registered a timeline for them", + ); + warn.mockRestore(); + }); + + it("warns and skips a value that is not JSON, rather than reading it as defaults", async () => { + // A typo in the options would otherwise blur with the wrong shutter, or not at all, + // with nothing on the console to say which. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare("{samplesPerFrame: 4}"); + target.register(); + + expect(target.groups()).toHaveLength(0); + expect(String(warn.mock.calls[0]?.[0])).toContain("is not JSON"); + + // Already reported, so the load-time sweep must not report it a second time. + warn.mockClear(); + await target.settle(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("does not stack a proxy when the registry is assigned back to itself", () => { + // `window.__timelines = window.__timelines || {}` is the documented boilerplate, so the + // interception sees its own result handed back and must not wrap it a second time. + const target = declare(""); + target.register(); + const host = window as unknown as { __timelines?: Record }; + const first = host.__timelines; + + host.__timelines = host.__timelines ?? {}; + + expect(host.__timelines).toBe(first); + }); + + it("says nothing when every target was claimed", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + target.register(); + await target.settle(); + + expect(target.groups()).toHaveLength(1); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe("motion-blur declarative attribute, the cases only executing found", () => { + it("refuses a target inside another target, and keeps no attribute on a copy", async () => { + // The inner target's group would be inserted into the LIVE outer element, so the outer's + // style replay would walk a subtree its own copies no longer match. And a copy is a deep + // clone, so the attribute rides along on descendants unless it is stripped there too. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + const child = document.createElement("span"); + child.setAttribute("data-hf-motion-blur", ""); + Object.defineProperty(child, "offsetWidth", { value: 10 }); + Object.defineProperty(child, "offsetHeight", { value: 10 }); + target.word.appendChild(child); + + target.register(); + target.register(); + + expect(target.groups()).toHaveLength(1); + expect( + document.querySelectorAll("[data-hf-motion-blur-group] [data-hf-motion-blur]"), + ).toHaveLength(0); + await target.settle(); + + // Once, not once per sweep: the refusal is terminal, so the poll must not re-report it, + // and it must not be counted as a target still waiting for a timeline. + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("inside another"); + expect(String(warn.mock.calls[0]?.[0])).not.toContain("no composition registered"); + warn.mockRestore(); + }); + + it("attaches a target whose composition mounts after its timeline is registered", async () => { + // A sub-composition registers and then its DOM arrives. Both triggers are write-driven, + // so the poll has to survive a tick on which nothing was pending. + const target = declare(""); + target.register(); + await vi.advanceTimersByTimeAsync(64); + expect(target.groups()).toHaveLength(1); + + const { tl } = makeTimeline(); + target.register("second-composition", tl); + const host = document.createElement("div"); + host.setAttribute("data-composition-id", "second-composition"); + const late = document.createElement("div"); + late.setAttribute("data-hf-motion-blur", ""); + Object.defineProperty(late, "offsetWidth", { value: WORD_WIDTH }); + Object.defineProperty(late, "offsetHeight", { value: WORD_HEIGHT }); + host.appendChild(late); + document.body.appendChild(host); + + await vi.advanceTimersByTimeAsync(64); + + expect(target.groups()).toHaveLength(2); + }); + + it("warns once about a second timeline, not once per poll tick", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + target.register(); + target.register(COMPOSITION, makeTimeline().tl); + + await vi.advanceTimersByTimeAsync(POLL_BUDGET_MS); + + const second = warn.mock.calls.filter((call) => String(call[0]).includes("second timeline")); + expect(second).toHaveLength(1); + warn.mockRestore(); + }); + + it("blames the attribute, not the composition, when a value is malformed", async () => { + // The parse has to happen before the timeline lookup, or an unclaimed composition hides + // the real cause behind a warning that points at the wrong thing. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + declare("{bad}", "a-composition-that-never-registers"); + + await vi.advanceTimersByTimeAsync(POLL_BUDGET_MS); + + const said = warn.mock.calls.map((call) => String(call[0])).join(" "); + expect(said).toContain("is not JSON"); + expect(said).not.toContain("no composition registered"); + warn.mockRestore(); + }); + + it.each(["null", "720", '"shutterAngle"', "[]"])( + "warns and skips %s, rather than reading it as defaults", + async (value) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(value); + target.register(); + + expect(target.groups()).toHaveLength(0); + expect(String(warn.mock.calls[0]?.[0])).toContain("is not a JSON object"); + warn.mockRestore(); + }, + ); + + it("warns when a second timeline is registered for an element it already blurred", async () => { + // The copies keep following the first timeline. Nobody seeks it, so they render sharp, + // and without this the only signal is a composition that quietly lost its blur. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + target.register(); + expect(target.groups()).toHaveLength(1); + + target.register(COMPOSITION, makeTimeline().tl); + + expect(target.groups()).toHaveLength(1); + expect(warn.mock.calls.map((call) => String(call[0])).join(" ")).toContain("second timeline"); + warn.mockRestore(); + }); + + it("attaches a registration the write trap cannot see", async () => { + // The runtime owns the registry object and wraps it per sub-composition, so a write can + // land on the raw object without passing through the accessor. Polling needs no + // cooperation from whoever writes it. + const host = window as unknown as { __timelines?: Record }; + host.__timelines = {}; + const raw = host.__timelines; + const target = declare(""); + const { tl } = makeTimeline(); + + raw[COMPOSITION] = tl; + expect(target.groups()).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(64); + + expect(target.groups()).toHaveLength(1); + }); + + it("does not warn about a target whose composition registers late", async () => { + // Nested compositions mount asynchronously, well after load, so a warning keyed to load + // would fire on a composition that was about to arrive. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + + await vi.advanceTimersByTimeAsync(2000); + expect(warn).not.toHaveBeenCalled(); + + target.register(); + await vi.advanceTimersByTimeAsync(POLL_BUDGET_MS); + + expect(target.groups()).toHaveLength(1); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("reads the frame rate of its own composition, not the first one in the document", async () => { + // `resolveFps` used a document-wide query while the timeline came from the NEAREST + // composition root, so a second composition on the page inherited the first one's rate + // and got a shutter window half or double the length it asked for. + const decoy = document.createElement("div"); + decoy.setAttribute("data-composition-id", "a-faster-composition"); + decoy.setAttribute("data-fps", String(FPS * 2)); + document.body.appendChild(decoy); + + const target = declare("", COMPOSITION, FPS); + target.register(); + target.fire(); + await Promise.resolve(); + + const group = target.groups()[0]; + if (!group) throw new Error("motion-blur group was not created"); + const { trailing, leading } = windowEdges(copyOffsets([...group.children] as HTMLElement[])); + + expect(trailing).toBeCloseTo(-reference.trailingDisplacementPx, 1); + expect(leading).toBeCloseTo(reference.leadingDisplacementPx, 1); + }); + + it("refuses an option name it does not know instead of rendering with the defaults", async () => { + // The likeliest typo in a JSON object is the key, and reading it as defaults is exactly + // the silent no-op the attribute exists to remove. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare('{"samplesperframe": 4}'); + + target.register(); + await target.settle(); + + expect(target.groups()).toHaveLength(0); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("samplesperframe"); + warn.mockRestore(); + }); + + it("survives a marked element whose parent is not an element", async () => { + // `document.documentElement.parentNode` is the Document, which has no `closest`, so the + // nesting check threw a TypeError on a marked root. The sweep's own boundary now catches + // anything thrown; this guard is what keeps the element's own message the honest one. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + document.documentElement.setAttribute("data-hf-motion-blur", ""); + try { + const target = declare(null); + + expect(() => target.register()).not.toThrow(); + await target.settle(); + + // The poll reached its deadline, which it could only do if it was installed at all. + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("no composition registered"); + } finally { + document.documentElement.removeAttribute("data-hf-motion-blur"); + warn.mockRestore(); + } + }); + + it("does not let one unblurrable target abort the author's registration statement", async () => { + // The write trap runs the sweep INSIDE `window.__timelines[id] = tl`, so a throw here + // would take the rest of the author's composition script with it. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + const host = window as unknown as { attachMotionBlur: (...args: unknown[]) => void }; + const real = host.attachMotionBlur; + host.attachMotionBlur = () => { + throw new Error("no"); + }; + + expect(() => target.register()).not.toThrow(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("could not blur"); + + host.attachMotionBlur = real; + warn.mockRestore(); + }); + + it.each([ + ['{"shutterAngle": "720deg"}', "shutterAngle"], + ['{"shutterPhase": "-360deg"}', "shutterPhase"], + ['{"fps": {"n": 30}}', "fps"], + ])("refuses %s, whose value the shutter cannot use", async (value, name) => { + // Checking the key name and not the value left the worse half of the same typo: a + // non-numeric angle hides the smear, and a non-numeric phase seeks every copy to NaN, + // which under real GSAP parks 17 copies at the timeline's start position. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(value); + + target.register(); + await target.settle(); + + expect(target.groups()).toHaveLength(0); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("needs a number for " + name); + warn.mockRestore(); + }); + + it("installs no tracker for a call that blurs nothing", async () => { + // A second call on an already-blurred element used to install another tracker, and a + // tracker runs the whole sample loop on every seek over a set it does not own. + const target = declare(""); + target.register(); + expect(target.trackers()).toBe(1); + + const attachBlur = ( + window as unknown as { attachMotionBlur: (s: unknown, t: unknown, o?: unknown) => void } + ).attachMotionBlur; + attachBlur(target.word, target.tl, {}); + attachBlur("#nothing-matches-this", target.tl, {}); + + expect(target.trackers()).toBe(1); + expect(target.groups()).toHaveLength(1); + }); + + it("leaves no copies and no mark behind when an attach throws partway", async () => { + // The sweep's boundary stops a throw from aborting the author's registration statement, + // but the element must not be left marked-attached with a group and no tracker: that is + // sharp forever, with no retry, and the copies still in the DOM. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const observers = installResizeObserver(); + const target = declare(""); + const doomed = makeTimeline(() => { + throw new Error("no tracker for you"); + }); + + target.register(COMPOSITION, doomed.tl); + + expect(target.groups()).toHaveLength(0); + expect(document.querySelectorAll("[data-hf-motion-blur-group]")).toHaveLength(0); + expect(observers.live()).toBe(0); + expect(String(warn.mock.calls[0]?.[0])).toContain("could not blur"); + + // The mark is given back, so the imperative form can still rescue the element. The + // DECLARATIVE path does not retry: the sweep records the failure as skipped, which + // `handled()` reads, and a throw is reported once rather than retried for eight seconds. + const rescue = makeTimeline(); + const attachBlur = ( + window as unknown as { attachMotionBlur: (s: unknown, t: unknown, o?: unknown) => void } + ).attachMotionBlur; + attachBlur(target.word, rescue.tl, {}); + + expect(target.groups()).toHaveLength(1); + expect(rescue.trackers()).toBe(1); + expect(observers.live()).toBe(1); + warn.mockRestore(); + }); + + it("warns when one call names compositions at different frame rates", async () => { + // One seek loop drives the whole call, so the shutter window is one length. Two roots + // at different rates cannot both be right, and silently using the first is what the + // document-wide lookup used to do. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(null, COMPOSITION, FPS); + const other = document.createElement("div"); + other.setAttribute("data-composition-id", "a-faster-composition"); + other.setAttribute("data-fps", String(FPS * 2)); + document.body.appendChild(other); + const fast = document.createElement("div"); + Object.defineProperty(fast, "offsetWidth", { value: 10 }); + Object.defineProperty(fast, "offsetHeight", { value: 10 }); + other.appendChild(fast); + + const { tl } = makeTimeline(); + const attachBlur = ( + window as unknown as { attachMotionBlur: (s: unknown, t: unknown, o?: unknown) => void } + ).attachMotionBlur; + attachBlur([target.word, fast], tl, {}); + + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("different frame rates"); + warn.mockRestore(); + }); + + it("does not blame the composition for a nested target claimed on the last poll tick", async () => { + // `refuse` returns null, so returning it from the nesting branch pushes the element into + // `pending`. Invisible on every tick but the last, which is the one the deadline warning + // reads, so the refusal arrives with a second line blaming a composition that did register. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(null); + target.register(); + await vi.advanceTimersByTimeAsync(249 * 32); + + const outer = document.createElement("div"); + outer.setAttribute("data-hf-motion-blur", ""); + Object.defineProperty(outer, "offsetWidth", { value: 40 }); + Object.defineProperty(outer, "offsetHeight", { value: 40 }); + const inner = document.createElement("span"); + inner.setAttribute("data-hf-motion-blur", ""); + Object.defineProperty(inner, "offsetWidth", { value: 10 }); + Object.defineProperty(inner, "offsetHeight", { value: 10 }); + outer.appendChild(inner); + target.root.appendChild(outer); + + await vi.advanceTimersByTimeAsync(POLL_BUDGET_MS); + + const lines = warn.mock.calls.map((call) => String(call[0])); + expect(lines.filter((line) => line.includes("inside another"))).toHaveLength(1); + expect(lines.filter((line) => line.includes("no composition registered"))).toHaveLength(0); + warn.mockRestore(); + }); + + it("takes the frame rate from an element it attached, not the first one it was handed", async () => { + // `targets[0]` can be an element this call does NOT blur, because an already-blurred + // element is skipped. Reading its root gives the whole call the wrong window length. + const faster = document.createElement("div"); + faster.setAttribute("data-composition-id", "already-blurred-and-faster"); + faster.setAttribute("data-fps", String(FPS * 2)); + document.body.appendChild(faster); + const done = document.createElement("div"); + faster.appendChild(done); + + const target = declare("", COMPOSITION, FPS); + const attachBlur = ( + window as unknown as { attachMotionBlur: (s: unknown, t: unknown, o?: unknown) => void } + ).attachMotionBlur; + attachBlur(done, makeTimeline().tl, {}); + + attachBlur([done, target.word], target.tl, {}); + target.fire(); + await Promise.resolve(); + + const group = [...document.querySelectorAll("[data-hf-motion-blur-group]")].at(-1); + if (!group) throw new Error("motion-blur group was not created"); + const { trailing, leading } = windowEdges(copyOffsets([...group.children] as HTMLElement[])); + + expect(trailing).toBeCloseTo(-reference.trailingDisplacementPx, 1); + expect(leading).toBeCloseTo(reference.leadingDisplacementPx, 1); + }); + + it("puts no copies in the page when the style walk itself throws", async () => { + // The rollback can only remove records `attachOne` RETURNED. A throw between the insert + // and the return leaves a group nothing owns, and the element unmarked, so a later + // trigger inserts a second one. Inserting after the snapshot is what closes that. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const target = declare(""); + const real = globalThis.getComputedStyle; + globalThis.getComputedStyle = ((element: Element) => { + if (element === target.word) throw new Error("no styles for you"); + return real(element); + }) as typeof globalThis.getComputedStyle; + + try { + target.register(); + + expect(document.querySelectorAll("[data-hf-motion-blur-group]")).toHaveLength(0); + expect(String(warn.mock.calls[0]?.[0])).toContain("could not blur"); + } finally { + globalThis.getComputedStyle = real; + warn.mockRestore(); + } + }); +}); diff --git a/registry/components/motion-blur/demo.html b/registry/components/motion-blur/demo.html index 6dadcef903f..8a8eac71b27 100644 --- a/registry/components/motion-blur/demo.html +++ b/registry/components/motion-blur/demo.html @@ -201,8 +201,8 @@
-
-
DRIFT
+
+
DRIFT
slow
@@ -213,8 +213,8 @@
-
-
GLIDE
+
+
GLIDE
medium
@@ -225,8 +225,8 @@
-
-
RUSH
+
+
RUSH
fast
@@ -239,8 +239,8 @@
-
-
BLAST
+
+
BLAST
faster
@@ -251,23 +251,55 @@
-
-
WARP
+
+
WARP
extreme
diff --git a/registry/components/shutter-slam/demo.html b/registry/components/shutter-slam/demo.html index 8ab138923b6..49c51c7f5da 100644 --- a/registry/components/shutter-slam/demo.html +++ b/registry/components/shutter-slam/demo.html @@ -88,16 +88,48 @@