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
19 changes: 13 additions & 6 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@
return false;
}

function textOverflowIssues(element, root, rootRect, time, tolerance) {
function textOverflowIssues(element, root, rootRect, time, tolerance, clippedIssue) {
const textRect = textRectFor(element, true);
if (!textRect) return [];
const text = textContentFor(element, true);
Expand All @@ -462,8 +462,15 @@
? tolerance
: Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2);
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
const billedAsClippedText =
container === element &&
clippedIssue != null &&
containerOverflow != null &&
containerOverflow.left == null &&
containerOverflow.top == null;
if (
containerOverflow &&
!billedAsClippedText &&
!hasTextClipOptOut(element) &&
!clippedByAncestor(element, container)
) {
Expand Down Expand Up @@ -663,8 +670,8 @@
code: "content_overlap",
severity: "warning",
time,
selector: selectorFor(a.element),
containerSelector: selectorFor(b.element),
selector: uniqueSelectorFor(a.element),
containerSelector: uniqueSelectorFor(b.element),
text: textContentFor(a.element),
message: "Two text blocks overlap and may render unreadable.",
rect: a.rect,
Expand Down Expand Up @@ -1021,8 +1028,8 @@
code: "text_occluded",
severity: "error",
time,
selector: selectorFor(element),
containerSelector: selectorFor(occluder),
selector: uniqueSelectorFor(element),
containerSelector: uniqueSelectorFor(occluder),
text,
message: "Text is hidden beneath an opaque element.",
rect: textRect,
Expand Down Expand Up @@ -1444,7 +1451,7 @@
if (!hasOwnTextCandidate(element)) continue;
const clipped = clippedTextIssue(element, time, tolerance);
if (clipped) issues.push(clipped);
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance, clipped));
const occluded = occludedTextIssue(element, time, proseCoverageFloor);
if (occluded) issues.push(occluded);
const invisible = invisibleTextIssue(element, time);
Expand Down
84 changes: 83 additions & 1 deletion packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,14 +292,96 @@ describe("layout-audit.browser", () => {
.map((issue) => issue.code)
.filter((code) => code === "clipped_text" || code === "text_box_overflow");

expect(textOverflowCodes()).toEqual(["clipped_text", "text_box_overflow"]);
expect(textOverflowCodes()).toEqual(["clipped_text"]);
document.querySelector("#overflow-optout")?.setAttribute("data-layout-allow-overflow", "");
expect(textOverflowCodes()).toEqual([]);
document.querySelector("#overflow-optout")?.removeAttribute("data-layout-allow-overflow");
headline.setAttribute("data-layout-bleed", "true");
expect(textOverflowCodes()).toEqual([]);
});

it("still flags a clipping self-constraint whose scroll metrics round below tolerance", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="headline" style="overflow: hidden">Intentional long truncated label</div>
</div>
`;
const headline = document.querySelector("#headline");
if (!(headline instanceof HTMLElement)) throw new Error("missing headline");
Object.defineProperties(headline, {
clientWidth: { configurable: true, value: 200 },
scrollWidth: { configurable: true, value: 202 },
clientHeight: { configurable: true, value: 20 },
scrollHeight: { configurable: true, value: 20 },
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
headline: rect({ left: 40, top: 60, width: 200, height: 20 }),
text: rect({ left: 40, top: 60, width: 203.4, height: 20 }),
},
{
headline: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" },
},
);
installAuditScript();
const codes = runAudit()
.map((issue) => issue.code)
.filter((code) => code === "clipped_text" || code === "text_box_overflow");

expect(codes).toEqual(["text_box_overflow"]);
});

it("still flags a clipping self-constraint whose text runs off to the left", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="headline" style="overflow: hidden">Intentional long truncated label</div>
</div>
`;
const headline = document.querySelector("#headline");
if (!(headline instanceof HTMLElement)) throw new Error("missing headline");
Object.defineProperties(headline, {
clientWidth: { configurable: true, value: 100 },
scrollWidth: { configurable: true, value: 100 },
clientHeight: { configurable: true, value: 20 },
scrollHeight: { configurable: true, value: 20 },
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
headline: rect({ left: 140, top: 60, width: 100, height: 20 }),
text: rect({ left: 40, top: 60, width: 200, height: 20 }),
},
{
headline: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" },
},
);
installAuditScript();

const found = runAudit().filter((issue) => issue.code === "text_box_overflow");
expect(found).toHaveLength(1);
expect(found[0]?.overflow?.left).toBe(100);
expect(runAudit().some((issue) => issue.code === "clipped_text")).toBe(false);
});

it("still flags a painted, NON-clipping box that is its own nearest constraint", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="bubble">Enterprise plan includes unlimited renders</div>
</div>
`;
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
bubble: rect({ left: 40, top: 60, width: 200, height: 40 }),
text: rect({ left: 40, top: 65, width: 520, height: 30 }),
});
installAuditScript();

const found = runAudit().filter((issue) => issue.code === "text_box_overflow");
expect(found).toHaveLength(1);
expect(found[0]?.selector).toBe("#bubble");
});

it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => {
// A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug
// line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/src/utils/layoutAudit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ describe("layoutAudit helpers", () => {
// Sample counts below (9) mirror the CLI's default grid so the "1 sample =
// entrance/exit transient, 2+ adjacent samples = held" framing in the
// approach doc lines up with the numbers used here.

describe("persistence-tiered severity (#U10)", () => {
it("demotes a content_overlap seen at only one sample among several to info", () => {
const collapsed = collapseStaticLayoutIssues(
Expand Down Expand Up @@ -255,6 +256,133 @@ describe("persistence-tiered severity (#U10)", () => {
expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 });
});

it("promotes a content_overlap whose text changes every sample (count-up over a label)", () => {
const collapsed = collapseStaticLayoutIssues(
[
{
...issue("content_overlap", "warning"),
time: 4.0,
containerSelector: ".num",
text: "$1,204",
},
{
...issue("content_overlap", "warning"),
time: 4.5,
containerSelector: ".num",
text: "$8,930",
},
],
73,
);

expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 });
});

it("keeps a text_occluded over changing text at error, not demoted per sample", () => {
const collapsed = collapseStaticLayoutIssues(
[
{
...issue("text_occluded", "error"),
time: 4.0,
containerSelector: ".scrim",
text: "$1,204",
},
{
...issue("text_occluded", "error"),
time: 4.5,
containerSelector: ".scrim",
text: "$8,930",
},
],
73,
);

expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 });
});

it("keeps two content_overlap pairs on different containers in separate groups", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("content_overlap", "warning"), time: 4.0, containerSelector: ".num" },
{ ...issue("content_overlap", "warning"), time: 4.5, containerSelector: ".pct" },
],
73,
);

expect(collapsed).toHaveLength(2);
});

it("does not bridge two separate transients on one pair into a held collision", () => {
const blip = { ...issue("content_overlap", "warning"), containerSelector: ".label" };
const collapsed = collapseStaticLayoutIssues(
[
{ ...blip, time: 1.0 },
{ ...blip, time: 1.125 },
{ ...blip, time: 9.0 },
{ ...blip, time: 9.125 },
],
73,
);

expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 4 });
});

it("promotes only when one contiguous run clears the floor, not the span between runs", () => {
const blip = { ...issue("content_overlap", "warning"), containerSelector: ".label" };
const held = [1.0, 1.125, 1.25, 1.375, 1.5, 1.625].map((time) => ({ ...blip, time }));
const collapsed = collapseStaticLayoutIssues([...held, { ...blip, time: 9.0 }], 73);

expect(collapsed).toHaveLength(1);
expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 7 });
});

it("does not bridge two blips 1.5s apart, whatever grid the collapse was handed", () => {
const blip = { ...issue("content_overlap", "warning"), containerSelector: ".label" };
for (const sampleCount of [9, 81]) {
const collapsed = collapseStaticLayoutIssues(
[
{ ...blip, time: 2.0 },
{ ...blip, time: 3.5 },
],
sampleCount,
);
expect(collapsed[0]).toMatchObject({ severity: "warning" });
}
});

it("promotes a 625ms contiguous collision even when a tight pair sits elsewhere", () => {
const blip = { ...issue("content_overlap", "warning"), containerSelector: ".label" };
const held = [5.0, 5.125, 5.25, 5.375, 5.5, 5.625].map((time) => ({ ...blip, time }));
const elsewhere = { ...issue("content_overlap", "warning"), containerSelector: ".other" };
const collapsed = collapseStaticLayoutIssues(
[
...held,
{ ...elsewhere, time: 2.0 },
{ ...elsewhere, time: 2.05 },
{ ...elsewhere, time: 2.1 },
],
81,
);

const label = collapsed.find((entry) => entry.containerSelector === ".label");
expect(label).toMatchObject({ severity: "error", heldMs: 625 });
});

it("still separates two distinct text_box_overflow findings that differ only by text", () => {
const collapsed = collapseStaticLayoutIssues(
[
{ ...issue("text_box_overflow", "warning"), time: 4.0, text: "first" },
{ ...issue("text_box_overflow", "warning"), time: 4.5, text: "second" },
],
73,
);

expect(collapsed).toHaveLength(2);
});

it("promotes content_overlap whose two occurrences span exactly 500ms (at the floor)", () => {
const collapsed = collapseStaticLayoutIssues(
[
Expand Down
Loading
Loading