mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): occlusion-probe false positives — pointer-events blindness, low-alpha gradients, not-yet-entered text (#2357)
* fix(cli): three occlusion-probe false-positive sources in text_occluded - pointer-events:none text is invisible to elementFromPoint, so the probe always hit whatever paints beneath and misread visible text as buried; restore hit-testing on the element for the duration of the probe - a backgroundImage counted as opaque regardless of alpha, so a 4%-alpha grid/scrim gradient qualified as an occluder; gradients now occlude only when their colours reach alpha > 0.6 (url() images unchanged) - a visible container whose every text-bearing descendant is still at opacity 0 (entrance not started) was probed anyway; skip when no text ink is on screen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): address review — document-wide hit-testing restore, gradient compositing, whitespace ink - restore hit-testing for ALL pointer-events:none elements during the text audit pass (not just the probed text): an occluder that itself carries pointer-events:none is invisible to elementFromPoint, which made truly buried text read as clean once the text alone became hittable - hasVisibleTextInk ignores whitespace-only text nodes (indented markup defeated the gate) and uses a 0.05 floor so mid-fade text keeps its persistence occurrences - hasOpaqueBackground composites gradient alpha with background-color (two 0.5-alpha layers paint at ~0.75); gradientMaxAlpha returns opaque for any colour function it cannot score (oklch/lab/...); percentage alpha values now parse as fractions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): walk the elementsFromPoint stack in occluderAt A transparent layer that becomes hittable (pointer-events restored) must not mask an opaque occluder painting beneath it — single-point elementFromPoint returned the transparent top and dropped two genuinely buried cases in the census acceptance run; the stack walk keeps 10/10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): composite stacked background-image layers when judging occluder opacity Two 0.5-alpha gradient layers paint at 0.75 combined; taking the max color-stop alpha across the whole declaration under-counted them and suppressed real text_occluded findings. Split layers at top-level commas (paren-aware), score each, composite as 1-prod(1-a_i). Also pins the 0.05 text-ink floor with a boundary test (review feedback on #2357). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): keep walking the occlusion stack past pair-specific exemptions sharedPreserve3d and isCrossSceneTransitionOverlap excuse one hit, not the whole probe; returning null let a transparent decorative layer in the text's 3D context mask a real occluder below it (review feedback). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3df59fc0a4
commit
3e3b37d37f
@@ -546,7 +546,9 @@
|
||||
}
|
||||
|
||||
function alphaFromParts(parts, index) {
|
||||
return parts.length > index ? parsePx(parts[index]) : 1;
|
||||
if (parts.length <= index) return 1;
|
||||
const raw = parts[index].trim();
|
||||
return raw.endsWith("%") ? parsePx(raw) / 100 : parsePx(raw);
|
||||
}
|
||||
|
||||
// Alpha of a CSS colour; 1 when no alpha component is present. Handles both
|
||||
@@ -659,9 +661,71 @@
|
||||
}
|
||||
|
||||
function hasOpaqueBackground(style) {
|
||||
if (style.backgroundImage && style.backgroundImage !== "none") return true;
|
||||
if (isTransparentColor(style.backgroundColor)) return false;
|
||||
return colorAlpha(style.backgroundColor) > 0.6;
|
||||
let imageAlpha = 0;
|
||||
if (style.backgroundImage && style.backgroundImage !== "none") {
|
||||
if (style.backgroundImage.includes("url(")) return true;
|
||||
// A gradient only occludes as much as its colours — a 4%-alpha grid/scrim must not count.
|
||||
imageAlpha = gradientLayersAlpha(style.backgroundImage);
|
||||
}
|
||||
const colorValue = isTransparentColor(style.backgroundColor)
|
||||
? 0
|
||||
: colorAlpha(style.backgroundColor);
|
||||
// Layers composite: a 0.5 gradient over a 0.5 background colour paints at ~0.75.
|
||||
return 1 - (1 - imageAlpha) * (1 - colorValue) > 0.6;
|
||||
}
|
||||
|
||||
// background-image layers stack: two 0.5-alpha gradients paint at 1-(1-.5)^2 = .75.
|
||||
function gradientLayersAlpha(backgroundImage) {
|
||||
let combined = 0;
|
||||
for (const layer of splitTopLevelCommas(backgroundImage)) {
|
||||
combined = 1 - (1 - combined) * (1 - gradientMaxAlpha(layer));
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
function splitTopLevelCommas(value) {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const ch = value[i];
|
||||
if (ch === "(") depth += 1;
|
||||
else if (ch === ")") depth -= 1;
|
||||
else if (ch === "," && depth === 0) {
|
||||
parts.push(value.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(value.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function gradientMaxAlpha(backgroundImage) {
|
||||
// Any colour we cannot score (oklch/lab/named-colour fns/...) counts as opaque so real panels keep flagging.
|
||||
const known = backgroundImage
|
||||
.replace(/(?:repeating-)?(?:linear|radial|conic)-gradient\(/gi, "(")
|
||||
.replace(/rgba?\([^)]*\)/gi, "");
|
||||
if (/[a-z][a-z-]+\(/i.test(known)) return 1;
|
||||
const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g);
|
||||
if (!colors) return 1;
|
||||
let max = 0;
|
||||
for (const color of colors) {
|
||||
if (color === "transparent") continue;
|
||||
if (color.startsWith("#")) {
|
||||
const hex = color.slice(1);
|
||||
max = Math.max(
|
||||
max,
|
||||
hex.length === 4
|
||||
? parseInt(hex[3] + hex[3], 16) / 255
|
||||
: hex.length === 8
|
||||
? parseInt(hex.slice(6), 16) / 255
|
||||
: 1,
|
||||
);
|
||||
} else {
|
||||
max = Math.max(max, colorAlpha(color));
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
|
||||
@@ -722,13 +786,21 @@
|
||||
// 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;
|
||||
// Walk the paint-ordered stack: a transparent layer on top must not mask an opaque one below it.
|
||||
const stack =
|
||||
typeof document.elementsFromPoint === "function"
|
||||
? document.elementsFromPoint(x, y)
|
||||
: typeof document.elementFromPoint === "function"
|
||||
? [document.elementFromPoint(x, y)].filter(Boolean)
|
||||
: [];
|
||||
for (const hit of stack) {
|
||||
if (!isForeignElement(element, hit)) return null;
|
||||
// Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
|
||||
if (sharedPreserve3d(element, hit)) continue;
|
||||
if (isCrossSceneTransitionOverlap(element, hit)) continue;
|
||||
if (isOpaqueOccluder(hit)) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
|
||||
@@ -768,6 +840,32 @@
|
||||
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
|
||||
}
|
||||
|
||||
// pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
|
||||
function restoreHitTesting(root) {
|
||||
const restores = [];
|
||||
for (const node of [root, ...root.querySelectorAll("*")]) {
|
||||
if (getComputedStyle(node).pointerEvents !== "none") continue;
|
||||
const previous = node.style.getPropertyValue("pointer-events");
|
||||
const priority = node.style.getPropertyPriority("pointer-events");
|
||||
node.style.setProperty("pointer-events", "auto", "important");
|
||||
restores.push(() => {
|
||||
if (previous) node.style.setProperty("pointer-events", previous, priority);
|
||||
else node.style.removeProperty("pointer-events");
|
||||
});
|
||||
}
|
||||
return () => restores.forEach((restore) => restore());
|
||||
}
|
||||
|
||||
// No text ink is on screen while every non-whitespace text node sits at ~0 opacity (entrance not started).
|
||||
function hasVisibleTextInk(element) {
|
||||
const nodes = [element, ...element.querySelectorAll("*")];
|
||||
for (const node of nodes) {
|
||||
if (!directTextNodes(node).some((textNode) => textNode.textContent.trim())) continue;
|
||||
if (opacityChain(node) >= 0.05) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -775,6 +873,7 @@
|
||||
// cover on a paragraph is usually a styling artifact, not a reading defect.
|
||||
function occludedTextIssue(element, time) {
|
||||
if (hasAllowOcclusionFlag(element)) return null;
|
||||
if (!hasVisibleTextInk(element)) return null;
|
||||
const textRect = textRectFor(element);
|
||||
if (!textRect) return null;
|
||||
const text = textContentFor(element);
|
||||
@@ -919,15 +1018,20 @@
|
||||
);
|
||||
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);
|
||||
const invisible = invisibleTextIssue(element, time);
|
||||
if (invisible) issues.push(invisible);
|
||||
const restoreHits = restoreHitTesting(root);
|
||||
try {
|
||||
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);
|
||||
const invisible = invisibleTextIssue(element, time);
|
||||
if (invisible) issues.push(invisible);
|
||||
}
|
||||
} finally {
|
||||
restoreHits();
|
||||
}
|
||||
|
||||
issues.push(...containerOverflowIssues(root, time, tolerance));
|
||||
|
||||
@@ -831,6 +831,7 @@ describe("layout-audit.browser occlusion", () => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||
delete (document as unknown as { elementsFromPoint?: unknown }).elementsFromPoint;
|
||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||
clearGeometryCollector();
|
||||
});
|
||||
@@ -904,6 +905,162 @@ describe("layout-audit.browser occlusion", () => {
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag visible text carrying pointer-events:none (probe restores hit-testing)", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">Headline copy</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: {
|
||||
headline: { pointerEvents: "none" },
|
||||
overlay: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
},
|
||||
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
|
||||
topmostId: "overlay",
|
||||
});
|
||||
// Simulate real hit-testing: with hit-testing restored (inline auto), the topmost hit IS the text.
|
||||
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => {
|
||||
const headline = document.getElementById("headline");
|
||||
return headline?.style.getPropertyValue("pointer-events") === "auto"
|
||||
? headline
|
||||
: document.getElementById("overlay");
|
||||
};
|
||||
installAuditScript();
|
||||
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count a low-alpha gradient overlay (grid/scrim) as an opaque occluder", () => {
|
||||
const issues = auditOcclusionScene({
|
||||
overlayStyle: {
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.04) 0px, transparent 1px)",
|
||||
},
|
||||
topmostId: "overlay",
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("walks past a transparent layer sharing the text's 3D context to a deeper occluder", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="stage">
|
||||
<div id="headline">Headline copy</div>
|
||||
<div id="decor"></div>
|
||||
</div>
|
||||
<div id="panel"></div>
|
||||
</div>
|
||||
`;
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: {
|
||||
stage: { transformStyle: "preserve-3d" },
|
||||
panel: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
},
|
||||
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
|
||||
topmostId: "decor",
|
||||
});
|
||||
(document as unknown as { elementsFromPoint: () => Element[] }).elementsFromPoint = () =>
|
||||
["decor", "panel"].map((id) => document.getElementById(id) as Element);
|
||||
installAuditScript();
|
||||
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(true);
|
||||
});
|
||||
|
||||
it("composites stacked translucent gradient layers (two 0.5-alpha layers occlude)", () => {
|
||||
const occluded = auditOcclusionScene({
|
||||
overlayStyle: {
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))",
|
||||
},
|
||||
topmostId: "overlay",
|
||||
}).find((issue) => issue.code === "text_occluded");
|
||||
expect(occluded).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not count a single 0.5-alpha gradient layer as an occluder", () => {
|
||||
const issues = auditOcclusionScene({
|
||||
overlayStyle: { backgroundImage: "linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))" },
|
||||
topmostId: "overlay",
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("probes text whose ink sits just above the 0.05 opacity floor", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">
|
||||
<span id="inner">Headline copy</span>
|
||||
</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: {
|
||||
inner: { opacity: "0.06" },
|
||||
overlay: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
},
|
||||
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
|
||||
topmostId: "overlay",
|
||||
});
|
||||
installAuditScript();
|
||||
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(true);
|
||||
});
|
||||
|
||||
it("still counts an opaque gradient panel as an occluder", () => {
|
||||
const occluded = auditOcclusionScene({
|
||||
overlayStyle: { backgroundImage: "linear-gradient(rgb(10, 10, 10), rgb(40, 40, 40))" },
|
||||
topmostId: "overlay",
|
||||
}).find((issue) => issue.code === "text_occluded");
|
||||
expect(occluded).toBeDefined();
|
||||
});
|
||||
|
||||
it("still flags text buried under an occluder that itself has pointer-events:none", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">Headline copy</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: {
|
||||
overlay: { backgroundColor: "rgb(10, 10, 10)", pointerEvents: "none" },
|
||||
},
|
||||
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
|
||||
topmostId: "overlay",
|
||||
});
|
||||
// Simulate hit-testing: the scrim is only hittable once the audit restores its pointer-events.
|
||||
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => {
|
||||
const overlay = document.getElementById("overlay");
|
||||
return overlay?.style.getPropertyValue("pointer-events") === "auto"
|
||||
? overlay
|
||||
: document.getElementById("headline");
|
||||
};
|
||||
installAuditScript();
|
||||
const occluded = runAudit().find((issue) => issue.code === "text_occluded");
|
||||
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
|
||||
});
|
||||
|
||||
it("does not probe text whose every text node is still at opacity 0 (whitespace-indented markup)", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">
|
||||
<span id="inner">Headline copy</span>
|
||||
</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: {
|
||||
inner: { opacity: "0" },
|
||||
overlay: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
},
|
||||
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
|
||||
topmostId: "overlay",
|
||||
});
|
||||
installAuditScript();
|
||||
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in
|
||||
|
||||
Reference in New Issue
Block a user