Files
hyperframes/packages/cli/src/commands/layout-audit.browser.js
T
Miguel Angel Simon Sierra 94f6de8b8d feat(cli): persistence-tiered findings, frozen-sweep guard, occlusion coverage
Layout findings now distinguish held defects from entrance/exit
transients: a dynamic issue seen at a single grid sample demotes to
info, while content_overlap held across two-plus samples (or 500ms+)
promotes to error, resolving the long-standing re-promotion TODO. Static
compositions keep their severity. check gains a sweep_static error when
a 3s+ composition shows zero geometry change across every sample (a
frozen timeline makes every green verdict unreliable); skipped when the
motion sidecar already reported motion_frozen. text_occluded findings
carry a coveredFraction; atomic labels (short, no whitespace) flag on
any cover while prose needs 15%, since partial cover changes what a
short label reads as.

Deprecation-test scaffolding consolidates into deprecationTestHarness;
tier logic and logger tests restructured under the complexity gate
without suppression markers.

Detection mechanics adapted from Adam Rosler's open-sourced
visual-linter design (github.com/Adam-Rosler/hyperframes-visual-linter-design);
the elementFromPoint paint model, opt-out attributes, and single-audit
architecture are unchanged.
2026-07-10 13:27:52 -04:00

915 lines
35 KiB
JavaScript

(function () {
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
function toRect(rect) {
return {
left: round(rect.left),
top: round(rect.top),
right: round(rect.right),
bottom: round(rect.bottom),
width: round(rect.width),
height: round(rect.height),
};
}
function rectFromOrigin(left, top, width, height) {
return {
left: round(left),
top: round(top),
right: round(left + width),
bottom: round(top + height),
width: round(width),
height: round(height),
};
}
function round(value) {
return Math.round(value * 100) / 100;
}
function overflowFor(subject, container, tolerance, vTolerance) {
// Horizontal axis uses `tolerance`; vertical axis uses `vTolerance` (defaults to the same).
// A separate vertical tolerance lets text overflow checks absorb glyph ink that exceeds a
// snug line-height — see textOverflowIssues.
if (vTolerance == null) vTolerance = tolerance;
const overflow = {};
if (subject.left < container.left - tolerance)
overflow.left = round(container.left - subject.left);
if (subject.right > container.right + tolerance)
overflow.right = round(subject.right - container.right);
if (subject.top < container.top - vTolerance) overflow.top = round(container.top - subject.top);
if (subject.bottom > container.bottom + vTolerance)
overflow.bottom = round(subject.bottom - container.bottom);
return Object.keys(overflow).length > 0 ? overflow : null;
}
function escapeCss(value) {
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}
function escapeAttr(value) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function selectorFor(element) {
if (element.id) return `#${escapeCss(element.id)}`;
const dataName =
element.getAttribute("data-layout-name") ||
element.getAttribute("data-composition-id") ||
element.getAttribute("data-start");
if (dataName) {
const attr = element.hasAttribute("data-layout-name")
? "data-layout-name"
: element.hasAttribute("data-composition-id")
? "data-composition-id"
: "data-start";
const attrSelector = `[${attr}="${escapeAttr(dataName)}"]`;
if (document.querySelectorAll(attrSelector).length === 1) return attrSelector;
return `${element.tagName.toLowerCase()}${attrSelector}`;
}
const classes = Array.from(element.classList).slice(0, 2);
if (classes.length > 0) {
return `${element.tagName.toLowerCase()}.${classes.map(escapeCss).join(".")}`;
}
const parent = element.parentElement;
if (!parent) return element.tagName.toLowerCase();
const siblings = Array.from(parent.children).filter(
(child) => child.tagName === element.tagName,
);
const index = siblings.indexOf(element) + 1;
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
}
function uniqueSelectorFor(element) {
const preferred = selectorFor(element);
try {
if (document.querySelectorAll(preferred).length === 1) return preferred;
} catch {
// Fall through to a structural selector.
}
const parent = element.parentElement;
if (!parent) return preferred;
const siblings = Array.from(parent.children).filter(
(child) => child.tagName === element.tagName,
);
const index = siblings.indexOf(element) + 1;
return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
}
function hasIgnoreFlag(element) {
return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']");
}
function hasAllowOverflowFlag(element) {
return !!element.closest("[data-layout-allow-overflow]");
}
function opacityChain(element) {
let opacity = 1;
for (let current = element; current; current = current.parentElement) {
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
if (Number.isFinite(parsed)) opacity *= parsed;
}
return opacity;
}
function hasOpacityBelow(element, floor) {
for (let current = element; current; current = current.parentElement) {
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
if (Number.isFinite(parsed) && parsed < floor) return true;
}
return false;
}
// A clip-path can shrink an element's painted region to nothing (e.g. a
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
// its layout box, opacity, visibility and display all still read as present.
// Such an element paints zero pixels, so flagging it for overlap/occlusion is
// a false positive. clip-path also drives hit-testing, so an element clipped
// to nothing is unreachable by elementFromPoint anywhere in its box; only run
// the probe when a clip-path is actually in effect (self or ancestor) to avoid
// mistaking a genuinely-occluded element for a clipped one.
function hasClipPath(element) {
for (let current = element; current; current = current.parentElement) {
const clip = getComputedStyle(current).clipPath;
if (clip && clip !== "none") return true;
}
return false;
}
const CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
const CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
function paintsAnyProbePoint(element, rect) {
// Probe resolution intentionally treats edge strips narrower than the
// nearest probe point as clipped away. That avoids noisy reports for
// typewriter pre-reveal states; if a real visible-strip bug appears, add
// edge probes here before widening the audit surface.
for (const fx of CLIP_PROBE_COLS) {
for (const fy of CLIP_PROBE_ROWS) {
const hit = document.elementFromPoint(
rect.left + rect.width * fx,
rect.top + rect.height * fy,
);
if (hit === element || element.contains(hit)) return true;
}
}
return false;
}
function isClippedAway(element) {
if (typeof document.elementFromPoint !== "function") return false;
if (!hasClipPath(element)) return false;
const rect = element.getBoundingClientRect();
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
return !paintsAnyProbePoint(element, rect);
}
function isVisibleElement(element, opacityFloor, probeClipPath) {
if (IGNORE_TAGS.has(element.tagName)) return false;
if (hasIgnoreFlag(element)) return false;
if (
opacityFloor != null &&
typeof element.checkVisibility === "function" &&
!element.checkVisibility({
opacityProperty: true,
visibilityProperty: true,
contentVisibilityAuto: true,
})
) {
return false;
}
const style = getComputedStyle(element);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse"
) {
return false;
}
if (
opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor)
) {
return false;
}
const rect = element.getBoundingClientRect();
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
return probeClipPath === false || !isClippedAway(element);
}
function directTextNodes(element) {
return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
}
function textContentFor(element, ownTextOnly) {
const content = ownTextOnly
? directTextNodes(element)
.map((node) => node.textContent || "")
.join("")
: element.innerText || element.textContent || "";
return content.replace(/\s+/g, " ").trim();
}
function hasOwnTextCandidate(element, directOnly) {
const text = textContentFor(element, directOnly);
if (!text) return false;
if (directOnly) return true;
for (const child of Array.from(element.children)) {
if (isVisibleElement(child) && textContentFor(child)) return false;
}
return true;
}
function textClientRects(element, directOnly) {
const subjects = directOnly ? directTextNodes(element) : [element];
const rects = [];
for (const subject of subjects) {
const range = document.createRange();
range.selectNodeContents(subject);
rects.push(
...Array.from(range.getClientRects()).filter(
(rect) => rect.width > 0.5 && rect.height > 0.5,
),
);
range.detach();
}
return rects;
}
function textRectFor(element, directOnly) {
const rects = textClientRects(element, directOnly);
if (rects.length === 0) return null;
const union = rects.reduce(
(acc, rect) => ({
left: Math.min(acc.left, rect.left),
top: Math.min(acc.top, rect.top),
right: Math.max(acc.right, rect.right),
bottom: Math.max(acc.bottom, rect.bottom),
}),
{
left: Number.POSITIVE_INFINITY,
top: Number.POSITIVE_INFINITY,
right: Number.NEGATIVE_INFINITY,
bottom: Number.NEGATIVE_INFINITY,
},
);
return toRect({
...union,
width: union.right - union.left,
height: union.bottom - union.top,
});
}
function parsePx(value) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function hasMeaningfulBoxStyle(style) {
return (
parsePx(style.paddingTop) +
parsePx(style.paddingRight) +
parsePx(style.paddingBottom) +
parsePx(style.paddingLeft) +
parsePx(style.borderTopWidth) +
parsePx(style.borderRightWidth) +
parsePx(style.borderBottomWidth) +
parsePx(style.borderLeftWidth) +
parsePx(style.borderTopLeftRadius) +
parsePx(style.borderTopRightRadius) +
parsePx(style.borderBottomRightRadius) +
parsePx(style.borderBottomLeftRadius) >
0
);
}
function hasPaint(style) {
const backgroundColor = style.backgroundColor || "";
const hasBackground =
backgroundColor !== "" &&
backgroundColor !== "transparent" &&
!backgroundColor.endsWith(", 0)") &&
backgroundColor !== "rgba(0, 0, 0, 0)";
const hasImage = style.backgroundImage && style.backgroundImage !== "none";
const hasBorder =
parsePx(style.borderTopWidth) +
parsePx(style.borderRightWidth) +
parsePx(style.borderBottomWidth) +
parsePx(style.borderLeftWidth) >
0;
const hasRadius =
parsePx(style.borderTopLeftRadius) +
parsePx(style.borderTopRightRadius) +
parsePx(style.borderBottomRightRadius) +
parsePx(style.borderBottomLeftRadius) >
0;
return hasBackground || hasImage || hasBorder || hasRadius;
}
function clipsOverflow(style) {
return [style.overflowX, style.overflowY, style.overflow].some(
(value) => value && value !== "visible" && value !== "clip visible",
);
}
function rootRectFor(root) {
const measured = toRect(root.getBoundingClientRect());
const authoredWidth = Number.parseFloat(root.getAttribute("data-width") || "");
const authoredHeight = Number.parseFloat(root.getAttribute("data-height") || "");
const hasAuthoredSize =
Number.isFinite(authoredWidth) &&
authoredWidth > 0 &&
Number.isFinite(authoredHeight) &&
authoredHeight > 0;
if (!hasAuthoredSize) return measured;
if (measured.width > 0.5 && measured.height > 0.5) return measured;
return rectFromOrigin(measured.left, measured.top, authoredWidth, authoredHeight);
}
function isConstraintCandidate(element, root, rootRect) {
if (element === root) return true;
const style = getComputedStyle(element);
if (clipsOverflow(style)) return true;
if (element.hasAttribute("data-layout-boundary")) return true;
if (!hasPaint(style)) return false;
if (!hasMeaningfulBoxStyle(style)) return false;
const rect = element.getBoundingClientRect();
const rootArea = rootRect.width * rootRect.height;
const area = rect.width * rect.height;
return area > 0 && area < rootArea * 0.95;
}
function nearestConstraint(element, root, rootRect) {
for (
let current = element;
current && current !== document.body;
current = current.parentElement
) {
if (!isVisibleElement(current)) continue;
if (isConstraintCandidate(current, root, rootRect)) return current;
if (current === root) return current;
}
return root;
}
function formatPx(value) {
return `${Math.round(value)}px`;
}
function maxOverflow(overflow) {
return Math.max(...Object.values(overflow).filter((value) => typeof value === "number"));
}
function textOverflowFixHint(textRect, containerRect, overflow, fontSize, targetName) {
const horizontalOverflow = (overflow.left || 0) + (overflow.right || 0);
const verticalOverflow = (overflow.top || 0) + (overflow.bottom || 0);
const neededWidth = containerRect.width + horizontalOverflow;
const neededHeight = containerRect.height + verticalOverflow;
const widthRatio = containerRect.width > 0 ? containerRect.width / textRect.width : 0;
const heightRatio = containerRect.height > 0 ? containerRect.height / textRect.height : 0;
const limitingRatio = Math.min(
widthRatio > 0 ? widthRatio : Number.POSITIVE_INFINITY,
heightRatio > 0 ? heightRatio : Number.POSITIVE_INFINITY,
);
const shrinkPercent =
Number.isFinite(limitingRatio) && limitingRatio < 1
? Math.ceil((1 - limitingRatio) * 100)
: 0;
const targetFont =
shrinkPercent > 0 && Number.isFinite(fontSize) && fontSize > 0
? ` or shrink font-size from ${formatPx(fontSize)} to ~${formatPx(fontSize * limitingRatio)}`
: "";
const sizeTarget =
horizontalOverflow > 0 && verticalOverflow > 0
? `resize ${targetName} to at least ~${formatPx(neededWidth)} x ${formatPx(neededHeight)}`
: horizontalOverflow > 0
? `widen ${targetName} to at least ~${formatPx(neededWidth)}`
: `increase ${targetName} height to at least ~${formatPx(neededHeight)}`;
return `Text is ${formatPx(textRect.width)} x ${formatPx(textRect.height)} inside ${formatPx(containerRect.width)} x ${formatPx(containerRect.height)} and overflows by up to ${formatPx(maxOverflow(overflow))}; ${sizeTarget}${targetFont}, or allow wrapping with max-width/fitTextFontSize.`;
}
function clippedTextIssue(element, time, tolerance) {
const style = getComputedStyle(element);
if (!clipsOverflow(style)) return null;
const overflowX = element.scrollWidth - element.clientWidth;
const overflowY = element.scrollHeight - element.clientHeight;
if (overflowX <= tolerance && overflowY <= tolerance) return null;
const overflow = {};
if (overflowX > tolerance) overflow.right = round(overflowX);
if (overflowY > tolerance) overflow.bottom = round(overflowY);
const selector = selectorFor(element);
const text = textContentFor(element);
const rect = toRect(element.getBoundingClientRect());
const fontSize = parsePx(style.fontSize);
return {
code: "clipped_text",
severity: "error",
time,
selector,
text,
message: "Text content is clipped by its own box.",
rect,
overflow,
fixHint: textOverflowFixHint(rect, rect, overflow, fontSize, "the text box"),
};
}
// An ancestor (up to and including `stopAt`) that clips its overflow makes any
// text spilling past it invisible — that clipping IS the layout mechanism
// (odometer/ticker reels, masked windows), not a defect to report.
function clippedByAncestor(element, stopAt) {
for (let current = element; current; current = current.parentElement) {
if (current !== element && clipsOverflow(getComputedStyle(current))) return true;
if (current === stopAt) break;
}
return false;
}
function textOverflowIssues(element, root, rootRect, time, tolerance) {
const textRect = textRectFor(element);
if (!textRect) return [];
const text = textContentFor(element);
const selector = selectorFor(element);
const issues = [];
const container = nearestConstraint(element, root, rootRect);
const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect());
// Glyph ink (ascenders / descenders / accents / heavy display faces) routinely exceeds a
// snug line-height box by a few px, proportional to font size. When the constraining box
// does NOT clip, that vertical spill is normal typography — it shows in the padding, nothing
// is hidden — not a layout defect (it false-flagged caption words). Allow a font-metric
// vertical tolerance there; keep it tight when the box actually clips (a real cut-off) and
// always tight horizontally (too-wide text is a real wrap/legibility issue).
const elementStyle = getComputedStyle(element);
const containerClips = clipsOverflow(
container === root ? getComputedStyle(root) : getComputedStyle(container),
);
const verticalTolerance = containerClips
? tolerance
: Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2);
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
if (
containerOverflow &&
!hasAllowOverflowFlag(element) &&
!clippedByAncestor(element, container)
) {
const style = elementStyle;
issues.push({
code: "text_box_overflow",
severity: "error",
time,
selector,
containerSelector: selectorFor(container),
text,
message: "Text extends outside its nearest visual/container box.",
rect: textRect,
containerRect,
overflow: containerOverflow,
fixHint: textOverflowFixHint(
textRect,
containerRect,
containerOverflow,
parsePx(style.fontSize),
"the container",
),
});
}
const canvasOverflow = overflowFor(textRect, rootRect, tolerance);
if (canvasOverflow && !hasAllowOverflowFlag(element)) {
issues.push({
code: "canvas_overflow",
severity: "info",
time,
selector,
containerSelector: selectorFor(root),
text,
message: "Text extends outside the composition canvas.",
rect: textRect,
containerRect: rootRect,
overflow: canvasOverflow,
fixHint:
"Move the text inward, reduce its size, or mark intentional off-canvas animation with data-layout-allow-overflow.",
});
}
return issues;
}
function containerOverflowIssues(root, time, tolerance) {
const issues = [];
const containers = Array.from(root.querySelectorAll("*")).filter((element) => {
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) return false;
const style = getComputedStyle(element);
return clipsOverflow(style) || element.hasAttribute("data-layout-boundary");
});
for (const container of containers) {
const containerRect = toRect(container.getBoundingClientRect());
for (const child of Array.from(container.children)) {
if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) continue;
const childRect = toRect(child.getBoundingClientRect());
const overflow = overflowFor(childRect, containerRect, tolerance);
if (!overflow) continue;
issues.push({
code: "container_overflow",
severity: "warning",
time,
selector: selectorFor(child),
containerSelector: selectorFor(container),
message: "Element extends outside a clipping layout container.",
rect: childRect,
containerRect,
overflow,
fixHint:
"Resize/reposition the child or container, or mark intentional overflow with data-layout-allow-overflow.",
});
}
}
return issues;
}
function hasAllowOverlapFlag(element) {
return !!element.closest("[data-layout-allow-overlap]");
}
function isTransparentColor(color) {
return (
!color || color === "transparent" || color === "rgba(0, 0, 0, 0)" || color.endsWith(", 0)")
);
}
function alphaFromParts(parts, index) {
return parts.length > index ? parsePx(parts[index]) : 1;
}
// Alpha of a CSS colour; 1 when no alpha component is present. Handles both
// legacy `rgba(r, g, b, a)` and modern `rgb(r g b / a)` syntaxes.
function colorAlpha(color) {
const match = (color || "").match(/rgba?\(([^)]+)\)/);
if (!match) return 1;
const body = match[1];
return body.includes(",")
? alphaFromParts(body.split(","), 3)
: alphaFromParts(body.split("/"), 1);
}
// A text block competes for space only when it is solid: watermark-style text
// (low colour alpha) is decorative and exempt, as are elements opted out with
// data-layout-allow-overlap.
function isSolidTextBlock(element) {
if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false;
if (hasAllowOverlapFlag(element)) return false;
return colorAlpha(getComputedStyle(element).color) >= 0.35;
}
function collectSolidTextBlocks(root) {
const blocks = [];
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!isSolidTextBlock(element)) continue;
const rect = textRectFor(element);
if (rect) blocks.push({ element, rect });
}
return blocks;
}
function rectArea(rect) {
return rect.width * rect.height;
}
function intersectionArea(a, b) {
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
}
function isNested(a, b) {
return a.contains(b) || b.contains(a);
}
function isInFlow(element) {
const position = getComputedStyle(element).position;
return position === "static" || position === "relative" || position === "sticky";
}
function nearestFlexGridAncestor(element) {
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
const display = getComputedStyle(parent).display;
if (display.includes("flex") || display.includes("grid")) return parent;
}
return null;
}
// Two in-flow text blocks governed by the same flex/grid container are placed
// by the layout engine, which reserves space for each — they cannot visually
// collide. Any measured text-rect overlap between them is line-box / leading
// slop (tight stacks, number lockups, super/subscript units), not a collision.
// A real overlap bug needs free positioning (absolute/fixed), which keeps a
// different formatting context and is still flagged.
function isManagedFlowOverlap(a, b) {
if (!isInFlow(a) || !isInFlow(b)) return false;
const container = nearestFlexGridAncestor(a);
return !!container && container === nearestFlexGridAncestor(b);
}
// Two solid text blocks whose boxes overlap by more than a fifth of the
// smaller block read as a collision — unreadable, and invisible to the
// overflow checks, which only compare an element against its container.
function overlapIssue(a, b, time) {
if (isNested(a.element, b.element)) return null;
if (isManagedFlowOverlap(a.element, b.element)) return null;
const area = intersectionArea(a.rect, b.rect);
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
return {
// Warning at the per-sample level: a single-sample overlap is usually an
// entrance/exit transient (two blocks crossing mid-animation), not a real
// collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes
// this to error once the SAME overlap is held across >= 2 adjacent samples
// (or ~500ms of timeline) — a persistence-tiered replacement for the old
// "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10).
code: "content_overlap",
severity: "warning",
time,
selector: selectorFor(a.element),
containerSelector: selectorFor(b.element),
text: textContentFor(a.element),
message: "Two text blocks overlap and may render unreadable.",
rect: a.rect,
fixHint:
"Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.",
};
}
function contentOverlapIssues(root, time) {
const blocks = collectSolidTextBlocks(root);
const issues = [];
for (let i = 0; i < blocks.length; i++) {
for (let j = i + 1; j < blocks.length; j++) {
const issue = overlapIssue(blocks[i], blocks[j], time);
if (issue) issues.push(issue);
}
}
return issues;
}
function hasOpaqueBackground(style) {
if (style.backgroundImage && style.backgroundImage !== "none") return true;
if (isTransparentColor(style.backgroundColor)) return false;
return colorAlpha(style.backgroundColor) > 0.6;
}
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
// An element hides text beneath it when it paints opaque pixels at near-full
// opacity: raster content (img/video/canvas), a background image, or a solid
// background colour. Low-opacity overlays (grain, scrims) do not occlude.
function isOpaqueOccluder(element) {
if (opacityChain(element) < 0.6) return false;
if (IGNORE_TAGS.has(element.tagName)) return false;
if (RASTER_TAGS.has(element.tagName)) return true;
return hasOpaqueBackground(getComputedStyle(element));
}
function hasAllowOcclusionFlag(element) {
return !!element.closest("[data-layout-allow-occlusion]");
}
// A foreign element is one painted independently of the text — not the text
// itself, its own subtree, or an ancestor it shares a background with.
function isForeignElement(element, hit) {
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
}
// During a scene-to-scene crossfade the incoming scene paints over the
// outgoing scene's still-visible text at >= 0.6 opacity — and `--at-transitions`
// samples exactly that midpoint. That overlap is the transition doing its job,
// not an occlusion bug. Detect it: the occluder lives in a DIFFERENT composition
// mount ([data-composition-id]) than the text, and at least one of the two scenes
// is mid-fade (effective opacity < 1). Two fully-settled scenes overlapping
// (both opacity 1) is NOT suppressed — that is a real layering bug.
function isCrossSceneTransitionOverlap(textEl, occluder) {
const textScene = textEl.closest("[data-composition-id]");
const occluderScene = occluder.closest("[data-composition-id]");
if (!textScene || !occluderScene || textScene === occluderScene) return false;
return Math.min(opacityChain(textScene), opacityChain(occluderScene)) < 0.999;
}
// The nearest ancestor establishing a 3D rendering context, or null. Elements
// sharing one are depth-sorted in 3D, so a "covering" hit is legitimate
// perspective (e.g. the back face of a preserve-3d cube), not a 2D overlap.
function preserve3dContext(element) {
for (let current = element; current; current = current.parentElement) {
const ts = getComputedStyle(current).transformStyle;
if (ts === "preserve-3d") return current;
}
return null;
}
function sharedPreserve3d(a, b) {
const ctx = preserve3dContext(a);
return !!ctx && ctx === preserve3dContext(b);
}
// The opaque element painted over (x, y), or null when the topmost element
// there is related to the text, non-opaque, sharing a 3D context with it, or
// part of a transient crossfade overlap.
// fallow-ignore-next-line complexity
function occluderAt(element, x, y) {
if (typeof document.elementFromPoint !== "function") return null;
const hit = document.elementFromPoint(x, y);
if (!isForeignElement(element, hit)) return null;
if (sharedPreserve3d(element, hit)) return null;
if (!isOpaqueOccluder(hit)) return null;
if (isCrossSceneTransitionOverlap(element, hit)) return null;
return hit;
}
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
const OCCLUSION_GRID_POINTS =
OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length;
// Short, atomic text (a label/button/word, no whitespace) reads as a single
// unit — ANY covered probe point changes what it says, so flag at any hit
// (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
// once a real share of it is covered — see `occludedTextIssue`.
const ATOMIC_LABEL_MAX_CHARS = 16;
const PROSE_COVERAGE_FLOOR = 0.15;
function isAtomicLabel(text) {
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
}
// Sweep a grid across the text box (three rows, not just the mid-line, so
// overlays covering only part of a multi-line block are caught). Unlike a
// first-hit scan, this keeps sampling every point so it can report what
// fraction of the box is actually covered — a corner nibble on a paragraph
// reads very differently from a label buried under an overlay. Still
// returns the first opaque element found, for `containerSelector`.
function occlusionCoverage(element, textRect) {
let occluder = null;
let hits = 0;
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
const y = textRect.top + textRect.height * yFraction;
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
if (!hit) continue;
hits += 1;
if (!occluder) occluder = hit;
}
}
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
}
// Catches the blind spot the overflow checks miss: text that fits its box
// perfectly but is covered by a later sibling/overlay. An atomic label
// (short, no whitespace) flags at any coverage; ordinary prose only flags
// once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge
// cover on a paragraph is usually a styling artifact, not a reading defect.
function occludedTextIssue(element, time) {
if (hasAllowOcclusionFlag(element)) return null;
const textRect = textRectFor(element);
if (!textRect) return null;
const text = textContentFor(element);
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
if (!occluder) return null;
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
return {
code: "text_occluded",
severity: "error",
time,
selector: selectorFor(element),
containerSelector: selectorFor(occluder),
text,
message: "Text is hidden beneath an opaque element.",
rect: textRect,
coveredFraction,
fixHint:
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
};
}
function candidateAnchor(element) {
const dataAttributes = {};
for (const attribute of Array.from(element.attributes)) {
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
}
const source = element
.closest("[data-composition-file]")
?.getAttribute("data-composition-file");
return {
selector: uniqueSelectorFor(element),
dataAttributes,
sourceFile: source || "index.html",
};
}
function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) {
const tag = element.tagName.toLowerCase();
const text = kind === "text" ? textContentFor(element, true) : tag;
const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null;
return {
kind,
tag,
text,
rect,
elementRect,
...candidateAnchor(element),
...(overflow ? { overflow } : {}),
};
}
window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) {
const includeText = options?.text === true;
const includeMedia = options?.media === true;
if (!includeText && !includeMedia) return [];
const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2;
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const rootRect = rootRectFor(root);
const candidates = [];
for (const element of Array.from(document.querySelectorAll("body *"))) {
if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) {
continue;
}
if (!isVisibleElement(element, 0.05, false)) continue;
const elementRect = toRect(element.getBoundingClientRect());
if (includeText && hasOwnTextCandidate(element, true)) {
const rect = textRectFor(element, true);
if (rect) {
candidates.push(
geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance),
);
}
}
if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) {
candidates.push(
geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance),
);
}
}
return candidates;
};
window.__hyperframesLayoutAudit = function auditLayout(options) {
const time = options && typeof options.time === "number" ? options.time : 0;
const tolerance =
options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2;
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const rootRect = rootRectFor(root);
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
isVisibleElement(element),
);
const issues = [];
for (const element of elements) {
if (!hasOwnTextCandidate(element)) continue;
const clipped = clippedTextIssue(element, time, tolerance);
if (clipped) issues.push(clipped);
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
const occluded = occludedTextIssue(element, time);
if (occluded) issues.push(occluded);
}
issues.push(...containerOverflowIssues(root, time, tolerance));
issues.push(...contentOverlapIssues(root, time));
return issues;
};
// Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample
// fingerprint of every visible element's box + opacity, in DOM order. Node
// calls this once per seeked grid point and compares the strings across the
// whole run — if every sample produces the identical string, the seek never
// actually moved anything and the whole audit run is unreliable. Deliberately
// a single opaque string (not a structured array) since Node only ever needs
// equality, not per-element diffing.
window.__hyperframesLayoutGeometry = function collectLayoutGeometry() {
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
isVisibleElement(element),
);
return elements
.map((element) => {
const rect = toRect(element.getBoundingClientRect());
const opacity = round(opacityChain(element));
return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`;
})
.join("|");
};
})();