Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,10 @@ compliant colour you could use instead.

Severity is persistence-aware: a finding at a single sample demotes to info, a
finding that persists gates the exit code, and a timeline that never moves on a
composition of 3s or more fails with `sweep_static`.
composition of 3s or more fails with `sweep_static`. When only audio advanced
and nothing on screen moved, `sweep_static` is a warning instead (`--strict`
fails the run on it); add `data-no-timeline` to a composition that is meant to
be still.

{/* VISUAL: a `check --snapshots` overview frame beside one finding crop, with the labelled finding box visible on both */}

Expand Down Expand Up @@ -753,13 +756,19 @@ HTML. Without a sidecar, nothing changes.

`keepsMoving` uses the same motion classifier as the frozen-sweep guard: box
geometry, opacity, text and generated content, form-control state, painted CSS
counters, clip-path, variable-font axes, and the pixels of visible
canvas/video/img elements all count as motion — so a playing same-origin (or
CORS-readable) background video keeps a scope live on its own. Elements under
counters, clip-path, variable-font axes, filters, colors, backgrounds, drawn
borders and shadows, the pixels of visible canvas/video/img elements, and video
playback time all count as motion — so a playing background video keeps a scope
live on its own, even a color-graded one whose picture is drawn into a canvas.
Audio playback time never counts here: it shows the timeline ran, but it is not
a moving picture. The frozen-sweep guard turns an audio-only advance into a
warning rather than a failure. Elements under
`data-layout-ignore` inside the scope never count; the opt-out does not apply to
the scope element itself, since naming it in an assertion outranks it. Content
the browser does not paint (`display: none` subtrees, `visibility: hidden`
boxes, skipped `content-visibility` contents) never counts as motion either.
boxes, skipped `content-visibility` contents) never counts as motion either,
except a video's playback time, since the runtime may draw that video's
picture elsewhere.

`duration`, `keepsMoving.withinSelector`, and `keepsMoving.maxStaticSec` are
optional. Findings use the same shape and envelope as layout findings and are
Expand Down
30 changes: 29 additions & 1 deletion packages/cli/src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1512,11 +1512,39 @@ describe("check pipeline", () => {
(finding) =>
finding.code === "sweep_static" &&
finding.severity === "error" &&
finding.message.includes("did not advance"),
finding.message.includes("did not advance") &&
finding.fixHint?.includes("data-no-timeline"),
),
).toBe(true);
});

it("warns, without failing, when only the audio advanced and nothing on screen moved", async () => {
let call = 0;
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
collectLayoutGeometry: vi.fn(async () => `still\u001f${call++}`),
});
const { report } = await runScenario(driver);

const sweep = report.layout.findings.filter((finding) => finding.code === "sweep_static");
expect(sweep.map((finding) => [finding.severity, finding.message])).toEqual([
["warning", "Only the audio advanced under seek; nothing on screen moved."],
]);
expect(sweep[0]?.fixHint).toContain("data-no-timeline");
expect(report.ok).toBe(true);
});

it("does not flag a sweep where something on screen moved", async () => {
let call = 0;
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
collectLayoutGeometry: vi.fn(async () => `frame${call++}\u001f0`),
});
const { report } = await runScenario(driver);

expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
});

it("does not flag --at times the user picked on a still end card", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 53.7),
Expand Down
45 changes: 37 additions & 8 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,15 @@
};
}

function capitalizeWords(text) {
const words = new Intl.Segmenter(undefined, { granularity: "word" }).segment(text);
return Array.from(words, ({ segment, isWordLike }) => {
if (!isWordLike) return segment;
const [first, ...rest] = segment;
return first.toUpperCase() + rest.join("");
}).join("");
}

function horizontalTextMetrics(element, style) {
if (style.writingMode && style.writingMode !== "horizontal-tb") return null;
const context = document.createElement("canvas").getContext("2d");
Expand All @@ -479,6 +488,7 @@
let text = textContentFor(element, true);
if (style.textTransform === "uppercase") text = text.toUpperCase();
if (style.textTransform === "lowercase") text = text.toLowerCase();
if (style.textTransform === "capitalize") text = capitalizeWords(text);
const metrics = context.measureText(text);
return metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent > 0 ? metrics : null;
}
Expand All @@ -490,19 +500,24 @@
);
}

// The glyphs' own extent inside a Range rect (the font's content area); font metrics scale with the rect.
function inkRect(rect, metrics) {
const scale = rect.height / (metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent);
const top =
rect.top + (metrics.fontBoundingBoxAscent - metrics.actualBoundingBoxAscent) * scale;
const bottom =
rect.bottom - (metrics.fontBoundingBoxDescent - metrics.actualBoundingBoxDescent) * scale;
return { ...rect, top, bottom, height: bottom - top };
}

function visibleTextLineRects(element, rects, style, clip, tolerance) {
const metrics = horizontalTextMetrics(element, style);
const fontHeight = metrics ? metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent : 0;
const lineHeight = parsePx(style.lineHeight) || fontHeight;
return rects.flatMap((rect) => {
if (!metrics) return intersectsTextWindow(rect, clip, tolerance) ? [rect] : [];
const scale = rect.height / fontHeight;
const inkTop =
rect.top + (metrics.fontBoundingBoxAscent - metrics.actualBoundingBoxAscent) * scale;
const inkBottom =
rect.bottom - (metrics.fontBoundingBoxDescent - metrics.actualBoundingBoxDescent) * scale;
const ink = { ...rect, top: inkTop, bottom: inkBottom };
if (!intersectsTextWindow(ink, clip, tolerance)) return [];
if (!intersectsTextWindow(inkRect(rect, metrics), clip, tolerance)) return [];
// Negative leading belongs outside the used line box. Font metrics scale
// with the Range rect, so zoomed cards retain the same clipping decision.
const inset = Math.max(0, (rect.height - lineHeight * scale) / 2);
Expand Down Expand Up @@ -718,6 +733,20 @@
return total;
}

function overlapsByAFifth(a, b) {
return fragmentIntersectionArea(a, b) > Math.min(rectsArea(a), rectsArea(b)) * 0.2;
}

// Collision is judged on the glyphs: at line-height < 1 the content areas of stacked lines overlap
// while the words do not touch. Measured only for pairs whose content areas already overlap.
function glyphRects(block) {
if (!block.glyphRects) {
const metrics = horizontalTextMetrics(block.element, getComputedStyle(block.element));
block.glyphRects = metrics ? block.rects.map((rect) => inkRect(rect, metrics)) : block.rects;
}
return block.glyphRects;
}

function isNested(a, b) {
return a.contains(b) || b.contains(a);
}
Expand Down Expand Up @@ -753,8 +782,8 @@
function overlapIssue(a, b, time) {
if (isNested(a.element, b.element)) return null;
if (isManagedFlowOverlap(a.element, b.element)) return null;
const area = fragmentIntersectionArea(a.rects, b.rects);
if (area <= Math.min(rectsArea(a.rects), rectsArea(b.rects)) * 0.2) return null;
if (!overlapsByAFifth(a.rects, b.rects)) return null;
if (!overlapsByAFifth(glyphRects(a), glyphRects(b))) return null;
return {
// Warning at the per-sample level: a single-sample overlap is usually an
// entrance/exit transient (two blocks crossing mid-animation), not a real
Expand Down
79 changes: 67 additions & 12 deletions packages/cli/src/commands/layout-audit.chromium.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ declare global {
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
const script = readFileSync(new URL("./layout-audit.browser.js", import.meta.url), "utf8");

const heading = (top: number, text: string, style = "") =>
`<h1 style="position:absolute;left:100px;top:${top}px;margin:0;font:120px/1 Arial;${style}">${text}</h1>`;
const WORDS_08 =
'<h1 style="position:absolute;left:120px;top:200px;width:900px;margin:0;font:700 140px/0.8 Arial"><span style="display:inline-block;margin-right:.25em">Launch</span><span style="display:inline-block;margin-right:.25em">faster</span><span style="display:inline-block;margin-right:.25em">ship</span><span style="display:inline-block;margin-right:.25em">sooner</span></h1>';
const WORDS_04 =
'<h1 style="position:absolute;left:120px;top:200px;width:900px;margin:0;font:700 140px/0.4 Arial"><span style="display:inline-block;margin-right:.25em">Launch</span><span style="display:inline-block;margin-right:.25em">faster</span><span style="display:inline-block;margin-right:.25em">ship</span><span style="display:inline-block;margin-right:.25em">sooner</span></h1>';

describe.runIf(executablePath)("layout audit in Chromium", () => {
let browser: Browser;
beforeAll(async () => {
Expand All @@ -20,6 +27,20 @@ describe.runIf(executablePath)("layout audit in Chromium", () => {
await browser?.close();
});

async function auditCodes(body: string): Promise<string[]> {
const page = await browser.newPage();
try {
await page.setContent(`<body style="margin:0">${body}</body>`);
await page.addScriptTag({ content: script });
const issues = await page.evaluate(() =>
window.__hyperframesLayoutAudit({ time: 1, tolerance: 2 }),
);
return issues.map((issue) => issue.code);
} finally {
await page.close();
}
}

it.each([
{ name: "line-height 1", css: "height:120px", textStyle: "", error: false },
{ name: "line-height .9", css: "height:108px;line-height:.9", textStyle: "", error: false },
Expand Down Expand Up @@ -62,21 +83,55 @@ describe.runIf(executablePath)("layout audit in Chromium", () => {
error: true,
},
])("handles $name", async ({ css, textStyle, error }) => {
const page = await browser.newPage();
try {
await page.setContent(`<body style="margin:0">
const codes = await auditCodes(`
<div data-composition-id="main" data-width="1000" data-height="800" style="width:1000px;height:800px">
<div style="position:absolute;left:100px;top:100px;width:400px;overflow:hidden;font:120px/1 Arial;${css}">
<div style="${textStyle}">HELLO</div>
</div>
</div></body>`);
await page.addScriptTag({ content: script });
const issues = await page.evaluate(() =>
window.__hyperframesLayoutAudit({ time: 1, tolerance: 2 }),
);
expect(issues.some((issue) => issue.code === "text_box_overflow")).toBe(error);
} finally {
await page.close();
}
</div>`);
expect(codes.includes("text_box_overflow")).toBe(error);
});
it.each([
{ name: "inline-block words wrapping at line-height .8", html: WORDS_08, overlap: false },
{
name: "the same words at line-height .4, where the glyphs collide",
html: WORDS_04,
overlap: true,
},
{
name: "two headings 60px apart whose letters collide",
html: heading(100, "HELLO") + heading(160, "WORLD"),
overlap: true,
},
{
name: "capitalized words 60px apart whose capitals collide",
html:
heading(100, "ace", "text-transform:capitalize") +
heading(160, "ace", "text-transform:capitalize"),
overlap: true,
},
{
name: "capitalized words led by punctuation, whose capitals collide",
html:
heading(100, "\u2026ace", "text-transform:capitalize") +
heading(160, "-ace", "text-transform:capitalize"),
overlap: true,
},
{
name: "capitalized words led by a digit, which stay lowercase, whose descenders collide",
html:
heading(100, "4you", "text-transform:capitalize") +
heading(175, "4you", "text-transform:capitalize"),
overlap: true,
},
{
name: "two headings placed on the same spot",
html: '<h1 style="position:absolute;left:100px;top:100px;margin:0;font:120px/1 Arial">HELLO</h1><h1 style="position:absolute;left:110px;top:110px;margin:0;font:120px/1 Arial">WORLD</h1>',
overlap: true,
},
])("judges text overlap on the glyphs: $name", async ({ html, overlap }) => {
const codes = await auditCodes(`
<div data-composition-id="main" data-width="1920" data-height="1080" style="position:relative;width:1920px;height:1080px">${html}</div>`);
expect(codes.includes("content_overlap")).toBe(overlap);
});
});
103 changes: 103 additions & 0 deletions packages/cli/src/commands/motion-signature.browser.chromium.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,109 @@ describe.skipIf(!RUNS_CHROMIUM)("motion-signature.browser in Chromium", () => {
expect(after.sweep).not.toBe(before.sweep);
});

it.each([
["a blur", "#title { color: #fff; }", "filter = 'blur(8px)'"],
["a text color", "#title { color: #fff; }", "color = 'rgb(255, 51, 0)'"],
["a background color", "#title { background: #000; }", "backgroundColor = 'rgb(40, 0, 0)'"],
["a box shadow", "#title { box-shadow: none; }", "boxShadow = '0 0 12px red'"],
])("sees %s tween on a title that never moves", async (_, css, change) => {
await load(composition(css, '<h1 id="title" class="fixed">Title</h1>'));
const before = await sample();
await mutate(`document.getElementById("title").style.${change}`);
const after = await sample();

expect(after.sweep).not.toBe(before.sweep);
expect(after.liveness).not.toBe(before.liveness);
});

it("sees an SVG shape's fill color change", async () => {
await load(
composition(
"",
'<svg width="200" height="100"><rect id="shape" width="120" height="60" fill="#fff" /></svg>',
),
);
const before = await sample();
await mutate('document.getElementById("shape").style.fill = "rgb(255, 51, 0)"');
const after = await sample();

expect(after.sweep).not.toBe(before.sweep);
expect(after.liveness).not.toBe(before.liveness);
});

it("sees the time of a video whose picture is drawn elsewhere advance, in both samplers", async () => {
await load(
composition(
"",
'<h1 class="fixed">Title</h1><video id="media" style="display:none"></video>',
),
);
const before = await sample();
await mutate('document.getElementById("media").currentTime = 4');
const after = await sample();

expect(after.sweep).not.toBe(before.sweep);
expect(after.liveness).not.toBe(before.liveness);
});

it("counts audio time as the timeline running, but never as a moving picture", async () => {
await load(composition("", '<h1 class="fixed">Title</h1><audio id="media"></audio>'));
const before = await sample();
await mutate('document.getElementById("media").currentTime = 4');
const after = await sample();

expect(after.sweep).not.toBe(before.sweep);
expect(after.liveness).toBe(before.liveness);
});

it.each([
["no border", ""],
["a border drawn on one side in its own color", "border-bottom: 2px solid #fff;"],
])(
"ignores a color change on a skipped host with %s, though Blink resolves undrawn border colors from it",
async (_, border) => {
await load(
composition(
`#host { content-visibility: hidden; width: 200px; height: 80px; color: #fff; ${border} }`,
'<div id="host">Title</div>',
),
);
const before = await sample();
await mutate('document.getElementById("host").style.color = "rgb(255, 51, 0)"');
const after = await sample();

expect(after.sweep).toBe(before.sweep);
},
);

it.each([
["border", "border: 4px solid #fff;", "borderColor"],
["outline", "outline: 4px solid #fff;", "outlineColor"],
])("sees a drawn %s change color", async (_, stroke, property) => {
await load(
composition(`#box { width: 120px; height: 60px; ${stroke} }`, '<div id="box"></div>'),
);
const before = await sample();
await mutate(`document.getElementById("box").style.${property} = "rgb(255, 51, 0)"`);
const after = await sample();

expect(after.sweep).not.toBe(before.sweep);
});

it("ignores media time on a data-layout-ignore layer", async () => {
await load(
composition(
"",
'<h1 class="fixed">Title</h1><div data-layout-ignore><audio id="media"></audio></div>',
),
);
const before = await sample();
await mutate('document.getElementById("media").currentTime = 4');
const after = await sample();

expect(after.sweep).toBe(before.sweep);
});

it("sees a clip-path wipe over a box that never moves", async () => {
await load(
composition(
Expand Down
Loading
Loading