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.
This commit is contained in:
Xuanru Li
2026-07-13 15:35:03 -07:00
committed by GitHub
parent 8e50e8477f
commit 7f4eaeb568
8 changed files with 686 additions and 11 deletions
+1 -1
View File
@@ -532,7 +532,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
npx hyperframes check [dir] --strict # exit non-zero on warnings too
```
`check` runs the linter first (browser skipped entirely on lint errors), then loads the bundled composition once and sweeps one seek grid running every audit per sample: runtime console errors and failed requests, layout defects (overflow, clipping, held overlaps, occlusion), `*.motion.json` sidecar assertions, and WCAG AA contrast.
`check` runs the linter first (browser skipped entirely on lint errors), then loads the bundled composition once and sweeps one seek grid running every audit per sample: runtime console errors and failed requests, layout defects (overflow, clipping, held overlaps, occlusion, coordinate-frame drift), `*.motion.json` sidecar assertions, and WCAG AA contrast.
| Flag | Description |
|------|-------------|
@@ -940,6 +940,255 @@
};
}
// Attachment allowance: callouts/tooltips legitimately hang near (not inside) their anchor.
const ESCAPE_INTERSECTION_FRACTION = 0.3;
const ESCAPE_MIN_CHILD_AREA = 2500;
function edgeGap(child, parent) {
const dx = Math.max(parent.left - child.right, 0, child.left - parent.right);
const dy = Math.max(parent.top - child.bottom, 0, child.top - parent.bottom);
return Math.sqrt(dx * dx + dy * dy);
}
// An absolute element rendering far outside its offset parent was positioned in the wrong frame.
function escapedContainerIssues(root, time) {
const issues = [];
const flagged = new Set();
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
if (getComputedStyle(element).position !== "absolute") continue;
const parent = element.offsetParent;
if (!parent || parent === document.body || parent === root || !isVisibleElement(parent)) {
continue;
}
const childRect = toRect(element.getBoundingClientRect());
if (rectArea(childRect) < ESCAPE_MIN_CHILD_AREA) continue;
const parentRect = toRect(parent.getBoundingClientRect());
const visible = intersectionArea(childRect, parentRect);
if (visible >= rectArea(childRect) * ESCAPE_INTERSECTION_FRACTION) continue;
// Fully detached but hugging the parent = a callout/tooltip; touching yet mostly outside = drift.
const allowance = Math.max(48, Math.min(childRect.width, childRect.height) / 2);
if (visible <= 0 && edgeGap(childRect, parentRect) <= allowance) continue;
flagged.add(element);
issues.push({
code: "escaped_container",
severity: "warning",
time,
selector: selectorFor(element),
containerSelector: selectorFor(parent),
text: textContentFor(element),
message:
"Positioned element renders far outside its offset parent — its coordinates were likely computed in a different frame (canvas/viewport pixels).",
rect: childRect,
containerRect: parentRect,
fixHint:
"Compute left/top in the offset parent's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.",
});
}
return { issues, flagged };
}
// A gradient reads as content when any stop is solid; all-translucent stops are glows/vignettes.
function gradientHasOpaqueStop(image) {
const colors = image.match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}|\btransparent\b/gi) || [];
return colors.some((color) => !/^transparent$/i.test(color) && colorAlpha(color) >= 0.6);
}
function isPaintedPanel(element) {
if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) return false;
const style = getComputedStyle(element);
const image = style.backgroundImage || "none";
if (image.includes("url(")) return true;
if (image !== "none" && gradientHasOpaqueStop(image)) return true;
if (!isTransparentColor(style.backgroundColor) && colorAlpha(style.backgroundColor) > 0.05) {
return true;
}
return (
parsePx(style.borderTopWidth) +
parsePx(style.borderRightWidth) +
parsePx(style.borderBottomWidth) +
parsePx(style.borderLeftWidth) >
0
);
}
// Canvas-breach floor: entrance nudges stay quiet; matches the connector threshold scale.
const PANEL_BREACH_FLOOR_PX = 24;
const PANEL_BREACH_FLOOR_FRACTION = 0.025;
// A hero-sized panel stuck on the edge is drift; a small painted bleed is usually decoration.
const PANEL_HERO_AREA_FRACTION = 0.1;
// Painted panels breaching the canvas: text is canvas_overflow's, media is frame_out_of_frame's, panels were nobody's.
function panelOutOfCanvasIssues(root, rootRect, time, tolerance, escapedElements) {
const issues = [];
const floor = Math.max(
PANEL_BREACH_FLOOR_PX,
Math.min(rootRect.width, rootRect.height) * PANEL_BREACH_FLOOR_FRACTION,
);
const rootArea = rectArea(rootRect);
const flagged = new Set();
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
if (escapedElements.has(element)) continue;
// Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own
// tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding.
if (hasOwnTextCandidate(element)) {
const textRect = textRectFor(element);
if (textRect && overflowFor(textRect, rootRect, tolerance)) continue;
}
const rect = toRect(element.getBoundingClientRect());
if (rectArea(rect) >= rootArea * 0.95) continue;
// Fully off-canvas paints nothing — that is a parked entrance, not drift.
if (intersectionArea(rect, rootRect) <= 0) continue;
const overflow = overflowFor(rect, rootRect, floor);
if (!overflow || !isPaintedPanel(element)) continue;
if (element.parentElement && flagged.has(element.parentElement)) {
flagged.add(element);
continue;
}
flagged.add(element);
issues.push({
code: "panel_out_of_canvas",
severity: rectArea(rect) >= rootArea * PANEL_HERO_AREA_FRACTION ? "warning" : "info",
time,
selector: selectorFor(element),
containerSelector: selectorFor(root),
text: textContentFor(element).slice(0, 48),
message: "Painted panel extends outside the composition canvas.",
rect,
containerRect: rootRect,
overflow,
fixHint:
"Move the panel inward, or mark intentional off-canvas animation with data-layout-allow-overflow.",
});
}
return issues;
}
const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
function connectorNameFor(element) {
const className =
typeof element.className === "string" ? element.className : element.className.baseVal || "";
return `${element.id || ""} ${className}`;
}
// Screen-space endpoints via the browser: getScreenCTM covers viewBox, preserveAspectRatio and group transforms.
function pathScreenEndpoints(svg, path) {
if (
typeof path.getTotalLength !== "function" ||
typeof path.getPointAtLength !== "function" ||
typeof path.getScreenCTM !== "function" ||
typeof svg.createSVGPoint !== "function"
) {
return null;
}
let total;
try {
total = path.getTotalLength();
} catch {
return null;
}
if (!Number.isFinite(total) || total <= 0) return null;
const matrix = path.getScreenCTM();
if (!matrix) return null;
const toScreen = (local) => {
const point = svg.createSVGPoint();
point.x = local.x;
point.y = local.y;
const mapped = point.matrixTransform(matrix);
return { x: mapped.x, y: mapped.y };
};
return {
start: toScreen(path.getPointAtLength(0)),
end: toScreen(path.getPointAtLength(total)),
};
}
function distanceToRect(point, rect) {
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
return Math.sqrt(dx * dx + dy * dy);
}
// Solid, compact elements a connector could plausibly anchor to.
function connectorAnchorRects(root, rootRect) {
const compact = [];
const painted = [];
const rootArea = rectArea(rootRect);
for (const element of Array.from(root.querySelectorAll("*"))) {
// Known blind spot: anchors living inside an SVG (<image>, foreignObject) are not counted.
if (element.closest("svg") || !isVisibleElement(element)) continue;
const opaque =
RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(getComputedStyle(element));
if (!opaque && !textContentFor(element)) continue;
const rect = toRect(element.getBoundingClientRect());
const area = rectArea(rect);
if (area < 400) continue;
// Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints.
if (opaque && area <= rootArea * 0.6) painted.push({ rect, element });
if (area <= rootArea * 0.15) compact.push(rect);
}
return { compact, painted };
}
function isConnectorPath(svg, path) {
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
return (
CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
);
}
// A connector whose BOTH endpoints land far from every anchorable element was drawn in the wrong frame.
// min over the two endpoints is intentional: a half-attached connector is a design choice, not frame drift.
function connectorDetachmentIssues(root, rootRect, time) {
const issues = [];
let anchors = null;
const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
for (const svg of Array.from(root.querySelectorAll("svg"))) {
if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
for (const path of Array.from(svg.querySelectorAll("path"))) {
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
if (!isConnectorPath(svg, path)) continue;
const endpoints = pathScreenEndpoints(svg, path);
if (!endpoints) continue;
if (anchors === null) anchors = connectorAnchorRects(root, rootRect);
if (anchors.compact.length < 2) return issues;
const attached = (point) =>
anchors.painted.some(
(anchor) => !anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0,
) || anchors.compact.some((rect) => distanceToRect(point, rect) <= threshold);
if (attached(endpoints.start) || attached(endpoints.end)) continue;
const gap = Math.round(
Math.min(
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.start, rect))),
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.end, rect))),
),
);
issues.push({
code: "connector_detached",
severity: "warning",
time,
selector: selectorFor(path),
containerSelector: selectorFor(svg),
message: `Connector path endpoints are ${gap}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`,
rect: toRect({
left: Math.min(endpoints.start.x, endpoints.end.x),
top: Math.min(endpoints.start.y, endpoints.end.y),
right: Math.max(endpoints.start.x, endpoints.end.x),
bottom: Math.max(endpoints.start.y, endpoints.end.y),
width: Math.abs(endpoints.end.x - endpoints.start.x),
height: Math.abs(endpoints.end.y - endpoints.start.y),
}),
fixHint:
"Subtract the SVG's own rect when converting measured coordinates, and keep the SVG a direct child of the stage.",
});
}
}
return issues;
}
function candidateAnchor(element) {
const dataAttributes = {};
for (const attribute of Array.from(element.attributes)) {
@@ -1036,6 +1285,10 @@
issues.push(...containerOverflowIssues(root, time, tolerance));
issues.push(...contentOverlapIssues(root, time));
const escaped = escapedContainerIssues(root, time);
issues.push(...escaped.issues);
issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged));
issues.push(...connectorDetachmentIssues(root, rootRect, time));
return issues;
};
@@ -486,6 +486,288 @@ describe("layout-audit.browser invisible text", () => {
});
});
describe("layout-audit.browser coordinate-frame findings", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
clearGeometryCollector();
});
it("flags a positioned element rendering far outside its offset parent", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="diagram"><div id="node"></div><div id="badge"></div><div id="callout"></div></div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
diagram: rect({ left: 610, top: 130, width: 700, height: 700 }),
node: rect({ left: 1490, top: 170, width: 160, height: 160 }),
badge: rect({ left: 580, top: 160, width: 120, height: 120 }),
callout: rect({ left: 700, top: 60, width: 160, height: 56 }),
},
{
node: { position: "absolute" },
badge: { position: "absolute" },
callout: { position: "absolute" },
},
);
installOffsetParents({ node: "diagram", badge: "diagram", callout: "diagram" });
installAuditScript();
const issues = runAudit().filter((issue) => issue.code === "escaped_container");
// The node is 180px away in a foreign frame; the badge overlaps its parent; the callout hangs 14px above it.
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
severity: "warning",
selector: "#node",
containerSelector: "#diagram",
});
expect(issues[0]?.message).toContain("computed in a different frame");
expect(issues[0]?.fixHint).toContain("offset parent's frame");
});
it("respects the allow-overflow opt-out and skips fixed elements", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="diagram">
<div id="node" data-layout-allow-overflow></div>
<div id="hud"></div>
</div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
diagram: rect({ left: 610, top: 130, width: 700, height: 700 }),
node: rect({ left: 1490, top: 170, width: 160, height: 160 }),
hud: rect({ left: 24, top: 900, width: 200, height: 100 }),
},
{
node: { position: "absolute" },
hud: { position: "fixed" },
},
);
installOffsetParents({ node: "diagram", hud: "diagram" });
installAuditScript();
expect(runAudit().filter((issue) => issue.code === "escaped_container")).toEqual([]);
});
it("flags painted panels crossing the canvas, hero-sized as warning and bleeds as info", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="hero"></div>
<div id="bleed"></div>
<div id="glow"></div>
<div id="spotlight"></div>
<div id="goldframe"></div>
<div id="parked"></div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
hero: rect({ left: 1400, top: 300, width: 800, height: 600 }),
bleed: rect({ left: -150, top: -150, width: 300, height: 300 }),
glow: rect({ left: 1800, top: 0, width: 400, height: 400 }),
spotlight: rect({ left: 560, top: -216, width: 800, height: 1200 }),
goldframe: rect({ left: 660, top: -150, width: 620, height: 820 }),
parked: rect({ left: 2200, top: 300, width: 600, height: 400 }),
},
{
// Paint alone qualifies — a flat solid panel with no padding/border is still content.
hero: { backgroundColor: "rgb(20, 20, 30)" },
bleed: { backgroundColor: "rgb(200, 180, 120)" },
// Gradient-only paint is decoration; a border is content even with pointer-events:none.
spotlight: {
backgroundImage: "radial-gradient(ellipse at top, rgba(212,175,55,0.15), transparent)",
},
goldframe: { borderTopWidth: "10px", borderBottomWidth: "10px" },
parked: { backgroundColor: "rgb(20, 20, 30)" },
},
);
installAuditScript();
const issues = runAudit().filter((issue) => issue.code === "panel_out_of_canvas");
// The unpainted glow, gradient-only spotlight, and fully off-canvas parked entrance stay silent.
expect(issues).toHaveLength(3);
expect(issues.some((issue) => issue.selector === "#goldframe")).toBe(true);
expect(issues.some((issue) => issue.selector === "#spotlight")).toBe(false);
expect(issues.find((issue) => issue.selector === "#hero")).toMatchObject({
severity: "warning",
overflow: { right: 280 },
message: "Painted panel extends outside the composition canvas.",
});
expect(issues.find((issue) => issue.selector === "#hero")?.fixHint).toContain(
"data-layout-allow-overflow",
);
expect(issues.find((issue) => issue.selector === "#bleed")).toMatchObject({ severity: "info" });
});
it("flags a gradient-content hero but not an all-translucent gradient glow", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="gradient-hero"></div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
"gradient-hero": rect({ left: 1400, top: 300, width: 800, height: 600 }),
},
{
// Opaque gradient stops read as content — miguel's regression case.
"gradient-hero": {
backgroundImage: "linear-gradient(90deg, rgb(16, 24, 40), rgb(52, 64, 84))",
},
},
);
installAuditScript();
const issues = runAudit().filter((issue) => issue.code === "panel_out_of_canvas");
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ severity: "warning", selector: "#gradient-hero" });
});
it("cedes ownership to canvas_overflow even for a shallow text breach", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="hero">Barely breaching title</div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
hero: rect({ left: 1400, top: 300, width: 800, height: 600 }),
// Text breaches 20px: past canvas_overflow's 2px tolerance, under the 27px panel floor.
text: rect({ left: 1740, top: 340, width: 200, height: 50 }),
},
{
hero: { backgroundColor: "rgb(20, 20, 30)" },
},
);
installAuditScript();
const issues = runAudit();
expect(issues.filter((issue) => issue.code === "panel_out_of_canvas")).toEqual([]);
expect(issues.some((issue) => issue.code === "canvas_overflow")).toBe(true);
});
it("flags a painted hero whose box breaches while its direct text stays in-bounds", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="hero">Title</div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
hero: rect({ left: 1400, top: 300, width: 800, height: 600 }),
text: rect({ left: 1450, top: 340, width: 200, height: 50 }),
},
{
hero: { backgroundColor: "rgb(20, 20, 30)" },
},
);
installAuditScript();
const issues = runAudit();
expect(issues.filter((issue) => issue.code === "panel_out_of_canvas")).toHaveLength(1);
expect(issues.filter((issue) => issue.code === "canvas_overflow")).toEqual([]);
});
it("leaves a breaching panel to canvas_overflow when its own text breaches too", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="hero">Very long breaching title</div>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
hero: rect({ left: 1400, top: 300, width: 800, height: 600 }),
text: rect({ left: 1450, top: 340, width: 700, height: 50 }),
},
{
hero: { backgroundColor: "rgb(20, 20, 30)" },
},
);
installAuditScript();
const issues = runAudit();
expect(issues.filter((issue) => issue.code === "panel_out_of_canvas")).toEqual([]);
expect(issues.some((issue) => issue.code === "canvas_overflow")).toBe(true);
});
it("flags connector paths drawn in a foreign frame and passes anchored ones", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="connector-svg">
<defs><marker id="arrow"><path id="tip" d="M 0 0 L 8 4 L 0 8" /></marker></defs>
<path id="detached" class="connector-line" d="M 980 580 L 380 280" />
<path id="anchored" class="connector-line" d="M 900 353 L 300 53" />
</svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
"connector-svg": rect({ left: 80, top: 227, width: 1740, height: 830 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
// Screen CTM translates svg user space by the svg's offset (80, 227): the detached path's
// start (980, 580) renders at (1060, 807) — 147px below #n1's box — while the anchored
// path's start (900, 353) renders at (980, 580), inside #n1.
installConnectorGeometry({ e: 80, f: 227 });
installAuditScript();
const issues = runAudit().filter((issue) => issue.code === "connector_detached");
// The marker tip path is skipped outright; only the detached line reports.
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ severity: "warning", selector: "#detached" });
expect(issues[0]?.message).toContain("drawn into an SVG with a different origin");
expect(issues[0]?.fixHint).toContain("Subtract the SVG's own rect");
});
it("skips svgs and paths without connector intent", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="n1"></div>
<div id="n2"></div>
<svg id="knowledge-overflow"><path id="squiggle" d="M 10 10 L 200 200" /></svg>
</div>
`;
installGeometry(
{
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
n1: rect({ left: 900, top: 500, width: 160, height: 160 }),
n2: rect({ left: 300, top: 200, width: 160, height: 160 }),
"knowledge-overflow": rect({ left: 1400, top: 100, width: 400, height: 400 }),
},
{
n1: { backgroundColor: "rgb(30, 40, 50)" },
n2: { backgroundColor: "rgb(30, 40, 50)" },
},
);
installConnectorGeometry({ e: 0, f: 0 });
installAuditScript();
// "knowledge-overflow" contains conn-family substrings only across word boundaries — no match.
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
});
});
describe("layout-audit.browser content overlap", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -1256,6 +1538,45 @@ function installOcclusionGeometry(options: {
document.getElementById(options.topmostId);
}
function installOffsetParents(map: Record<string, string>): void {
for (const [childId, parentId] of Object.entries(map)) {
const child = document.getElementById(childId);
const parent = document.getElementById(parentId);
if (child && parent) Object.defineProperty(child, "offsetParent", { value: parent });
}
}
interface CtmTranslate {
e: number;
f: number;
}
// happy-dom has no SVG geometry APIs; endpoints come from the path's `d`, the CTM is a pure translate.
function installConnectorGeometry(translate: CtmTranslate): void {
const matrix = { a: 1, b: 0, c: 0, d: 1, e: translate.e, f: translate.f };
for (const svg of Array.from(document.querySelectorAll("svg"))) {
Object.defineProperty(svg, "createSVGPoint", {
value: () => ({
x: 0,
y: 0,
matrixTransform(m: typeof matrix) {
return { x: this.x * m.a + this.y * m.c + m.e, y: this.x * m.b + this.y * m.d + m.f };
},
}),
});
for (const path of Array.from(svg.querySelectorAll("path"))) {
const numbers = (path.getAttribute("d")?.match(/-?\d*\.?\d+/g) || []).map(Number);
const start = { x: numbers[0] ?? 0, y: numbers[1] ?? 0 };
const end = { x: numbers[numbers.length - 2] ?? 0, y: numbers[numbers.length - 1] ?? 0 };
Object.defineProperty(path, "getTotalLength", { value: () => 100 });
Object.defineProperty(path, "getPointAtLength", {
value: (length: number) => (length === 0 ? start : end),
});
Object.defineProperty(path, "getScreenCTM", { value: () => matrix });
}
}
}
function installAuditScript(): void {
window.eval(script);
}
@@ -1339,6 +1660,7 @@ interface AuditIssue {
containerSelector?: string;
overflow?: Record<string, number>;
message?: string;
fixHint?: string;
coveredFraction?: number;
}
+3
View File
@@ -955,6 +955,9 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
"text_not_painted",
"caption_zone_collision",
"frame_out_of_frame",
"escaped_container",
"panel_out_of_canvas",
"connector_detached",
"motion_appears_late",
"motion_out_of_order",
"motion_off_frame",
+65 -1
View File
@@ -227,6 +227,70 @@ describe("persistence-tiered severity (#U10)", () => {
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(
[
@@ -241,7 +305,7 @@ describe("persistence-tiered severity (#U10)", () => {
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 3 });
});
it("only re-promotes content_overlap — other held codes keep their original severity", () => {
it("does not promote held codes without a promotion rule — container_overflow keeps its severity", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("container_overflow", "warning"), time: 3 },
+40 -7
View File
@@ -19,6 +19,10 @@ export type LayoutIssueCode =
| "text_not_painted"
| "caption_zone_collision"
| "frame_out_of_frame"
// Coordinate-frame findings — geometry computed in one frame, rendered in another.
| "escaped_container"
| "panel_out_of_canvas"
| "connector_detached"
// Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample
// geometry observation; never persistence-tiered (see `applyPersistenceTier`).
| "sweep_static"
@@ -203,6 +207,9 @@ const PERSISTENCE_TIERED_CODES: ReadonlySet<LayoutIssueCode> = new Set([
"container_overflow",
"content_overlap",
"text_occluded",
"escaped_container",
"panel_out_of_canvas",
"connector_detached",
]);
export function collapseStaticLayoutIssues(
@@ -255,12 +262,13 @@ export function collapseStaticLayoutIssues(
* Held-duration severity tiering (#U10). A finding observed at only one
* sample among several (held 0ms) is an entrance/exit transient, not a held
* defect demote to info so it stays in the data (verbose/--json output)
* without gating the run. `content_overlap` specifically re-promotes from
* warning to error once it's held long enough to be a real, sustained
* collision rather than a crossfade/transition blip (resolves the TODO in
* layout-audit.browser.js's `overlapIssue`). A finding held at every sample
* (a genuinely static defect) is well past both thresholds and is left
* untouched either way persistence, not the code, decides the tier.
* without gating the run. Two codes re-promote once held: `content_overlap`
* warning->error when the collision is sustained rather than a crossfade blip
* (resolves the TODO in layout-audit.browser.js's `overlapIssue`), and
* `canvas_overflow` info->warning when the breach is held, canvas-scale
* (>= 5% of the short edge) AND partially visible a fully off-canvas rect
* is a parked entrance, not drift. Codes without a promotion rule are left
* untouched when held persistence, not the code, decides their tier.
*/
function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): LayoutIssue {
if (!multiSampleRun) return issue;
@@ -276,9 +284,33 @@ function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): Layo
if (issue.code === "content_overlap" && isContentOverlapHeldLongEnough(issue, occurrences)) {
return { ...issue, severity: "error" };
}
if (issue.code === "canvas_overflow" && isCanvasBreachHeldLarge(issue, occurrences)) {
return { ...issue, severity: "warning" };
}
return issue;
}
// A held, canvas-scale, PARTIALLY visible breach is drift; a fully off-canvas rect is a parked entrance.
function isCanvasBreachHeldLarge(issue: LayoutIssue, occurrences: number): boolean {
if (
occurrences < HELD_ACROSS_SAMPLES_MIN_OCCURRENCES ||
!issue.overflow ||
!issue.containerRect
) {
return false;
}
const breach = Math.max(
...Object.values(issue.overflow).filter((value) => typeof value === "number"),
);
if (breach < Math.min(issue.containerRect.width, issue.containerRect.height) * 0.05) return false;
const container = issue.containerRect;
const overlapX =
Math.min(issue.rect.right, container.right) - Math.max(issue.rect.left, container.left);
const overlapY =
Math.min(issue.rect.bottom, container.bottom) - Math.max(issue.rect.top, container.top);
return overlapX > 0 && overlapY > 0;
}
// Split out of applyPersistenceTier so the two independent "held long enough"
// signals (sample count vs. wall-clock span) read as one boolean question
// instead of adding a third compound branch to the tiering ladder above.
@@ -326,7 +358,8 @@ function staticIssueKey(issue: LayoutIssue): string {
}
function framePositionKey(issue: LayoutIssue): string {
return issue.code === "frame_out_of_frame"
// connector_detached shares it: id-less paths collapse to one selector, so distinct lines need geometry in the key.
return issue.code === "frame_out_of_frame" || issue.code === "connector_detached"
? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}`
: "";
}
+1 -1
View File
@@ -26,7 +26,7 @@
"files": 99
},
"hyperframes-cli": {
"hash": "797fd624f0b83cb5",
"hash": "ecd5ec042ff500c7",
"files": 7
},
"hyperframes-core": {
@@ -56,7 +56,7 @@ One command, one Chrome boot. `check` runs the linter first and skips the browse
Every finding carries a selector, the element's `data-*` identity, the composition source file, a bbox, and the sample time: jump straight from the JSON to the HTML you must edit and re-run.
**Severity is persistence-aware.** A dynamic issue observed at a single grid sample (an entrance/exit transient) demotes to info and never gates. Issues held across samples gate the exit code, and a held `content_overlap` is an error. If a 3s+ composition shows zero geometry change across every sample, `check` fails with `sweep_static`: a frozen timeline makes every green verdict unreliable, so it refuses to pass.
**Severity is persistence-aware.** A dynamic issue observed at a single grid sample (an entrance/exit transient) demotes to info and never gates. Issues held across samples gate the exit code, a held `content_overlap` is an error, and a held, partially-visible `canvas_overflow` breaching ≥5% of the canvas promotes to warning. Coordinate-frame findings (`escaped_container`, `panel_out_of_canvas`, `connector_detached`) flag geometry computed in one frame but rendered in another — an element far outside its offset parent, a painted panel stuck across the canvas edge, a connector line detached from every node. If a 3s+ composition shows zero geometry change across every sample, `check` fails with `sweep_static`: a frozen timeline makes every green verdict unreliable, so it refuses to pass.
**Escape hatches** (mark intent in the HTML, then re-run):