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:
Xuanru Li
2026-07-13 14:27:39 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 3df59fc0a4
commit 3e3b37d37f
2 changed files with 281 additions and 20 deletions
+124 -20
View File
@@ -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