Skip to content

Commit ed05b2e

Browse files
xuanruliclaude
andcommitted
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>
1 parent 648e4e8 commit ed05b2e

2 files changed

Lines changed: 137 additions & 11 deletions

File tree

packages/cli/src/commands/layout-audit.browser.js

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -659,11 +659,39 @@
659659
}
660660

661661
function hasOpaqueBackground(style) {
662-
if (style.backgroundImage && style.backgroundImage !== "none") return true;
662+
if (style.backgroundImage && style.backgroundImage !== "none") {
663+
if (style.backgroundImage.includes("url(")) return true;
664+
// A gradient only occludes as much as the colours in it: a 4%-alpha
665+
// grid/scrim pattern must not count as an opaque occluder.
666+
if (gradientMaxAlpha(style.backgroundImage) > 0.6) return true;
667+
}
663668
if (isTransparentColor(style.backgroundColor)) return false;
664669
return colorAlpha(style.backgroundColor) > 0.6;
665670
}
666671

672+
function gradientMaxAlpha(backgroundImage) {
673+
const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g);
674+
if (!colors) return 1; // named colours we cannot parse — keep the old (opaque) behaviour
675+
let max = 0;
676+
for (const color of colors) {
677+
if (color === "transparent") continue;
678+
if (color.startsWith("#")) {
679+
const hex = color.slice(1);
680+
max = Math.max(
681+
max,
682+
hex.length === 4
683+
? parseInt(hex[3] + hex[3], 16) / 255
684+
: hex.length === 8
685+
? parseInt(hex.slice(6), 16) / 255
686+
: 1,
687+
);
688+
} else {
689+
max = Math.max(max, colorAlpha(color));
690+
}
691+
}
692+
return max;
693+
}
694+
667695
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
668696
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
669697

@@ -754,18 +782,50 @@
754782
// reads very differently from a label buried under an overlay. Still
755783
// returns the first opaque element found, for `containerSelector`.
756784
function occlusionCoverage(element, textRect) {
757-
let occluder = null;
758-
let hits = 0;
759-
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
760-
const y = textRect.top + textRect.height * yFraction;
761-
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
762-
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
763-
if (!hit) continue;
764-
hits += 1;
765-
if (!occluder) occluder = hit;
785+
// pointer-events:none makes the element invisible to elementFromPoint, so
786+
// the probe would always "hit" whatever paints beneath it and misread
787+
// perfectly visible text as buried (decorative text commonly carries
788+
// pointer-events:none). Restore hit-testing for the duration of the probe.
789+
const restore = restoreHitTesting(element);
790+
try {
791+
let occluder = null;
792+
let hits = 0;
793+
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
794+
const y = textRect.top + textRect.height * yFraction;
795+
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
796+
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
797+
if (!hit) continue;
798+
hits += 1;
799+
if (!occluder) occluder = hit;
800+
}
766801
}
802+
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
803+
} finally {
804+
restore();
767805
}
768-
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
806+
}
807+
808+
function restoreHitTesting(element) {
809+
if (getComputedStyle(element).pointerEvents !== "none") return () => {};
810+
const previous = element.style.getPropertyValue("pointer-events");
811+
const priority = element.style.getPropertyPriority("pointer-events");
812+
element.style.setProperty("pointer-events", "auto", "important");
813+
return () => {
814+
if (previous) element.style.setProperty("pointer-events", previous, priority);
815+
else element.style.removeProperty("pointer-events");
816+
};
817+
}
818+
819+
// The container can be fully visible while every text-bearing descendant is
820+
// still at opacity 0 (entrance not started yet) — there is no ink on screen
821+
// to occlude, so probing it only manufactures false "buried text" findings.
822+
function hasVisibleTextInk(element) {
823+
const nodes = [element, ...element.querySelectorAll("*")];
824+
for (const node of nodes) {
825+
if (directTextNodes(node).length === 0) continue;
826+
if (opacityChain(node) >= 0.2) return true;
827+
}
828+
return false;
769829
}
770830

771831
// Catches the blind spot the overflow checks miss: text that fits its box
@@ -775,6 +835,7 @@
775835
// cover on a paragraph is usually a styling artifact, not a reading defect.
776836
function occludedTextIssue(element, time) {
777837
if (hasAllowOcclusionFlag(element)) return null;
838+
if (!hasVisibleTextInk(element)) return null;
778839
const textRect = textRectFor(element);
779840
if (!textRect) return null;
780841
const text = textContentFor(element);

packages/cli/src/commands/layout-audit.browser.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,71 @@ describe("layout-audit.browser occlusion", () => {
904904
});
905905
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
906906
});
907+
908+
it("does not flag visible text carrying pointer-events:none (probe restores hit-testing)", () => {
909+
document.body.innerHTML = `
910+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
911+
<div id="headline">Headline copy</div>
912+
<div id="overlay"></div>
913+
</div>
914+
`;
915+
installOcclusionGeometry({
916+
styleOverrides: {
917+
headline: { pointerEvents: "none" },
918+
overlay: { backgroundColor: "rgb(10, 10, 10)" },
919+
},
920+
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
921+
topmostId: "overlay",
922+
});
923+
// Simulate real hit-testing: once the probe restores hit-testing (inline
924+
// pointer-events: auto), the topmost element at the text IS the text.
925+
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => {
926+
const headline = document.getElementById("headline");
927+
return headline?.style.getPropertyValue("pointer-events") === "auto"
928+
? headline
929+
: document.getElementById("overlay");
930+
};
931+
installAuditScript();
932+
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
933+
});
934+
935+
it("does not count a low-alpha gradient overlay (grid/scrim) as an opaque occluder", () => {
936+
const issues = auditOcclusionScene({
937+
overlayStyle: {
938+
backgroundImage:
939+
"repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.04) 0px, transparent 1px)",
940+
},
941+
topmostId: "overlay",
942+
});
943+
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
944+
});
945+
946+
it("still counts an opaque gradient panel as an occluder", () => {
947+
const occluded = auditOcclusionScene({
948+
overlayStyle: { backgroundImage: "linear-gradient(rgb(10, 10, 10), rgb(40, 40, 40))" },
949+
topmostId: "overlay",
950+
}).find((issue) => issue.code === "text_occluded");
951+
expect(occluded).toBeDefined();
952+
});
953+
954+
it("does not probe text whose every text node is still at opacity 0 (entrance not started)", () => {
955+
document.body.innerHTML = `
956+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
957+
<div id="headline"><span id="inner">Headline copy</span></div>
958+
<div id="overlay"></div>
959+
</div>
960+
`;
961+
installOcclusionGeometry({
962+
styleOverrides: {
963+
inner: { opacity: "0" },
964+
overlay: { backgroundColor: "rgb(10, 10, 10)" },
965+
},
966+
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
967+
topmostId: "overlay",
968+
});
969+
installAuditScript();
970+
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
971+
});
907972
});
908973

909974
// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in

0 commit comments

Comments
 (0)