feat(cli): tell authors in check why motion blur is off (#5992)

Blur that turns itself off is silent in the output, so an author who asked for
data-motion-blur="on" cannot tell. Have hyperframes check read the runtime's
decision through probe() and the reason codes from the previous change.

check reports motion_blur_off as a warning with a fix hint for a root that
asked for blur and got none (an iframe in the root gets its own hint),
motion_blur_no_movers when nothing moves enough to blur, and
motion_blur_unverified when the check browser has no html-in-canvas to decide
with. The console echo of the same message is dropped so a finding is not
shown twice.

check asks the runtime which root wants blur instead of reading attributes
itself, so data-root="true" is honoured, and after probing it suspends blur
painting for the rest of the session: its audits read plain DOM, which removes
the roughly 10x slowdown of auditing a wrapped composition. It never sets the
page's off switch, which would change the probe's answer.

Tests drive a finding from the page to the check output, with its fix and
without its echo.

Folded review fixes: R9 (check hint), R12, R13 (check half), T6.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-10-21 15:07:08 -07:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 38f6e01c2b
commit 4371981b74
5 changed files with 424 additions and 7 deletions
+153
View File
@@ -1380,3 +1380,156 @@ describe("component output membership", () => {
);
});
});
describe("motion blur findings in the check output (T6)", () => {
type Handler = (message: ReturnType<typeof fakeConsoleMessage>) => void;
const ACCELERATED = {
state: "off",
reason: "a WebGL canvas cannot be captured",
reasonCode: "accelerated-canvas",
movers: 0,
};
afterEach(() => {
Reflect.deleteProperty(window, "__hfMotionBlur");
});
async function checkWith(
report: object,
options: { cores?: number; echo?: [string, string][]; mount?: () => void } = {},
) {
vi.mocked(os.availableParallelism).mockReturnValue(options.cores ?? 1);
mountCanvasFixture(`<p id="copy" data-layout-name="copy">Hi</p>`);
options.mount?.();
const handlers: Handler[] = [];
const log = (type: string, text: string) =>
handlers.forEach((handler) => handler(fakeConsoleMessage(type, text)));
Reflect.set(window, "__hfMotionBlur", {
probe: () => {
for (const [type, text] of options.echo ?? []) log(type, text);
return report;
},
suspend: vi.fn(),
});
const browser = Object.assign(Object.create(null), {
close: vi.fn(async () => undefined),
process: () => ({ exitCode: 0, signalCode: null }),
});
let opened = 0;
vi.mocked(openSettledCompositionPage).mockImplementation(
async (_html: string, _url: string, settle: OpenSettledCompositionPageOptions) => {
const page = fakePage();
const first = ++opened === 1;
page.on = vi.fn((event: string, handler: Handler) => {
if (event === "console" && first) handlers.push(handler);
});
settle.onBrowser?.(browser);
await settle.beforeNavigate?.(page);
return { page, browser, renderReadyTimedOut: false };
},
);
const result = await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, contrast: false },
{ kind: "none" },
runAuditGrid,
);
return { findings: result.runtimeFindings, pages: opened };
}
const blurCodes = (findings: { code: string }[]) =>
findings.map((finding) => finding.code).filter((code) => code.startsWith("motion_blur"));
it("carries a blur finding, its fix and its anchor into the output", async () => {
const { findings } = await checkWith(ACCELERATED);
const [finding, ...rest] = findings.filter(({ code }) => code === "motion_blur_off");
expect(rest).toEqual([]);
expect(finding).toMatchObject({
severity: "warning",
message: expect.stringContaining("a WebGL canvas cannot be captured"),
selector: expect.any(String),
fixHint: expect.stringMatching(/WebGL\/WebGPU.*sub-composition/),
});
});
it("anchors the finding on the root the runtime picks, not the first composition (W4)", async () => {
const mount = (): void => {
document.body.innerHTML = `
<div id="aux" data-composition-id="aux" data-duration="4"></div>
<div id="real" data-composition-id="main" data-root="true" data-duration="10"></div>`;
const rect = { x: 3, y: 4, width: 640, height: 360 } as DOMRect;
const real = document.getElementById("real");
if (real) real.getBoundingClientRect = () => rect;
};
const { findings } = await checkWith(ACCELERATED, { mount });
expect(findings.find(({ code }) => code === "motion_blur_off")).toMatchObject({
selector: '[data-composition-id][data-root="true"]',
dataAttributes: expect.objectContaining({
"data-root": "true",
"data-composition-id": "main",
}),
bbox: { x: 3, y: 4, width: 640, height: 360 },
});
});
it("still carries it, once, when a second Chrome shares the sampling", async () => {
const { findings, pages } = await checkWith(ACCELERATED, { cores: 4 });
expect(pages).toBe(2);
expect(blurCodes(findings)).toEqual(["motion_blur_off"]);
});
it("keeps an unrelated console warning and drops the runtime's duplicate of the finding", async () => {
const { findings } = await checkWith(ACCELERATED, {
echo: [
["warn", "[motion-blur] off: a WebGL canvas cannot be captured"],
["warn", "your own warning"],
],
});
const warnings = findings.filter(({ code }) => code === "console_warning");
expect(warnings.map(({ message }) => message)).toEqual(["your own warning"]);
expect(blurCodes(findings)).toEqual(["motion_blur_off"]);
});
it("keeps the runtime's off-line when there is no finding to replace it", async () => {
const { findings } = await checkWith(
{ state: "on", reason: null, reasonCode: null, movers: 2 },
{ echo: [["warn", "[motion-blur] off: later, for a reason the probe did not see"]] },
);
expect(findings.map(({ code }) => code)).toContain("console_warning");
expect(blurCodes(findings)).toEqual([]);
});
it("does not drop an error that merely starts like the echo", async () => {
const { findings } = await checkWith(ACCELERATED, {
echo: [["error", "[motion-blur] off: thrown by the page itself"]],
});
expect(findings.map(({ code }) => code)).toContain("console_error");
});
it("says nothing moves only when the report says no mover, not when it says some", async () => {
const still = await checkWith({ state: "on", reason: null, reasonCode: null, movers: 0 });
expect(blurCodes(still.findings)).toEqual(["motion_blur_no_movers"]);
const moving = await checkWith({ state: "on", reason: null, reasonCode: null, movers: 3 });
expect(blurCodes(moving.findings)).toEqual([]);
const off = await checkWith(ACCELERATED);
expect(blurCodes(off.findings)).toEqual(["motion_blur_off"]);
});
it("treats a report without a mover count as no movers", async () => {
const { findings } = await checkWith({ state: "on", reason: null, reasonCode: null });
expect(blurCodes(findings)).toEqual(["motion_blur_no_movers"]);
});
it("adds nothing for a page that never opted in", async () => {
vi.mocked(os.availableParallelism).mockReturnValue(1);
mountCanvasFixture();
const page = fakePage();
installSessionMock(page);
const result = await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false },
{ kind: "none" },
runAuditGrid,
);
expect(blurCodes(result.runtimeFindings)).toEqual([]);
});
});
+15 -7
View File
@@ -1,5 +1,6 @@
import type { CheckDeadline } from "./checkDeadline.js";
import { BrowserCleanupError, closeManagedBrowser } from "../browser/launch.js";
import { collectMotionBlurDrafts, dropMotionBlurConsoleEcho } from "./checkMotionBlur.js";
import { checkSnapshotDirectory } from "./checkSnapshotDirectory.js";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import os from "node:os";
@@ -81,6 +82,7 @@ interface RuntimeDraft {
line?: number;
count?: number;
abortedImage?: boolean;
fixHint?: string;
}
type RuntimeStamp = Pick<RuntimeDraft, "time" | "timeRange">;
@@ -297,6 +299,8 @@ export async function runBrowserCheck(
const message = tweenTailMessage(tail);
drafts.push({ code: "gsap_tween_tail", severity: "warning", message, time: tail.later.end });
}
const blur = await collectMotionBlurDrafts(page);
drafts.push(...blur);
const sampleLog = sampleWindow(drafts);
const driver = createPageDriver(page, clock, sampleLog);
const result = await runGrid(driver, options, motion, helpers);
@@ -308,7 +312,7 @@ export async function runBrowserCheck(
...result,
timings: { ...result.timings, launchSettleMs },
runtimeFindings: dropFailedProxyEchoes(
keepBrokenImageAborts(merged, broken),
dropMotionBlurConsoleEcho(keepBrokenImageAborts(merged, broken), blur),
failedPaths,
).map((draft) => runtimeFinding(draft, rootAnchor)),
};
@@ -1352,20 +1356,23 @@ function anchorsInPage(values: AnchorRequest[]): CheckAnchor[] {
}
async function resolveRootAnchor(page: Page): Promise<CheckAnchor> {
const selector = await page.evaluate(
(explicit) => (document.querySelector(explicit) ? explicit : "[data-composition-id]"),
'[data-composition-id][data-root="true"]',
);
const anchors = await resolveAnchors(page, [
{ selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) },
{ selector, time: 0, bbox: await compositionBbox(page, selector) },
]);
return anchors[0] ?? fallbackAnchor(undefined);
}
async function compositionBbox(page: Page): Promise<CheckBbox> {
return page.evaluate(() => {
const element = document.querySelector("[data-composition-id]");
const rect = element?.getBoundingClientRect();
async function compositionBbox(page: Page, selector: string): Promise<CheckBbox> {
return page.evaluate((root) => {
const rect = document.querySelector(root)?.getBoundingClientRect();
return rect
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
: { x: 0, y: 0, width: 0, height: 0 };
});
}, selector);
}
async function collectContrast(
@@ -1835,6 +1842,7 @@ function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding {
...(draft.timeRange ? { timeRange: draft.timeRange } : {}),
url: draft.url,
line: draft.line,
...(draft.fixHint ? { fixHint: draft.fixHint } : {}),
};
}
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { readMotionBlurProbe } from "./checkMotionBlur.js";
const report = { state: "off", reason: "why", reasonCode: "root-transform", movers: 3 };
function page(html: string, withApi = true, order: string[] = []) {
document.body.innerHTML = html;
const probe = vi.fn(() => {
order.push("probe");
return report;
});
const suspend = vi.fn(() => order.push("suspend"));
if (withApi) Reflect.set(window, "__hfMotionBlur", { probe, suspend });
return Object.assign(probe, { suspend });
}
afterEach(() => {
Reflect.deleteProperty(window, "__hfMotionBlur");
document.body.innerHTML = "";
});
const MAIN = '<div data-composition-id="main" data-motion-blur="on"></div>';
const TWO_COMPOSITIONS =
'<div data-composition-id="empty"></div>' +
'<div data-composition-id="main" data-root="true" data-motion-blur="on"></div>';
describe("readMotionBlurProbe", () => {
it("calls the runtime probe and returns its report", () => {
const probe = page(MAIN);
expect(readMotionBlurProbe()).toEqual(report);
expect(probe).toHaveBeenCalledTimes(1);
});
it("asks the runtime, not the first composition's attribute, which root wants blur", () => {
const probe = page(TWO_COMPOSITIONS);
expect(readMotionBlurProbe()).toEqual(report);
expect(probe).toHaveBeenCalledTimes(1);
});
it("suspends blur painting right after the probe, so the audits read plain DOM", () => {
const order: string[] = [];
page(MAIN, true, order);
readMotionBlurProbe();
expect(order).toEqual(["probe", "suspend"]);
});
it("still reads the probe from a runtime that has no suspend", () => {
document.body.innerHTML = MAIN;
Reflect.set(window, "__hfMotionBlur", { probe: () => report });
expect(readMotionBlurProbe()).toEqual(report);
});
it("takes the mover count from the report, and reads a missing one as none", () => {
Reflect.set(window, "__hfMotionBlur", { probe: () => ({ state: "on", movers: 7 }) });
expect(readMotionBlurProbe()).toMatchObject({ movers: 7 });
Reflect.set(window, "__hfMotionBlur", { probe: () => ({ state: "on" }) });
expect(readMotionBlurProbe()).toMatchObject({ state: "on", movers: 0, reason: null });
});
it("reads nothing when the runtime installed no instance, as for a composition that never opted in", () => {
page('<div data-composition-id="main"></div>', false);
expect(readMotionBlurProbe()).toBeNull();
});
});
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
type MotionBlurProbe,
dropMotionBlurConsoleEcho,
motionBlurDrafts,
} from "./checkMotionBlur.js";
const off = (reasonCode: string | null, reason = "because"): MotionBlurProbe => ({
state: "off",
reason,
reasonCode,
movers: 0,
});
describe("motionBlurDrafts", () => {
it("says nothing without an opt-in to read, or when blur works", () => {
expect(motionBlurDrafts(null)).toEqual([]);
expect(motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 2 })).toEqual(
[],
);
expect(motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 1 })).toEqual(
[],
);
});
it.each([
[
"accelerated-canvas",
/in sub-compositions too.*render that effect separately.*drop data-motion-blur/,
],
["backface-visibility", /mirrored.*3D flip/],
["iframe-in-root", /<iframe>, <object>, <embed> or custom element out of the composition root/],
["root-transform", /child element instead of the composition root/],
["root-opacity", /child element instead of the composition root/],
["root-blend-mode", /mix-blend-mode on a child element/],
["nested-capture-canvas", /inner <canvas layoutsubtree>/],
["something-new", /drop data-motion-blur/],
])("warns with a concrete fix for %s", (code, hint) => {
const [finding, ...rest] = motionBlurDrafts(off(code, "the reason text"));
expect(rest).toEqual([]);
expect(finding).toMatchObject({
code: "motion_blur_off",
severity: "warning",
message: 'data-motion-blur="on" is set but blur is off: the reason text',
});
expect(finding?.fixHint).toMatch(hint);
});
it("only informs, once, when the browser cannot run html-in-canvas", () => {
const drafts = motionBlurDrafts(off("unsupported-browser", "html-in-canvas is unavailable"));
expect(drafts).toHaveLength(1);
expect(drafts[0]).toMatchObject({ code: "motion_blur_unverified", severity: "info" });
expect(drafts[0]?.message).toContain("could not be evaluated in this browser");
});
it("informs when blur is on but nothing moves", () => {
expect(
motionBlurDrafts({ state: "on", reason: null, reasonCode: null, movers: 0 }),
).toMatchObject([{ code: "motion_blur_no_movers", severity: "info" }]);
});
});
describe("dropMotionBlurConsoleEcho", () => {
const echo = { code: "console_warning", message: "[motion-blur] off: because" };
const other = { code: "console_warning", message: "something else" };
const found = motionBlurDrafts(off("root-transform"));
it("drops the runtime's own off-line once a finding says it", () => {
expect(dropMotionBlurConsoleEcho([echo, other], found)).toEqual([other]);
});
it("leaves every console warning alone when there is no finding", () => {
expect(dropMotionBlurConsoleEcho([echo, other], [])).toEqual([echo, other]);
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { Page } from "puppeteer-core";
import type { CheckSeverity } from "./checkTypes.js";
/** What the core runtime's `__hfMotionBlur.probe()` reports for a root that asked for blur. */
export interface MotionBlurProbe {
state: "on" | "off";
reason: string | null;
reasonCode: string | null;
movers: number;
}
export interface MotionBlurDraft {
code: string;
severity: CheckSeverity;
message: string;
fixHint: string;
time: number;
}
const OFF_HINTS: Record<string, string> = {
"accelerated-canvas":
"html-in-canvas captures WebGL/WebGPU frozen, in sub-compositions too: render that effect separately or flatten it out of the page, or drop data-motion-blur on this composition",
"backface-visibility":
"backface-visibility: hidden inside a 3D transform is drawn mirrored; avoid the 3D flip or drop motion blur",
"iframe-in-root":
"move the <iframe>, <object>, <embed> or custom element out of the composition root (each is re-initialised when blur wraps the root), or drop motion blur",
"root-transform": "animate a child element instead of the composition root, or drop motion blur",
"root-opacity": "animate a child element instead of the composition root, or drop motion blur",
"root-blend-mode":
"set mix-blend-mode on a child element instead of the composition root, or drop motion blur",
"nested-capture-canvas":
"remove the inner <canvas layoutsubtree> / content=drawable, or drop motion blur",
};
const GENERIC_OFF_HINT = "remove what the reason names, or drop data-motion-blur";
const ASKED = 'data-motion-blur="on" is set but';
/** The findings for one probe; null (no opt-in, or nothing to read) gives none. */
export function motionBlurDrafts(probe: MotionBlurProbe | null): MotionBlurDraft[] {
if (!probe) return [];
if (probe.reasonCode === "unsupported-browser") {
return [
{
code: "motion_blur_unverified",
severity: "info",
message: `${ASKED} blur could not be evaluated in this browser`,
fixHint:
"the check browser has no html-in-canvas (drawElementImage); render once to see whether blur applies",
time: 0,
},
];
}
if (probe.state === "off") {
return [
{
code: "motion_blur_off",
severity: "warning",
message: `${ASKED} blur is off: ${probe.reason ?? "unknown reason"}`,
fixHint: OFF_HINTS[probe.reasonCode ?? ""] ?? GENERIC_OFF_HINT,
time: 0,
},
];
}
if (probe.movers === 0) {
return [
{
code: "motion_blur_no_movers",
severity: "info",
message: `${ASKED} nothing in the composition moves fast enough to blur, so it has no effect`,
fixHint:
"animate an element's transform or position by 2+ px per frame, or drop data-motion-blur",
time: 0,
},
];
}
return [];
}
// Serialized into the page; the guards must stay one function.
// fallow-ignore-next-line complexity
export function readMotionBlurProbe(): MotionBlurProbe | null {
const api: unknown = Reflect.get(window, "__hfMotionBlur");
if (typeof api !== "object" || api === null) return null;
const probe: unknown = Reflect.get(api, "probe");
if (typeof probe !== "function") return null;
const report: unknown = Reflect.apply(probe, api, []);
if (typeof report !== "object" || report === null) return null;
const reason: unknown = Reflect.get(report, "reason");
const code: unknown = Reflect.get(report, "reasonCode");
const movers: unknown = Reflect.get(report, "movers");
const suspend: unknown = Reflect.get(api, "suspend");
if (typeof suspend === "function") Reflect.apply(suspend, api, []);
return {
state: Reflect.get(report, "state") === "off" ? "off" : "on",
reason: typeof reason === "string" ? reason : null,
reasonCode: typeof code === "string" ? code : null,
movers: typeof movers === "number" ? movers : 0,
};
}
/** Ask the page to decide blur now. The runtime exposes the global only for a root that asked for blur. */
export async function collectMotionBlurDrafts(page: Page): Promise<MotionBlurDraft[]> {
const probe = await page.evaluate(readMotionBlurProbe).catch(() => null);
return motionBlurDrafts(probe);
}
/** The runtime also warns on the console when a scan turns blur off; the finding already says it. */
export function dropMotionBlurConsoleEcho<T extends { code: string; message: string }>(
drafts: T[],
found: MotionBlurDraft[],
): T[] {
if (found.length === 0) return drafts;
return drafts.filter(
(d) => !(d.code === "console_warning" && d.message.startsWith("[motion-blur] off:")),
);
}