From 07fd559419a50811c22edd74a7454ea117dd4c92 Mon Sep 17 00:00:00 2001 From: xuanru Date: Mon, 13 Jul 2026 19:39:08 +0000 Subject: [PATCH 1/6] feat(cli): coordinate-frame layout findings in check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../cli/src/commands/layout-audit.browser.js | 198 ++++++++++++++++++ .../src/commands/layout-audit.browser.test.ts | 122 +++++++++++ packages/cli/src/utils/checkBrowser.ts | 3 + packages/cli/src/utils/layoutAudit.test.ts | 35 ++++ packages/cli/src/utils/layoutAudit.ts | 25 +++ 5 files changed, 383 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 1d57956fa1..4f9ab0bc70 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -841,6 +841,201 @@ }; } + function positioningAncestor(element, root) { + for (let current = element.parentElement; current; current = current.parentElement) { + if (current === root || current === document.body) return null; + if (getComputedStyle(current).position !== "static") return current; + } + return null; + } + + // A positioned child rendering mostly outside its positioning ancestor is a coordinate-frame mistake, not a layout choice. + function positionedOutOfParentIssues(root, time) { + const issues = []; + for (const element of Array.from(root.querySelectorAll("*"))) { + if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue; + const position = getComputedStyle(element).position; + if (position !== "absolute" && position !== "fixed") continue; + const parent = positioningAncestor(element, root); + if (!parent || !isVisibleElement(parent)) continue; + const childRect = toRect(element.getBoundingClientRect()); + if (rectArea(childRect) < 2500) continue; + const parentRect = toRect(parent.getBoundingClientRect()); + if (intersectionArea(childRect, parentRect) >= rectArea(childRect) * 0.3) continue; + issues.push({ + code: "positioned_out_of_parent", + severity: "warning", + time, + selector: selectorFor(element), + containerSelector: selectorFor(parent), + text: textContentFor(element), + message: + "Positioned element renders mostly outside its positioning ancestor — its coordinates were likely computed in a different frame (canvas/viewport pixels).", + rect: childRect, + containerRect: parentRect, + fixHint: + "Compute left/top in the positioning ancestor's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.", + }); + } + return issues; + } + + function isContentBox(element) { + if (FRAME_MEDIA_TAGS.has(element.tagName)) return false; + const style = getComputedStyle(element); + return hasPaint(style) && hasMeaningfulBoxStyle(style); + } + + // Painted boxes breaching the canvas: text is canvas_overflow's, media is frame_out_of_frame's, panels were nobody's. + function boxCanvasOverflowIssues(root, rootRect, time) { + const issues = []; + const floor = Math.max(24, Math.min(rootRect.width, rootRect.height) * 0.025); + const rootArea = rectArea(rootRect); + const flagged = new Set(); + for (const element of Array.from(root.querySelectorAll("*"))) { + if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue; + if (hasOwnTextCandidate(element)) continue; + const rect = toRect(element.getBoundingClientRect()); + if (rectArea(rect) >= rootArea * 0.95) continue; + const overflow = overflowFor(rect, rootRect, floor); + if (!overflow || !isContentBox(element)) continue; + if (element.parentElement && flagged.has(element.parentElement)) { + flagged.add(element); + continue; + } + flagged.add(element); + issues.push({ + code: "box_out_of_canvas", + severity: "warning", + time, + selector: selectorFor(element), + containerSelector: selectorFor(root), + text: textContentFor(element).slice(0, 48), + message: "Element box extends outside the composition canvas.", + rect, + containerRect: rootRect, + overflow, + fixHint: + "Move the element inward, or mark intentional off-canvas animation with data-layout-allow-overflow.", + }); + } + return issues; + } + + const CONNECTOR_NAME = /conn|arrow|edge|link|flow|wire/i; + + function connectorNameFor(element) { + const className = + typeof element.className === "string" ? element.className : element.className.baseVal || ""; + return `${element.id || ""} ${className}`; + } + + // Endpoints from an absolute-command path `d`; relative/H/V/closed paths bail to null. + function pathEndpointsFor(d) { + if (!d || /[a-z]/.test(d.replace(/e[+-]?\d+/gi, "")) || /[HVZ]/.test(d)) return null; + const numbers = (d.match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || []).map(Number); + if (numbers.length < 4 || numbers.some((value) => !Number.isFinite(value))) return null; + return { + start: { x: numbers[0], y: numbers[1] }, + end: { x: numbers[numbers.length - 2], y: numbers[numbers.length - 1] }, + }; + } + + function svgUserToScreen(svg, svgRect, point) { + const viewBox = (svg.getAttribute("viewBox") || "") + .trim() + .split(/[\s,]+/) + .map(Number); + const hasViewBox = + viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0; + const scaleX = hasViewBox ? svgRect.width / viewBox[2] : 1; + const scaleY = hasViewBox ? svgRect.height / viewBox[3] : 1; + const minX = hasViewBox ? viewBox[0] : 0; + const minY = hasViewBox ? viewBox[1] : 0; + return { + x: svgRect.left + (point.x - minX) * scaleX, + y: svgRect.top + (point.y - minY) * scaleY, + }; + } + + 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 rects = []; + const rootArea = rectArea(rootRect); + for (const element of Array.from(root.querySelectorAll("*"))) { + if (element.closest("svg") || !isVisibleElement(element)) continue; + const rect = toRect(element.getBoundingClientRect()); + const area = rectArea(rect); + if (area < 400 || area > rootArea * 0.15) continue; + if ( + !RASTER_TAGS.has(element.tagName) && + !textContentFor(element) && + !hasOpaqueBackground(getComputedStyle(element)) + ) { + continue; + } + rects.push(rect); + } + return rects; + } + + 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 line whose BOTH endpoints land far from every anchorable element is drawn in the wrong frame. + 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; + const svgRect = toRect(svg.getBoundingClientRect()); + for (const path of Array.from(svg.querySelectorAll("path"))) { + if (!isConnectorPath(svg, path)) continue; + const endpoints = pathEndpointsFor(path.getAttribute("d")); + if (!endpoints) continue; + if (anchors === null) anchors = connectorAnchorRects(root, rootRect); + if (anchors.length < 2) return issues; + const start = svgUserToScreen(svg, svgRect, endpoints.start); + const end = svgUserToScreen(svg, svgRect, endpoints.end); + const gap = Math.min( + Math.min(...anchors.map((rect) => distanceToRect(start, rect))), + Math.min(...anchors.map((rect) => distanceToRect(end, rect))), + ); + if (gap <= threshold) continue; + issues.push({ + code: "connector_detached", + severity: "warning", + time, + selector: selectorFor(path), + containerSelector: selectorFor(svg), + message: `Connector path endpoints are ${Math.round(gap)}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`, + rect: toRect({ + left: Math.min(start.x, end.x), + top: Math.min(start.y, end.y), + right: Math.max(start.x, end.x), + bottom: Math.max(start.y, end.y), + width: Math.abs(end.x - start.x), + height: Math.abs(end.y - 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)) { @@ -932,6 +1127,9 @@ issues.push(...containerOverflowIssues(root, time, tolerance)); issues.push(...contentOverlapIssues(root, time)); + issues.push(...positionedOutOfParentIssues(root, time)); + issues.push(...boxCanvasOverflowIssues(root, rootRect, time)); + issues.push(...connectorDetachmentIssues(root, rootRect, time)); return issues; }; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 1a334a5b68..d1b55d6dd9 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -486,6 +486,128 @@ 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 mostly outside its positioning ancestor", () => { + document.body.innerHTML = ` +
+
+
+ `; + 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 }), + }, + { + diagram: { position: "relative" }, + node: { position: "absolute" }, + badge: { position: "absolute" }, + }, + ); + + installAuditScript(); + const issues = runAudit().filter((issue) => issue.code === "positioned_out_of_parent"); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: "warning", + selector: "#node", + containerSelector: "#diagram", + }); + }); + + it("flags a painted panel crossing the canvas and skips unpainted decoration", () => { + document.body.innerHTML = ` +
+
+
+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), + panel: rect({ left: 1700, top: 600, width: 300, height: 300 }), + glow: rect({ left: 1800, top: 0, width: 400, height: 400 }), + }, + { + panel: { backgroundColor: "rgb(20, 20, 30)", paddingTop: "16px" }, + }, + ); + + installAuditScript(); + const issues = runAudit().filter((issue) => issue.code === "box_out_of_canvas"); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: "warning", + selector: "#panel", + overflow: { right: 80 }, + }); + }); + + it("flags connector paths drawn in a foreign frame and passes anchored ones", () => { + document.body.innerHTML = ` +
+
+
+ + + + +
+ `; + 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)" }, + }, + ); + + installAuditScript(); + const issues = runAudit().filter((issue) => issue.code === "connector_detached"); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ severity: "warning", selector: "#detached" }); + }); + + it("skips svgs and paths without connector intent", () => { + document.body.innerHTML = ` +
+
+
+ +
+ `; + 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 }), + art: rect({ left: 1400, top: 100, width: 400, height: 400 }), + }, + { + n1: { backgroundColor: "rgb(30, 40, 50)" }, + n2: { backgroundColor: "rgb(30, 40, 50)" }, + }, + ); + + installAuditScript(); + expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]); + }); +}); + describe("layout-audit.browser content overlap", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 3f2abdc6b8..160ef4ac98 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -955,6 +955,9 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "text_not_painted", "caption_zone_collision", "frame_out_of_frame", + "positioned_out_of_parent", + "box_out_of_canvas", + "connector_detached", "motion_appears_late", "motion_out_of_order", "motion_off_frame", diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index ba57171033..7ccf85ecaa 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -227,6 +227,41 @@ 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 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( [ diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index bcec7cb126..ace79d123c 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -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. + | "positioned_out_of_parent" + | "box_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 = new Set([ "container_overflow", "content_overlap", "text_occluded", + "positioned_out_of_parent", + "box_out_of_canvas", + "connector_detached", ]); export function collapseStaticLayoutIssues( @@ -276,9 +283,27 @@ 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 canvas breach both held across samples and large relative to the canvas is a real defect, not an entrance transient. +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"), + ); + return breach >= Math.min(issue.containerRect.width, issue.containerRect.height) * 0.05; +} + // 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. From c16cc17a0055b7c4b5a8ffbdb2e9c32d9bcc6b03 Mon Sep 17 00:00:00 2001 From: xuanru Date: Mon, 13 Jul 2026 21:21:04 +0000 Subject: [PATCH 2/6] fix(cli): harden coordinate-frame findings against review false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/packages/cli.mdx | 2 +- .../cli/src/commands/layout-audit.browser.js | 199 ++++++++++-------- .../src/commands/layout-audit.browser.test.ts | 123 +++++++++-- packages/cli/src/utils/checkBrowser.ts | 4 +- packages/cli/src/utils/layoutAudit.test.ts | 31 ++- packages/cli/src/utils/layoutAudit.ts | 34 +-- skills-manifest.json | 2 +- .../references/lint-validate-inspect.md | 2 +- 8 files changed, 276 insertions(+), 121 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 91094b0ed2..9204e1a0c1 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 | |------|-------------| diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 4f9ab0bc70..bc273e7bb0 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -841,88 +841,112 @@ }; } - function positioningAncestor(element, root) { - for (let current = element.parentElement; current; current = current.parentElement) { - if (current === root || current === document.body) return null; - if (getComputedStyle(current).position !== "static") return current; - } - return null; + // 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); } - // A positioned child rendering mostly outside its positioning ancestor is a coordinate-frame mistake, not a layout choice. - function positionedOutOfParentIssues(root, time) { + // 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; - const position = getComputedStyle(element).position; - if (position !== "absolute" && position !== "fixed") continue; - const parent = positioningAncestor(element, root); - if (!parent || !isVisibleElement(parent)) 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) < 2500) continue; + if (rectArea(childRect) < ESCAPE_MIN_CHILD_AREA) continue; const parentRect = toRect(parent.getBoundingClientRect()); - if (intersectionArea(childRect, parentRect) >= rectArea(childRect) * 0.3) continue; + 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: "positioned_out_of_parent", + code: "escaped_container", severity: "warning", time, selector: selectorFor(element), containerSelector: selectorFor(parent), text: textContentFor(element), message: - "Positioned element renders mostly outside its positioning ancestor — its coordinates were likely computed in a different frame (canvas/viewport pixels).", + "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 positioning ancestor's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.", + "Compute left/top in the offset parent's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.", }); } - return issues; + return { issues, flagged }; } - function isContentBox(element) { - if (FRAME_MEDIA_TAGS.has(element.tagName)) return false; + function isPaintedPanel(element) { + if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) return false; const style = getComputedStyle(element); - return hasPaint(style) && hasMeaningfulBoxStyle(style); + // pointer-events:none is the decorative-layer convention (spotlights, grain, vignettes). + if (style.pointerEvents === "none") return false; + return hasPaint(style); } - // Painted boxes breaching the canvas: text is canvas_overflow's, media is frame_out_of_frame's, panels were nobody's. - function boxCanvasOverflowIssues(root, rootRect, time) { + // 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, escapedElements) { const issues = []; - const floor = Math.max(24, Math.min(rootRect.width, rootRect.height) * 0.025); + 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; if (hasOwnTextCandidate(element)) 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 || !isContentBox(element)) continue; + if (!overflow || !isPaintedPanel(element)) continue; if (element.parentElement && flagged.has(element.parentElement)) { flagged.add(element); continue; } flagged.add(element); issues.push({ - code: "box_out_of_canvas", - severity: "warning", + 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: "Element box extends outside the composition canvas.", + message: "Painted panel extends outside the composition canvas.", rect, containerRect: rootRect, overflow, fixHint: - "Move the element inward, or mark intentional off-canvas animation with data-layout-allow-overflow.", + "Move the panel inward, or mark intentional off-canvas animation with data-layout-allow-overflow.", }); } return issues; } - const CONNECTOR_NAME = /conn|arrow|edge|link|flow|wire/i; + 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 = @@ -930,31 +954,35 @@ return `${element.id || ""} ${className}`; } - // Endpoints from an absolute-command path `d`; relative/H/V/closed paths bail to null. - function pathEndpointsFor(d) { - if (!d || /[a-z]/.test(d.replace(/e[+-]?\d+/gi, "")) || /[HVZ]/.test(d)) return null; - const numbers = (d.match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || []).map(Number); - if (numbers.length < 4 || numbers.some((value) => !Number.isFinite(value))) return null; - return { - start: { x: numbers[0], y: numbers[1] }, - end: { x: numbers[numbers.length - 2], y: numbers[numbers.length - 1] }, + // 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 }; }; - } - - function svgUserToScreen(svg, svgRect, point) { - const viewBox = (svg.getAttribute("viewBox") || "") - .trim() - .split(/[\s,]+/) - .map(Number); - const hasViewBox = - viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0; - const scaleX = hasViewBox ? svgRect.width / viewBox[2] : 1; - const scaleY = hasViewBox ? svgRect.height / viewBox[3] : 1; - const minX = hasViewBox ? viewBox[0] : 0; - const minY = hasViewBox ? viewBox[1] : 0; return { - x: svgRect.left + (point.x - minX) * scaleX, - y: svgRect.top + (point.y - minY) * scaleY, + start: toScreen(path.getPointAtLength(0)), + end: toScreen(path.getPointAtLength(total)), }; } @@ -966,23 +994,22 @@ // Solid, compact elements a connector could plausibly anchor to. function connectorAnchorRects(root, rootRect) { - const rects = []; + const compact = []; + const painted = []; const rootArea = rectArea(rootRect); for (const element of Array.from(root.querySelectorAll("*"))) { 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 || area > rootArea * 0.15) continue; - if ( - !RASTER_TAGS.has(element.tagName) && - !textContentFor(element) && - !hasOpaqueBackground(getComputedStyle(element)) - ) { - continue; - } - rects.push(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 rects; + return { compact, painted }; } function isConnectorPath(svg, path) { @@ -992,41 +1019,46 @@ ); } - // A connector line whose BOTH endpoints land far from every anchorable element is drawn in the wrong frame. + // 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; - const svgRect = toRect(svg.getBoundingClientRect()); for (const path of Array.from(svg.querySelectorAll("path"))) { + if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue; if (!isConnectorPath(svg, path)) continue; - const endpoints = pathEndpointsFor(path.getAttribute("d")); + const endpoints = pathScreenEndpoints(svg, path); if (!endpoints) continue; if (anchors === null) anchors = connectorAnchorRects(root, rootRect); - if (anchors.length < 2) return issues; - const start = svgUserToScreen(svg, svgRect, endpoints.start); - const end = svgUserToScreen(svg, svgRect, endpoints.end); - const gap = Math.min( - Math.min(...anchors.map((rect) => distanceToRect(start, rect))), - Math.min(...anchors.map((rect) => distanceToRect(end, rect))), + 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))), + ), ); - if (gap <= threshold) continue; issues.push({ code: "connector_detached", severity: "warning", time, selector: selectorFor(path), containerSelector: selectorFor(svg), - message: `Connector path endpoints are ${Math.round(gap)}px from the nearest anchorable element — measured coordinates were likely drawn into an SVG with a different origin.`, + 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(start.x, end.x), - top: Math.min(start.y, end.y), - right: Math.max(start.x, end.x), - bottom: Math.max(start.y, end.y), - width: Math.abs(end.x - start.x), - height: Math.abs(end.y - start.y), + 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.", @@ -1127,8 +1159,9 @@ issues.push(...containerOverflowIssues(root, time, tolerance)); issues.push(...contentOverlapIssues(root, time)); - issues.push(...positionedOutOfParentIssues(root, time)); - issues.push(...boxCanvasOverflowIssues(root, rootRect, time)); + const escaped = escapedContainerIssues(root, time); + issues.push(...escaped.issues); + issues.push(...panelOutOfCanvasIssues(root, rootRect, time, escaped.flagged)); issues.push(...connectorDetachmentIssues(root, rootRect, time)); return issues; }; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index d1b55d6dd9..f775cf6ec9 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -494,10 +494,10 @@ describe("layout-audit.browser coordinate-frame findings", () => { clearGeometryCollector(); }); - it("flags a positioned element rendering mostly outside its positioning ancestor", () => { + it("flags a positioned element rendering far outside its offset parent", () => { document.body.innerHTML = `
-
+
`; installGeometry( @@ -506,16 +506,19 @@ describe("layout-audit.browser coordinate-frame findings", () => { 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 }), }, { - diagram: { position: "relative" }, node: { position: "absolute" }, badge: { position: "absolute" }, + callout: { position: "absolute" }, }, ); - + installOffsetParents({ node: "diagram", badge: "diagram", callout: "diagram" }); installAuditScript(); - const issues = runAudit().filter((issue) => issue.code === "positioned_out_of_parent"); + + 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", @@ -524,32 +527,67 @@ describe("layout-audit.browser coordinate-frame findings", () => { }); }); - it("flags a painted panel crossing the canvas and skips unpainted decoration", () => { + it("respects the allow-overflow opt-out and skips fixed elements", () => { + document.body.innerHTML = ` +
+
+
+
+
+
+ `; + 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 = `
-
+
+
+
`; installGeometry( { root: rect({ left: 0, top: 0, width: 1920, height: 1080 }), - panel: rect({ left: 1700, top: 600, width: 300, height: 300 }), + 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 }), + parked: rect({ left: 2200, top: 300, width: 600, height: 400 }), }, { - panel: { backgroundColor: "rgb(20, 20, 30)", paddingTop: "16px" }, + // 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)" }, + parked: { backgroundColor: "rgb(20, 20, 30)" }, }, ); - installAuditScript(); - const issues = runAudit().filter((issue) => issue.code === "box_out_of_canvas"); - expect(issues).toHaveLength(1); - expect(issues[0]).toMatchObject({ + + const issues = runAudit().filter((issue) => issue.code === "panel_out_of_canvas"); + // The unpainted glow and the fully off-canvas parked entrance stay silent. + expect(issues).toHaveLength(2); + expect(issues.find((issue) => issue.selector === "#hero")).toMatchObject({ severity: "warning", - selector: "#panel", - overflow: { right: 80 }, + overflow: { right: 280 }, }); + expect(issues.find((issue) => issue.selector === "#bleed")).toMatchObject({ severity: "info" }); }); it("flags connector paths drawn in a foreign frame and passes anchored ones", () => { @@ -558,6 +596,7 @@ describe("layout-audit.browser coordinate-frame findings", () => {
+ @@ -575,9 +614,14 @@ describe("layout-audit.browser coordinate-frame findings", () => { 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" }); }); @@ -587,7 +631,7 @@ describe("layout-audit.browser coordinate-frame findings", () => {
- +
`; installGeometry( @@ -595,15 +639,17 @@ describe("layout-audit.browser coordinate-frame findings", () => { 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 }), - art: rect({ left: 1400, top: 100, width: 400, height: 400 }), + "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([]); }); }); @@ -1159,6 +1205,45 @@ function installOcclusionGeometry(options: { document.getElementById(options.topmostId); } +function installOffsetParents(map: Record): 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); } diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 160ef4ac98..e310db9c78 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -955,8 +955,8 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "text_not_painted", "caption_zone_collision", "frame_out_of_frame", - "positioned_out_of_parent", - "box_out_of_canvas", + "escaped_container", + "panel_out_of_canvas", "connector_detached", "motion_appears_late", "motion_out_of_order", diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index 7ccf85ecaa..bccb025ae7 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -245,6 +245,35 @@ describe("persistence-tiered severity (#U10)", () => { 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"), @@ -276,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 }, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index ace79d123c..493afdc7f1 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -20,8 +20,8 @@ export type LayoutIssueCode = | "caption_zone_collision" | "frame_out_of_frame" // Coordinate-frame findings — geometry computed in one frame, rendered in another. - | "positioned_out_of_parent" - | "box_out_of_canvas" + | "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`). @@ -207,8 +207,8 @@ const PERSISTENCE_TIERED_CODES: ReadonlySet = new Set([ "container_overflow", "content_overlap", "text_occluded", - "positioned_out_of_parent", - "box_out_of_canvas", + "escaped_container", + "panel_out_of_canvas", "connector_detached", ]); @@ -262,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; @@ -289,7 +290,7 @@ function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): Layo return issue; } -// A canvas breach both held across samples and large relative to the canvas is a real defect, not an entrance transient. +// 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 || @@ -301,7 +302,13 @@ function isCanvasBreachHeldLarge(issue: LayoutIssue, occurrences: number): boole const breach = Math.max( ...Object.values(issue.overflow).filter((value) => typeof value === "number"), ); - return breach >= Math.min(issue.containerRect.width, issue.containerRect.height) * 0.05; + 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" @@ -351,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)}` : ""; } diff --git a/skills-manifest.json b/skills-manifest.json index 80fc0fe017..973306591c 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -26,7 +26,7 @@ "files": 99 }, "hyperframes-cli": { - "hash": "797fd624f0b83cb5", + "hash": "ecd5ec042ff500c7", "files": 7 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/lint-validate-inspect.md b/skills/hyperframes-cli/references/lint-validate-inspect.md index 92efe9db0b..f5a903a18c 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -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): From 84135c496324e85e7933b30cdd0301cb26a63387 Mon Sep 17 00:00:00 2001 From: xuanru Date: Mon, 13 Jul 2026 21:38:56 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(cli):=20panel=20ownership=20is=20geomet?= =?UTF-8?q?ric=20=E2=80=94=20direct-text=20panels=20were=20a=20silent=20fa?= =?UTF-8?q?lse=20negative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cli/src/commands/layout-audit.browser.js | 8 ++- .../src/commands/layout-audit.browser.test.ts | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index bc273e7bb0..3cac41ae46 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -915,7 +915,12 @@ for (const element of Array.from(root.querySelectorAll("*"))) { if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue; if (escapedElements.has(element)) continue; - if (hasOwnTextCandidate(element)) continue; + // Ownership is geometric, not elemental: skip only when the element's own text ALSO breaches + // (canvas_overflow owns that); a painted box breaching around in-bounds text is a panel finding. + if (hasOwnTextCandidate(element)) { + const textRect = textRectFor(element); + if (textRect && overflowFor(textRect, rootRect, floor)) 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. @@ -998,6 +1003,7 @@ const painted = []; const rootArea = rectArea(rootRect); for (const element of Array.from(root.querySelectorAll("*"))) { + // Known blind spot: anchors living inside an SVG (, foreignObject) are not counted. if (element.closest("svg") || !isVisibleElement(element)) continue; const opaque = RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(getComputedStyle(element)); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index f775cf6ec9..3395d49748 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -525,6 +525,8 @@ describe("layout-audit.browser coordinate-frame findings", () => { 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", () => { @@ -586,10 +588,60 @@ describe("layout-audit.browser coordinate-frame findings", () => { 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 painted hero whose box breaches while its direct text stays in-bounds", () => { + document.body.innerHTML = ` +
+
Title
+
+ `; + 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 = ` +
+
Very long breaching title
+
+ `; + 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 = `
@@ -624,6 +676,8 @@ describe("layout-audit.browser coordinate-frame findings", () => { // 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", () => { From 2c48e159c241e09ffd1f09100210d0142dd8bf0f Mon Sep 17 00:00:00 2001 From: xuanru Date: Mon, 13 Jul 2026 21:47:44 +0000 Subject: [PATCH 4/6] fix(cli): classify panel decoration by paint kind, not pointer-events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cli/src/commands/layout-audit.browser.js | 16 +++++++++++++--- .../src/commands/layout-audit.browser.test.ts | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 3cac41ae46..4dc1ccb84f 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -892,9 +892,19 @@ function isPaintedPanel(element) { if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) return false; const style = getComputedStyle(element); - // pointer-events:none is the decorative-layer convention (spotlights, grain, vignettes). - if (style.pointerEvents === "none") return false; - return hasPaint(style); + const image = style.backgroundImage || "none"; + // Gradient-only paint is decoration (spotlights, textures, vignettes); url() images, solid fills and borders are content. + if (image.includes("url(")) 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. diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 3395d49748..daaf280f18 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -562,6 +562,8 @@ describe("layout-audit.browser coordinate-frame findings", () => {
+
+
`; @@ -571,20 +573,29 @@ describe("layout-audit.browser coordinate-frame findings", () => { 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 and the fully off-canvas parked entrance stay silent. - expect(issues).toHaveLength(2); + // 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 }, From 6dc5504c98ff525d6d2f5953cbe83473e47d0e07 Mon Sep 17 00:00:00 2001 From: xuanru Date: Mon, 13 Jul 2026 21:49:46 +0000 Subject: [PATCH 5/6] fix(cli): add fixHint to the test-local AuditIssue shape --- packages/cli/src/commands/layout-audit.browser.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index daaf280f18..1f5ac8c620 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1392,6 +1392,7 @@ interface AuditIssue { containerSelector?: string; overflow?: Record; message?: string; + fixHint?: string; coveredFraction?: number; } From 511bcb7920fcedad51459c55f7f589aec869448b Mon Sep 17 00:00:00 2001 From: xuanru Date: Mon, 13 Jul 2026 22:13:34 +0000 Subject: [PATCH 6/6] fix(cli): gradient stops decide content vs decoration; ownership matches canvas_overflow's tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cli/src/commands/layout-audit.browser.js | 18 ++++--- .../src/commands/layout-audit.browser.test.ts | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 4dc1ccb84f..7907dcb676 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -889,12 +889,18 @@ 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"; - // Gradient-only paint is decoration (spotlights, textures, vignettes); url() images, solid fills and borders are content. if (image.includes("url(")) return true; + if (image !== "none" && gradientHasOpaqueStop(image)) return true; if (!isTransparentColor(style.backgroundColor) && colorAlpha(style.backgroundColor) > 0.05) { return true; } @@ -914,7 +920,7 @@ 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, escapedElements) { + function panelOutOfCanvasIssues(root, rootRect, time, tolerance, escapedElements) { const issues = []; const floor = Math.max( PANEL_BREACH_FLOOR_PX, @@ -925,11 +931,11 @@ for (const element of Array.from(root.querySelectorAll("*"))) { if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue; if (escapedElements.has(element)) continue; - // Ownership is geometric, not elemental: skip only when the element's own text ALSO breaches - // (canvas_overflow owns that); a painted box breaching around in-bounds text is a panel finding. + // 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, floor)) continue; + if (textRect && overflowFor(textRect, rootRect, tolerance)) continue; } const rect = toRect(element.getBoundingClientRect()); if (rectArea(rect) >= rootArea * 0.95) continue; @@ -1177,7 +1183,7 @@ issues.push(...contentOverlapIssues(root, time)); const escaped = escapedContainerIssues(root, time); issues.push(...escaped.issues); - issues.push(...panelOutOfCanvasIssues(root, rootRect, time, escaped.flagged)); + issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged)); issues.push(...connectorDetachmentIssues(root, rootRect, time)); return issues; }; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 1f5ac8c620..5c4aeea5f0 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -607,6 +607,55 @@ describe("layout-audit.browser coordinate-frame findings", () => { 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 = ` +
+
+
+ `; + 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 = ` +
+
Barely breaching title
+
+ `; + 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 = `