mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}`
|
||||
: "";
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user