mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(cli): flag text occluded by opaque elements in inspect (#1435)
The layout audit only reported boxes that overflow their container; text that fits perfectly but is painted over by a later sibling or overlay was never caught. Add a text_occluded check that sweeps a grid across each text box (three rows x nine columns) and, via elementFromPoint, flags text whose topmost element is an unrelated opaque element (raster content, background image, or a solid background at near-full opacity). Low-opacity overlays such as scrims and grain are exempt. Opt out of intentional layering with data-layout-allow-occlusion. The two *.browser.js audit scripts are added to the fallow entry list: they are injected by path via page.addScriptTag, so they have no import-graph referrer. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
3f3293da86
commit
1e54827957
@@ -402,6 +402,12 @@
|
||||
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;
|
||||
}
|
||||
@@ -483,6 +489,79 @@
|
||||
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"]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// The opaque element painted over (x, y), or null when the topmost element
|
||||
// there is related to the text or non-opaque.
|
||||
function occluderAt(element, x, y) {
|
||||
if (typeof document.elementFromPoint !== "function") return null;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!isForeignElement(element, hit)) return null;
|
||||
return isOpaqueOccluder(hit) ? hit : null;
|
||||
}
|
||||
|
||||
// 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) and return
|
||||
// the first opaque element painted over any sample point.
|
||||
function firstOccluder(element, textRect) {
|
||||
for (const yFraction of [0.25, 0.5, 0.75]) {
|
||||
const y = textRect.top + textRect.height * yFraction;
|
||||
for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) {
|
||||
const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
||||
if (occluder) return occluder;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Catches the blind spot the overflow checks miss: text that fits its box
|
||||
// perfectly but is covered by a later sibling/overlay.
|
||||
function occludedTextIssue(element, time) {
|
||||
if (hasAllowOcclusionFlag(element)) return null;
|
||||
const textRect = textRectFor(element);
|
||||
if (!textRect) return null;
|
||||
const occluder = firstOccluder(element, textRect);
|
||||
if (!occluder) return null;
|
||||
return {
|
||||
code: "text_occluded",
|
||||
severity: "error",
|
||||
time,
|
||||
selector: selectorFor(element),
|
||||
containerSelector: selectorFor(occluder),
|
||||
text: textContentFor(element),
|
||||
message: "Text is hidden beneath an opaque element.",
|
||||
rect: textRect,
|
||||
fixHint:
|
||||
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
|
||||
};
|
||||
}
|
||||
|
||||
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
||||
const time = options && typeof options.time === "number" ? options.time : 0;
|
||||
const tolerance =
|
||||
@@ -500,6 +579,8 @@
|
||||
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));
|
||||
|
||||
@@ -194,6 +194,132 @@ function auditOverlapScene(options: {
|
||||
return runAudit();
|
||||
}
|
||||
|
||||
describe("layout-audit.browser occlusion", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint;
|
||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||
});
|
||||
|
||||
it("flags text painted over by an opaque sibling overlay", () => {
|
||||
const occluded = auditOcclusionScene({
|
||||
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
topmostId: "overlay",
|
||||
}).find((issue) => issue.code === "text_occluded");
|
||||
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
|
||||
});
|
||||
|
||||
it("reports occlusion only on the covered text, not the text itself when on top", () => {
|
||||
// elementFromPoint returns the headline itself (it is on top), so nothing
|
||||
// occludes it — the topmost-hit-is-self path must NOT flag.
|
||||
const issues = auditOcclusionScene({
|
||||
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
topmostId: "headline",
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores low-opacity overlays such as scrims and grain", () => {
|
||||
const issues = auditOcclusionScene({
|
||||
overlayStyle: { backgroundColor: "rgb(10, 10, 10)", opacity: "0.3" },
|
||||
topmostId: "overlay",
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("respects the data-layout-allow-occlusion opt-out", () => {
|
||||
const issues = auditOcclusionScene({
|
||||
headlineAttrs: "data-layout-allow-occlusion",
|
||||
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
|
||||
topmostId: "overlay",
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function auditOcclusionScene(options: {
|
||||
headlineAttrs?: string;
|
||||
overlayStyle: Partial<Record<string, string>>;
|
||||
topmostId: string;
|
||||
}): ReturnType<typeof runAudit> {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline" ${options.headlineAttrs ?? ""}>Headline copy</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: { overlay: options.overlayStyle },
|
||||
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
|
||||
topmostId: options.topmostId,
|
||||
});
|
||||
installAuditScript();
|
||||
return runAudit();
|
||||
}
|
||||
|
||||
function installOcclusionGeometry(options: {
|
||||
styleOverrides: Record<string, Partial<Record<string, string>>>;
|
||||
headlineTextRect: DOMRect;
|
||||
topmostId: string;
|
||||
}): void {
|
||||
const baseStyle: Record<string, string> = {
|
||||
display: "block",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
overflow: "visible",
|
||||
overflowX: "visible",
|
||||
overflowY: "visible",
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
backgroundImage: "none",
|
||||
borderTopWidth: "0px",
|
||||
borderRightWidth: "0px",
|
||||
borderBottomWidth: "0px",
|
||||
borderLeftWidth: "0px",
|
||||
borderTopLeftRadius: "0px",
|
||||
borderTopRightRadius: "0px",
|
||||
borderBottomRightRadius: "0px",
|
||||
borderBottomLeftRadius: "0px",
|
||||
paddingTop: "0px",
|
||||
paddingRight: "0px",
|
||||
paddingBottom: "0px",
|
||||
paddingLeft: "0px",
|
||||
fontSize: "36px",
|
||||
};
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
const id = (element as Element).id;
|
||||
return {
|
||||
...baseStyle,
|
||||
...(options.styleOverrides[id] ?? {}),
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
for (const element of Array.from(document.querySelectorAll("*"))) {
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
|
||||
rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
);
|
||||
}
|
||||
|
||||
vi.spyOn(document, "createRange").mockImplementation(() => {
|
||||
let selected: Node | null = null;
|
||||
return {
|
||||
selectNodeContents(node: Node) {
|
||||
selected = node;
|
||||
},
|
||||
getClientRects() {
|
||||
return (selected as Element | null)?.id === "headline"
|
||||
? ([options.headlineTextRect] as unknown as DOMRectList)
|
||||
: ([] as unknown as DOMRectList);
|
||||
},
|
||||
detach() {},
|
||||
} as unknown as Range;
|
||||
});
|
||||
|
||||
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
|
||||
document.getElementById(options.topmostId);
|
||||
}
|
||||
|
||||
function installAuditScript(): void {
|
||||
window.eval(script);
|
||||
}
|
||||
@@ -203,6 +329,7 @@ function runAudit(): Array<{
|
||||
selector: string;
|
||||
containerSelector?: string;
|
||||
overflow?: Record<string, number>;
|
||||
message?: string;
|
||||
}> {
|
||||
const audit = (
|
||||
window as unknown as {
|
||||
@@ -211,6 +338,7 @@ function runAudit(): Array<{
|
||||
selector: string;
|
||||
containerSelector?: string;
|
||||
overflow?: Record<string, number>;
|
||||
message?: string;
|
||||
}>;
|
||||
}
|
||||
).__hyperframesLayoutAudit;
|
||||
|
||||
@@ -14,7 +14,8 @@ export type LayoutIssueCode =
|
||||
| "clipped_text"
|
||||
| "canvas_overflow"
|
||||
| "container_overflow"
|
||||
| "content_overlap";
|
||||
| "content_overlap"
|
||||
| "text_occluded";
|
||||
|
||||
export type LayoutIssueSeverity = "error" | "warning" | "info";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user