Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -580,8 +580,9 @@
const blocks = [];
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!isSolidTextBlock(element)) continue;
const rects = textClientRects(element, true);
const rect = textRectFor(element, true);
if (rect) blocks.push({ element, rect });
if (rect) blocks.push({ element, rect, rects });
}
return blocks;
}
Expand All @@ -596,6 +597,18 @@
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
}

function rectsArea(rects) {
return rects.reduce((total, rect) => total + rectArea(rect), 0);
}

function fragmentIntersectionArea(a, b) {
let total = 0;
for (const aRect of a) {
for (const bRect of b) total += intersectionArea(aRect, bRect);
}
return total;
}

function isNested(a, b) {
return a.contains(b) || b.contains(a);
}
Expand Down Expand Up @@ -631,8 +644,8 @@
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;
const area = fragmentIntersectionArea(a.rects, b.rects);
if (area <= Math.min(rectsArea(a.rects), rectsArea(b.rects)) * 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
Expand Down Expand Up @@ -928,25 +941,32 @@
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
// Sweep a grid across each painted text fragment (three rows, not just the
// mid-line, so overlays covering only part of a multi-line block are caught).
// Sampling fragments instead of their union avoids probing empty line gaps.
// 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) {
function occlusionCoverage(element, textRects) {
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;
for (const textRect of textRects) {
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) };
return {
occluder,
coveredFraction: round(hits / (OCCLUSION_GRID_POINTS * textRects.length)),
};
}

// pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
Expand Down Expand Up @@ -985,8 +1005,12 @@
if (!hasVisibleTextInk(element)) return null;
const textRect = textRectFor(element, true);
if (!textRect) return null;
const textRects = textClientRects(element, true);
const text = textContentFor(element, true);
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
const { occluder, coveredFraction } = occlusionCoverage(
element,
textRects.length > 0 ? textRects : [textRect],
);
if (!occluder) return null;
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
return {
Expand Down
72 changes: 63 additions & 9 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,20 @@ describe("layout-audit.browser content overlap", () => {
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
});

it("ignores another block placed only in a multiline text block's empty line gap", () => {
const issues = auditOverlapScene({
a: {
textRect: [
rect({ left: 100, top: 100, width: 400, height: 60 }),
rect({ left: 100, top: 260, width: 400, height: 60 }),
],
},
b: { textRect: rect({ left: 180, top: 180, width: 240, height: 50 }) },
});

expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
});

it("ignores watermark-style text with low colour alpha", () => {
expectExemptFromOverlap({ color: "rgba(0, 0, 0, 0.2)" });
});
Expand Down Expand Up @@ -1283,8 +1297,8 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
}

function auditOverlapScene(options: {
a: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
a: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
b: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
}): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
Expand All @@ -1300,7 +1314,10 @@ function auditOverlapScene(options: {
a: options.a.clipPath ?? "none",
b: options.b.clipPath ?? "none",
};
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
const textRects: Record<string, DOMRect[]> = {
a: normalizeTextRects(options.a.textRect),
b: normalizeTextRects(options.b.textRect),
};

vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
Expand All @@ -1323,7 +1340,8 @@ function auditOverlapScene(options: {

for (const element of Array.from(document.querySelectorAll("*"))) {
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }),
boundingTextRect(textRects[element.id]) ??
rect({ left: 0, top: 0, width: 1920, height: 1080 }),
);
}

Expand All @@ -1339,9 +1357,7 @@ function auditOverlapScene(options: {
? selected.parentElement
: (selected as Element | null);
const id = element?.id ?? "";
return textRects[id]
? ([textRects[id]] as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
return (textRects[id] ?? []) as unknown as DOMRectList;
},
detach() {},
} as unknown as Range;
Expand All @@ -1351,6 +1367,19 @@ function auditOverlapScene(options: {
return runAudit();
}

function normalizeTextRects(value: DOMRect | DOMRect[]): DOMRect[] {
return Array.isArray(value) ? value : [value];
}

function boundingTextRect(rects: DOMRect[] | undefined): DOMRect | undefined {
if (!rects?.length) return undefined;
const left = Math.min(...rects.map((item) => item.left));
const top = Math.min(...rects.map((item) => item.top));
const right = Math.max(...rects.map((item) => item.right));
const bottom = Math.max(...rects.map((item) => item.bottom));
return rect({ left, top, width: right - left, height: bottom - top });
}

function isFullyClipped(clipPath: string): boolean {
return /inset\([^)]*100%|circle\(0px/i.test(clipPath);
}
Expand Down Expand Up @@ -1475,6 +1504,31 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
});

it("does not sample opaque content in the gap between multiline text fragments", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">First line of prose<br />Second line of prose</div>
<div id="overlay"></div>
</div>
`;
const lineRects = [
rect({ left: 200, top: 500, width: 600, height: 40 }),
rect({ left: 200, top: 580, width: 600, height: 40 }),
];
installOcclusionGeometry({
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
headlineTextRect: lineRects,
topmostId: "headline",
});
(
document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }
).elementFromPoint = (_x, y) =>
document.getElementById(y > 540 && y < 580 ? "overlay" : "headline");

installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
});

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">
Expand Down Expand Up @@ -1778,7 +1832,7 @@ function auditImageOcclusionScene(

function installOcclusionGeometry(options: {
styleOverrides: Record<string, Partial<Record<string, string>>>;
headlineTextRect: DOMRect;
headlineTextRect: DOMRect | DOMRect[];
topmostId: string;
textRectElementId?: string;
}): void {
Expand Down Expand Up @@ -1832,7 +1886,7 @@ function installOcclusionGeometry(options: {
? (selected.parentElement as Element | null)
: (selected as Element | null);
return selectedElement?.id === (options.textRectElementId ?? "headline")
? ([options.headlineTextRect] as unknown as DOMRectList)
? (normalizeTextRects(options.headlineTextRect) as unknown as DOMRectList)
: ([] as unknown as DOMRectList);
},
detach() {},
Expand Down
Loading