diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 91094b0ed..9204e1a0c 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 0d71599f7..1755e4973 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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 (, 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; }; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 891a228dd..3034df101 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -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 = ` +
+
+
+ `; + 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 = ` +
+
+
+
+
+
+ `; + 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 }), + 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 = ` +
+
+
+ `; + 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 = ` +
+
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 = ` +
+
+
+ + + + + +
+ `; + 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 = ` +
+
+
+ +
+ `; + 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): 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; message?: string; + fixHint?: string; coveredFraction?: number; } diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 3f2abdc6b..e310db9c7 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", + "escaped_container", + "panel_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 ba5717103..bccb025ae 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -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 }, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index bcec7cb12..493afdc7f 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. + | "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 = 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)}` : ""; } diff --git a/skills-manifest.json b/skills-manifest.json index 80fc0fe01..973306591 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 92efe9db0..f5a903a18 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):