Skip to content

Commit 7f4eaeb

Browse files
authored
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.
1 parent 8e50e84 commit 7f4eaeb

8 files changed

Lines changed: 686 additions & 11 deletions

File tree

docs/packages/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
532532
npx hyperframes check [dir] --strict # exit non-zero on warnings too
533533
```
534534

535-
`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.
535+
`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.
536536

537537
| Flag | Description |
538538
|------|-------------|

packages/cli/src/commands/layout-audit.browser.js

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,255 @@
940940
};
941941
}
942942

943+
// Attachment allowance: callouts/tooltips legitimately hang near (not inside) their anchor.
944+
const ESCAPE_INTERSECTION_FRACTION = 0.3;
945+
const ESCAPE_MIN_CHILD_AREA = 2500;
946+
947+
function edgeGap(child, parent) {
948+
const dx = Math.max(parent.left - child.right, 0, child.left - parent.right);
949+
const dy = Math.max(parent.top - child.bottom, 0, child.top - parent.bottom);
950+
return Math.sqrt(dx * dx + dy * dy);
951+
}
952+
953+
// An absolute element rendering far outside its offset parent was positioned in the wrong frame.
954+
function escapedContainerIssues(root, time) {
955+
const issues = [];
956+
const flagged = new Set();
957+
for (const element of Array.from(root.querySelectorAll("*"))) {
958+
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
959+
if (getComputedStyle(element).position !== "absolute") continue;
960+
const parent = element.offsetParent;
961+
if (!parent || parent === document.body || parent === root || !isVisibleElement(parent)) {
962+
continue;
963+
}
964+
const childRect = toRect(element.getBoundingClientRect());
965+
if (rectArea(childRect) < ESCAPE_MIN_CHILD_AREA) continue;
966+
const parentRect = toRect(parent.getBoundingClientRect());
967+
const visible = intersectionArea(childRect, parentRect);
968+
if (visible >= rectArea(childRect) * ESCAPE_INTERSECTION_FRACTION) continue;
969+
// Fully detached but hugging the parent = a callout/tooltip; touching yet mostly outside = drift.
970+
const allowance = Math.max(48, Math.min(childRect.width, childRect.height) / 2);
971+
if (visible <= 0 && edgeGap(childRect, parentRect) <= allowance) continue;
972+
flagged.add(element);
973+
issues.push({
974+
code: "escaped_container",
975+
severity: "warning",
976+
time,
977+
selector: selectorFor(element),
978+
containerSelector: selectorFor(parent),
979+
text: textContentFor(element),
980+
message:
981+
"Positioned element renders far outside its offset parent — its coordinates were likely computed in a different frame (canvas/viewport pixels).",
982+
rect: childRect,
983+
containerRect: parentRect,
984+
fixHint:
985+
"Compute left/top in the offset parent's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.",
986+
});
987+
}
988+
return { issues, flagged };
989+
}
990+
991+
// A gradient reads as content when any stop is solid; all-translucent stops are glows/vignettes.
992+
function gradientHasOpaqueStop(image) {
993+
const colors = image.match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}|\btransparent\b/gi) || [];
994+
return colors.some((color) => !/^transparent$/i.test(color) && colorAlpha(color) >= 0.6);
995+
}
996+
997+
function isPaintedPanel(element) {
998+
if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) return false;
999+
const style = getComputedStyle(element);
1000+
const image = style.backgroundImage || "none";
1001+
if (image.includes("url(")) return true;
1002+
if (image !== "none" && gradientHasOpaqueStop(image)) return true;
1003+
if (!isTransparentColor(style.backgroundColor) && colorAlpha(style.backgroundColor) > 0.05) {
1004+
return true;
1005+
}
1006+
return (
1007+
parsePx(style.borderTopWidth) +
1008+
parsePx(style.borderRightWidth) +
1009+
parsePx(style.borderBottomWidth) +
1010+
parsePx(style.borderLeftWidth) >
1011+
0
1012+
);
1013+
}
1014+
1015+
// Canvas-breach floor: entrance nudges stay quiet; matches the connector threshold scale.
1016+
const PANEL_BREACH_FLOOR_PX = 24;
1017+
const PANEL_BREACH_FLOOR_FRACTION = 0.025;
1018+
// A hero-sized panel stuck on the edge is drift; a small painted bleed is usually decoration.
1019+
const PANEL_HERO_AREA_FRACTION = 0.1;
1020+
1021+
// Painted panels breaching the canvas: text is canvas_overflow's, media is frame_out_of_frame's, panels were nobody's.
1022+
function panelOutOfCanvasIssues(root, rootRect, time, tolerance, escapedElements) {
1023+
const issues = [];
1024+
const floor = Math.max(
1025+
PANEL_BREACH_FLOOR_PX,
1026+
Math.min(rootRect.width, rootRect.height) * PANEL_BREACH_FLOOR_FRACTION,
1027+
);
1028+
const rootArea = rectArea(rootRect);
1029+
const flagged = new Set();
1030+
for (const element of Array.from(root.querySelectorAll("*"))) {
1031+
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
1032+
if (escapedElements.has(element)) continue;
1033+
// Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own
1034+
// tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding.
1035+
if (hasOwnTextCandidate(element)) {
1036+
const textRect = textRectFor(element);
1037+
if (textRect && overflowFor(textRect, rootRect, tolerance)) continue;
1038+
}
1039+
const rect = toRect(element.getBoundingClientRect());
1040+
if (rectArea(rect) >= rootArea * 0.95) continue;
1041+
// Fully off-canvas paints nothing — that is a parked entrance, not drift.
1042+
if (intersectionArea(rect, rootRect) <= 0) continue;
1043+
const overflow = overflowFor(rect, rootRect, floor);
1044+
if (!overflow || !isPaintedPanel(element)) continue;
1045+
if (element.parentElement && flagged.has(element.parentElement)) {
1046+
flagged.add(element);
1047+
continue;
1048+
}
1049+
flagged.add(element);
1050+
issues.push({
1051+
code: "panel_out_of_canvas",
1052+
severity: rectArea(rect) >= rootArea * PANEL_HERO_AREA_FRACTION ? "warning" : "info",
1053+
time,
1054+
selector: selectorFor(element),
1055+
containerSelector: selectorFor(root),
1056+
text: textContentFor(element).slice(0, 48),
1057+
message: "Painted panel extends outside the composition canvas.",
1058+
rect,
1059+
containerRect: rootRect,
1060+
overflow,
1061+
fixHint:
1062+
"Move the panel inward, or mark intentional off-canvas animation with data-layout-allow-overflow.",
1063+
});
1064+
}
1065+
return issues;
1066+
}
1067+
1068+
const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
1069+
const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
1070+
1071+
function connectorNameFor(element) {
1072+
const className =
1073+
typeof element.className === "string" ? element.className : element.className.baseVal || "";
1074+
return `${element.id || ""} ${className}`;
1075+
}
1076+
1077+
// Screen-space endpoints via the browser: getScreenCTM covers viewBox, preserveAspectRatio and group transforms.
1078+
function pathScreenEndpoints(svg, path) {
1079+
if (
1080+
typeof path.getTotalLength !== "function" ||
1081+
typeof path.getPointAtLength !== "function" ||
1082+
typeof path.getScreenCTM !== "function" ||
1083+
typeof svg.createSVGPoint !== "function"
1084+
) {
1085+
return null;
1086+
}
1087+
let total;
1088+
try {
1089+
total = path.getTotalLength();
1090+
} catch {
1091+
return null;
1092+
}
1093+
if (!Number.isFinite(total) || total <= 0) return null;
1094+
const matrix = path.getScreenCTM();
1095+
if (!matrix) return null;
1096+
const toScreen = (local) => {
1097+
const point = svg.createSVGPoint();
1098+
point.x = local.x;
1099+
point.y = local.y;
1100+
const mapped = point.matrixTransform(matrix);
1101+
return { x: mapped.x, y: mapped.y };
1102+
};
1103+
return {
1104+
start: toScreen(path.getPointAtLength(0)),
1105+
end: toScreen(path.getPointAtLength(total)),
1106+
};
1107+
}
1108+
1109+
function distanceToRect(point, rect) {
1110+
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
1111+
const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
1112+
return Math.sqrt(dx * dx + dy * dy);
1113+
}
1114+
1115+
// Solid, compact elements a connector could plausibly anchor to.
1116+
function connectorAnchorRects(root, rootRect) {
1117+
const compact = [];
1118+
const painted = [];
1119+
const rootArea = rectArea(rootRect);
1120+
for (const element of Array.from(root.querySelectorAll("*"))) {
1121+
// Known blind spot: anchors living inside an SVG (<image>, foreignObject) are not counted.
1122+
if (element.closest("svg") || !isVisibleElement(element)) continue;
1123+
const opaque =
1124+
RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(getComputedStyle(element));
1125+
if (!opaque && !textContentFor(element)) continue;
1126+
const rect = toRect(element.getBoundingClientRect());
1127+
const area = rectArea(rect);
1128+
if (area < 400) continue;
1129+
// Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints.
1130+
if (opaque && area <= rootArea * 0.6) painted.push({ rect, element });
1131+
if (area <= rootArea * 0.15) compact.push(rect);
1132+
}
1133+
return { compact, painted };
1134+
}
1135+
1136+
function isConnectorPath(svg, path) {
1137+
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
1138+
return (
1139+
CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
1140+
);
1141+
}
1142+
1143+
// A connector whose BOTH endpoints land far from every anchorable element was drawn in the wrong frame.
1144+
// min over the two endpoints is intentional: a half-attached connector is a design choice, not frame drift.
1145+
function connectorDetachmentIssues(root, rootRect, time) {
1146+
const issues = [];
1147+
let anchors = null;
1148+
const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
1149+
for (const svg of Array.from(root.querySelectorAll("svg"))) {
1150+
if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
1151+
for (const path of Array.from(svg.querySelectorAll("path"))) {
1152+
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
1153+
if (!isConnectorPath(svg, path)) continue;
1154+
const endpoints = pathScreenEndpoints(svg, path);
1155+
if (!endpoints) continue;
1156+
if (anchors === null) anchors = connectorAnchorRects(root, rootRect);
1157+
if (anchors.compact.length < 2) return issues;
1158+
const attached = (point) =>
1159+
anchors.painted.some(
1160+
(anchor) => !anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0,
1161+
) || anchors.compact.some((rect) => distanceToRect(point, rect) <= threshold);
1162+
if (attached(endpoints.start) || attached(endpoints.end)) continue;
1163+
const gap = Math.round(
1164+
Math.min(
1165+
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.start, rect))),
1166+
Math.min(...anchors.compact.map((rect) => distanceToRect(endpoints.end, rect))),
1167+
),
1168+
);
1169+
issues.push({
1170+
code: "connector_detached",
1171+
severity: "warning",
1172+
time,
1173+
selector: selectorFor(path),
1174+
containerSelector: selectorFor(svg),
1175+
message: `Connector path endpoints are ${gap}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`,
1176+
rect: toRect({
1177+
left: Math.min(endpoints.start.x, endpoints.end.x),
1178+
top: Math.min(endpoints.start.y, endpoints.end.y),
1179+
right: Math.max(endpoints.start.x, endpoints.end.x),
1180+
bottom: Math.max(endpoints.start.y, endpoints.end.y),
1181+
width: Math.abs(endpoints.end.x - endpoints.start.x),
1182+
height: Math.abs(endpoints.end.y - endpoints.start.y),
1183+
}),
1184+
fixHint:
1185+
"Subtract the SVG's own rect when converting measured coordinates, and keep the SVG a direct child of the stage.",
1186+
});
1187+
}
1188+
}
1189+
return issues;
1190+
}
1191+
9431192
function candidateAnchor(element) {
9441193
const dataAttributes = {};
9451194
for (const attribute of Array.from(element.attributes)) {
@@ -1036,6 +1285,10 @@
10361285

10371286
issues.push(...containerOverflowIssues(root, time, tolerance));
10381287
issues.push(...contentOverlapIssues(root, time));
1288+
const escaped = escapedContainerIssues(root, time);
1289+
issues.push(...escaped.issues);
1290+
issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged));
1291+
issues.push(...connectorDetachmentIssues(root, rootRect, time));
10391292
return issues;
10401293
};
10411294

0 commit comments

Comments
 (0)