Files
hyperframes/packages/cli/src/utils/layoutAudit.test.ts
T
Xuanru Li 7f4eaeb568 feat(cli): coordinate-frame layout findings in check (#2354)
* feat(cli): coordinate-frame layout findings in check

Four production compositions shipped with 100-600px layout drift, each a
different coordinate-frame confusion the check graded info or missed
entirely: viewport pixels written as container left/top, gsap x/y
treated as absolute position, a -350px margin fighting flex centering,
and stage-relative path coords drawn into a nested SVG.

Three new layout findings close the class:
- positioned_out_of_parent: an absolute/fixed element rendering mostly
  outside its positioning ancestor (warning) — the parent needs no
  overflow clipping, which is what let container_overflow miss it.
- box_out_of_canvas: a painted panel breaching the canvas (warning) —
  text is canvas_overflow's, media is frame_out_of_frame's, painted
  boxes were nobody's.
- connector_detached: a connector path whose endpoints land far from
  every anchorable element (warning) — measured coordinates drawn into
  an SVG with a different origin.

canvas_overflow additionally promotes from info to warning when held
across samples AND the breach exceeds 5% of the canvas.

All three are persistence-tiered and respect data-layout-allow-overflow.
Verified against the four incident compositions: every one now surfaces
its drift as held warnings (previously: info or silence).

* fix(cli): harden coordinate-frame findings against review false positives

Reworks all three findings after two-lens review (adversarial FP hunt in
real Chrome + maintainer pass):

- escaped_container (was positioned_out_of_parent): uses offsetParent
  (transform-aware, skips fixed-as-canvas), exempts fully-detached
  callouts within an attachment allowance while still flagging
  touching-but-mostly-outside drift.
- panel_out_of_canvas (was box_out_of_canvas): paint alone qualifies
  (flat solid panels were a false negative), fully off-canvas rects are
  parked entrances and stay silent, pointer-events:none marks decorative
  layers, hero-sized breaches warn while small bleeds stay info.
- connector_detached: endpoints via getPointAtLength + getScreenCTM
  (viewBox, preserveAspectRatio, group transforms, every command type),
  defs/marker/clipPath subtrees skipped, word-boundary connector naming,
  containment tier limited to opaque non-ancestor targets (a text-bearing
  wrapper contains its own diagram's endpoints).
- canvas_overflow promotion requires partial visibility — a fully
  off-canvas rect is a parked entrance, not drift.

Verified: the four incident compositions still surface their drift as
held warnings; the review's false-positive repros (fixed HUD, callout,
parked entrance, corner bleed, marker arrowheads, g-transform and
viewBox-scaled connectors) are clean at warning level. Docs and the CLI
skill reference now describe the coordinate-frame findings.

* fix(cli): panel ownership is geometric — direct-text panels were a silent false negative

A painted panel whose direct text stays in-bounds while its box breaches
the canvas produced neither finding: canvas_overflow measures the text
range and panel_out_of_canvas skipped every own-text element. Skip the
panel finding only when the element's own text ALSO breaches (that
geometry belongs to canvas_overflow); pin the message/fixHint wording of
all three findings with positive assertions; document the SVG-internal
anchor blind spot.

* fix(cli): classify panel decoration by paint kind, not pointer-events

pointer-events:none exempted the framed-painting incident's gold frame
layers — hero content that happens to disable hit-testing. Decoration is
now gradient-only paint (spotlights, textures, vignettes); url() images,
solid fills and borders are content regardless of pointer-events.

* fix(cli): add fixHint to the test-local AuditIssue shape

* fix(cli): gradient stops decide content vs decoration; ownership matches canvas_overflow's tolerance

A gradient with any solid stop (alpha >= 0.6) is content — heroes and
cards painted with linear-gradient were invisible under the blanket
gradient exemption; all-translucent stops (spotlights, vignettes) stay
decoration. The text-ownership check now uses the audit tolerance that
canvas_overflow itself fires at, making the contract strict-mutex: any
text breach past that tolerance cedes the element, so a shallow 20px
text breach no longer double-reports.
2026-07-13 15:35:03 -07:00

354 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
buildLayoutSampleTimes,
buildTransitionSampleTimes,
computeOverflow,
overflowValueClips,
collapseStaticLayoutIssues,
limitLayoutIssues,
mergeSampleTimes,
summarizeLayoutIssues,
formatLayoutIssue,
type LayoutIssue,
} from "./layoutAudit.js";
describe("buildTransitionSampleTimes (#1380)", () => {
it("samples boundaries plus the midpoint of each segment between them", () => {
// The #1380 repro: capA fades out 11.3311.55, capB slams in 11.3511.69.
// The collision window 11.3511.55 only shows both captions half-visible
// away from the exact boundaries — the midpoints land inside it.
const result = buildTransitionSampleTimes({
duration: 20,
boundaries: [11.33, 11.55, 11.35, 11.69],
});
expect(result.times).toEqual([11.33, 11.34, 11.35, 11.45, 11.55, 11.62, 11.69]);
expect(result.dropped).toBe(0);
});
it("drops boundaries outside the composition and dedupes repeats", () => {
const result = buildTransitionSampleTimes({
duration: 10,
boundaries: [2, 2, -1, 10.5, NaN, 4],
});
expect(result.times).toEqual([2, 3, 4]);
expect(result.dropped).toBe(0);
});
it("returns an empty list without a valid duration", () => {
expect(buildTransitionSampleTimes({ duration: 0, boundaries: [1, 2] })).toEqual({
times: [],
dropped: 0,
});
});
it("samples every collected boundary when no cap is given", () => {
const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05);
const result = buildTransitionSampleTimes({ duration: 10, boundaries });
// 200 boundaries + 199 segment midpoints, all distinct after rounding.
expect(result.times.length).toBe(399);
expect(result.dropped).toBe(0);
});
it("caps only on explicit request, reporting the omitted count and keeping the extremes", () => {
const boundaries = Array.from({ length: 200 }, (_, i) => i * 0.05);
const result = buildTransitionSampleTimes({ duration: 10, boundaries, cap: 40 });
expect(result.times.length).toBeLessThanOrEqual(40);
expect(result.dropped).toBe(399 - result.times.length);
expect(result.times[0]).toBe(0);
expect(result.times[result.times.length - 1]).toBeCloseTo(9.95, 3);
});
it("merges with even-spacing samples into one deduplicated ascending list", () => {
expect(mergeSampleTimes([1, 3, 5], [3, 2.5, 7])).toEqual([1, 2.5, 3, 5, 7]);
});
});
describe("layoutAudit helpers", () => {
it("samples the whole duration using stable midpoint timestamps", () => {
expect(buildLayoutSampleTimes({ duration: 10, samples: 5 })).toEqual([1, 3, 5, 7, 9]);
});
it("prefers explicit timestamps and keeps them inside the composition duration", () => {
expect(buildLayoutSampleTimes({ duration: 10, samples: 5, at: [0, 2.5, 12, -1, NaN] })).toEqual(
[0, 2.5],
);
});
it("computes per-side overflow beyond a tolerance", () => {
const overflow = computeOverflow(
{ left: 88, top: 102, right: 231, bottom: 181, width: 143, height: 79 },
{ left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 },
2,
);
expect(overflow).toEqual({ left: 12, right: 11 });
});
it("returns no overflow when the subject only exceeds the box within tolerance", () => {
const overflow = computeOverflow(
{ left: 99, top: 100, right: 221, bottom: 180, width: 122, height: 80 },
{ left: 100, top: 100, right: 220, bottom: 180, width: 120, height: 80 },
2,
);
expect(overflow).toBeNull();
});
it("summarizes errors and warnings separately", () => {
const issues: LayoutIssue[] = [
issue("text_box_overflow", "error"),
issue("canvas_overflow", "warning"),
issue("clipped_text", "error"),
];
expect(summarizeLayoutIssues(issues)).toEqual({
ok: false,
errorCount: 2,
warningCount: 1,
infoCount: 0,
issueCount: 3,
});
});
it("tracks info findings separately from warnings and errors", () => {
expect(summarizeLayoutIssues([issue("canvas_overflow", "info")])).toEqual({
ok: true,
errorCount: 0,
warningCount: 0,
infoCount: 1,
issueCount: 1,
});
});
it("collapses repeated static issues across sampled timestamps", () => {
const collapsed = collapseStaticLayoutIssues([
{ ...issue("text_box_overflow", "error"), time: 1 },
{ ...issue("text_box_overflow", "error"), time: 3 },
{ ...issue("text_box_overflow", "error"), time: 5 },
]);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({
time: 1,
firstSeen: 1,
lastSeen: 5,
occurrences: 3,
});
});
it("formats issues with timestamp, selector, container, and fix hint", () => {
const formatted = formatLayoutIssue({
...issue("text_box_overflow", "error"),
time: 3.25,
selector: "#headline",
containerSelector: ".bubble",
text: "Quarterly plan",
overflow: { right: 18, bottom: 7 },
fixHint: "Increase container padding or reduce font-size.",
});
expect(formatted).toContain("t=3.25s");
expect(formatted).toContain("#headline");
expect(formatted).toContain("inside .bubble");
expect(formatted).toContain("right 18px, bottom 7px");
expect(formatted).toContain("Fix: Increase container padding");
});
it("formats collapsed issue time ranges", () => {
const formatted = formatLayoutIssue({
...issue("text_box_overflow", "error"),
time: 1,
firstSeen: 1,
lastSeen: 5,
occurrences: 3,
});
expect(formatted).toContain("t=1-5s (3 samples)");
});
// The clip rule that suppresses the odometer/ticker false positive: text
// spilling past an `overflow:hidden` reel window is the mechanism, not a bug.
it("treats clipping overflow values as masking (suppress) and visible as not (still report)", () => {
// Intended clipping — the queued digit rows of a reel are masked here.
expect(overflowValueClips("hidden")).toBe(true);
expect(overflowValueClips("clip")).toBe(true);
expect(overflowValueClips("auto")).toBe(true);
expect(overflowValueClips("scroll")).toBe(true);
// Genuine overflow — nothing masks the text, so it must STILL be reported.
expect(overflowValueClips("visible")).toBe(false);
expect(overflowValueClips("clip visible")).toBe(false);
expect(overflowValueClips("")).toBe(false);
expect(overflowValueClips(null)).toBe(false);
expect(overflowValueClips(undefined)).toBe(false);
});
it("limits returned issues by severity before truncating", () => {
const limited = limitLayoutIssues(
[
{ ...issue("canvas_overflow", "info"), time: 1 },
{ ...issue("text_box_overflow", "error"), time: 2 },
],
1,
);
expect(limited).toMatchObject({
totalIssueCount: 2,
truncated: true,
issues: [{ code: "text_box_overflow", severity: "error" }],
});
});
});
// #U10: held-duration severity tiering on top of the existing collapse step.
// Sample counts below (9) mirror the CLI's default grid so the "1 sample =
// entrance/exit transient, 2+ adjacent samples = held" framing in the
// approach doc lines up with the numbers used here.
describe("persistence-tiered severity (#U10)", () => {
it("demotes a content_overlap seen at only one sample among several to info", () => {
const collapsed = collapseStaticLayoutIssues(
[{ ...issue("content_overlap", "warning"), time: 3 }],
9,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 });
});
it("promotes content_overlap held across >= 2 adjacent samples to error", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("content_overlap", "warning"), time: 3 },
{ ...issue("content_overlap", "warning"), time: 3.6 },
],
9,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 });
});
it("promotes a held, canvas-scale canvas_overflow breach from info to warning", () => {
const breach = {
...issue("canvas_overflow", "info"),
overflow: { top: 140 },
containerRect: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
};
const collapsed = collapseStaticLayoutIssues(
[
{ ...breach, time: 1 },
{ ...breach, time: 3 },
],
9,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 });
});
it("keeps a held, large but fully off-canvas canvas_overflow at info — a parked entrance, not drift", () => {
const breach = {
...issue("canvas_overflow", "info"),
rect: { left: 2200, top: 300, right: 2800, bottom: 700, width: 600, height: 400 },
overflow: { right: 880 },
containerRect: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
};
const collapsed = collapseStaticLayoutIssues(
[
{ ...breach, time: 1 },
{ ...breach, time: 3 },
],
9,
);
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 2 });
});
it("demotes single-sample coordinate-frame findings to info", () => {
for (const code of [
"escaped_container",
"panel_out_of_canvas",
"connector_detached",
] as const) {
const collapsed = collapseStaticLayoutIssues([{ ...issue(code, "warning"), time: 3 }], 9);
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 });
}
});
it("keeps a held but small canvas_overflow at info", () => {
const breach = {
...issue("canvas_overflow", "info"),
overflow: { top: 30 },
containerRect: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
};
const collapsed = collapseStaticLayoutIssues(
[
{ ...breach, time: 1 },
{ ...breach, time: 3 },
],
9,
);
expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 2 });
});
it("does not demote a finding held at every sample — persistence, not a single hit", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("text_box_overflow", "error"), time: 1 },
{ ...issue("text_box_overflow", "error"), time: 3 },
{ ...issue("text_box_overflow", "error"), time: 5 },
],
9,
);
expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 3 });
});
it("does not promote held codes without a promotion rule — container_overflow keeps its severity", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("container_overflow", "warning"), time: 3 },
{ ...issue("container_overflow", "warning"), time: 3.6 },
],
9,
);
expect(collapsed[0]).toMatchObject({ severity: "warning" });
});
it("skips tiering entirely on a single-sample run — nothing to compare a transient against", () => {
const collapsed = collapseStaticLayoutIssues(
[{ ...issue("content_overlap", "warning"), time: 3 }],
1,
);
expect(collapsed[0]).toMatchObject({ severity: "warning" });
});
it("infers the sample count from distinct issue times when none is given", () => {
// Two distinct times among the raw issues imply a multi-sample run even
// without an explicit count, so the single-occurrence group still demotes.
const collapsed = collapseStaticLayoutIssues([
{ ...issue("content_overlap", "warning"), time: 3 },
{ ...issue("text_box_overflow", "error"), time: 5 },
]);
const overlap = collapsed.find((found) => found.code === "content_overlap");
expect(overlap).toMatchObject({ severity: "info" });
});
});
function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue {
return {
code,
severity,
time: 1,
selector: ".label",
message: "Layout issue",
rect: { left: 0, top: 0, right: 100, bottom: 20, width: 100, height: 20 },
overflow: { right: 8 },
fixHint: "Adjust layout.",
};
}