Skip to content
Closed
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
83 changes: 83 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed");
}

// Explicit truncation opt-out on top of allow-overflow/bleed, so an author can green-light an intentional ellipsis without silencing bbox-overflow reporting.
function hasTruncationOptOut(element) {
return hasTextClipOptOut(element) || !!element.closest("[data-layout-allow-truncation]");
}

function opacityChain(element) {
let opacity = 1;
for (let current = element; current; current = current.parentElement) {
Expand Down Expand Up @@ -540,6 +545,83 @@
return issues;
}

const TRUNCATION_MAX_FINDINGS = 40;

// The clipping box that swallows element's overflow: itself if it clips, else the nearest clipping ancestor below root (root-level clipping is canvas_overflow's job); null when nothing clips.
function truncationClipper(element, root) {
if (clipsOverflow(getComputedStyle(element))) return element;
for (
let current = element.parentElement;
current && current !== root;
current = current.parentElement
) {
if (clipsOverflow(getComputedStyle(current))) return current;
}
return null;
}

function buildTruncationIssue(candidate, time, tolerance) {
const { element, clipper, overflowX, overflowY } = candidate;
const clientWidth = element.clientWidth;
const clientHeight = element.clientHeight;
const scrollWidth = element.scrollWidth;
const scrollHeight = element.scrollHeight;
const text = textContentFor(element, false);
const snippet = text.length > 40 ? `${text.slice(0, 40)}…` : text;
const overflow = {};
if (overflowX > tolerance) overflow.right = round(overflowX);
if (overflowY > tolerance) overflow.bottom = round(overflowY);
const dims =
overflowX > tolerance
? `content width ${Math.round(scrollWidth)}px exceeds visible ${Math.round(clientWidth)}px`
: `content height ${Math.round(scrollHeight)}px exceeds visible ${Math.round(clientHeight)}px`;
return {
code: "text_truncated",
severity: "warning",
time,
selector: selectorFor(element),
containerSelector: selectorFor(clipper),
text,
message: `Text is clipped by its container — "${snippet}" is truncated (${dims}).`,
rect: toRect(element.getBoundingClientRect()),
overflow,
fixHint:
"Widen the container, reduce the text, or allow wrapping; if the clip is intentional add data-layout-allow-truncation.",
};
}

// scrollWidth/clientWidth truncation detector: getBoundingClientRect returns the already-clipped box so bbox checks miss text cut off by an overflow:hidden ancestor; scrollWidth/Height > client exposes it.
function textTruncationIssues(root, time, tolerance) {
const candidates = [];
for (const element of Array.from(root.querySelectorAll("*"))) {
if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) continue;
if (element.closest("svg")) continue;
if (!isVisibleElement(element, 0.05)) continue;
if (hasTruncationOptOut(element)) continue;
// Must carry visible text (own or descendants) — skip pure containers, spacers and media wrappers.
if (!textContentFor(element, false)) continue;
const overflowX = element.scrollWidth - element.clientWidth;
const overflowY = element.scrollHeight - element.clientHeight;
if (overflowX <= tolerance && overflowY <= tolerance) continue;
const clipper = truncationClipper(element, root);
if (!clipper) continue;
// An element that clips its own overflow and owns its text is already reported as `clipped_text` — don't double-report.
if (clipper === element && hasOwnTextCandidate(element, true)) continue;
candidates.push({ element, clipper, overflowX, overflowY });
}

// Report the innermost truncated box per nesting chain — drop any candidate containing another so one clip yields one finding.
const elements = candidates.map((candidate) => candidate.element);
const innermost = candidates.filter(
(candidate) =>
!elements.some((other) => other !== candidate.element && candidate.element.contains(other)),
);

return innermost
.slice(0, TRUNCATION_MAX_FINDINGS)
.map((candidate) => buildTruncationIssue(candidate, time, tolerance));
}

function hasAllowOverlapFlag(element) {
return !!element.closest("[data-layout-allow-overlap]");
}
Expand Down Expand Up @@ -1417,6 +1499,7 @@
}

issues.push(...containerOverflowIssues(root, time, tolerance));
issues.push(...textTruncationIssues(root, time, tolerance));
issues.push(...contentOverlapIssues(root, time));
const escaped = escapedContainerIssues(root, time);
issues.push(...escaped.issues);
Expand Down
198 changes: 198 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,204 @@ describe("layout-audit.browser", () => {
);
expect(parentOverflow).toBeUndefined();
});

// text_truncated: content clipped by an overflow-hidden ancestor — bbox checks miss it (clipped box shows no spill), only scrollWidth > clientWidth exposes it.
function defineScrollMetrics(
id: string,
metrics: {
clientWidth: number;
scrollWidth: number;
clientHeight: number;
scrollHeight: number;
},
): void {
const element = document.querySelector(`#${id}`);
if (!(element instanceof HTMLElement)) throw new Error(`missing #${id}`);
Object.defineProperties(element, {
clientWidth: { configurable: true, value: metrics.clientWidth },
scrollWidth: { configurable: true, value: metrics.scrollWidth },
clientHeight: { configurable: true, value: metrics.clientHeight },
scrollHeight: { configurable: true, value: metrics.scrollHeight },
});
}

it("flags text whose scrollWidth exceeds clientWidth inside an overflow:hidden ancestor", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="window">
<div id="label">Microgrid Active</div>
</div>
</div>
`;
defineScrollMetrics("label", {
clientWidth: 100,
scrollWidth: 240,
clientHeight: 20,
scrollHeight: 20,
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
window: rect({ left: 40, top: 60, width: 100, height: 20 }),
label: rect({ left: 40, top: 60, width: 100, height: 20 }),
},
{ window: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" } },
);
installAuditScript();

const truncated = runAudit().filter((issue) => issue.code === "text_truncated");
expect(truncated).toHaveLength(1);
expect(truncated[0]).toMatchObject({
code: "text_truncated",
selector: "#label",
containerSelector: "#window",
});
expect(truncated[0]?.message).toContain("content width 240px exceeds visible 100px");
expect(truncated[0]?.fixHint).toContain("data-layout-allow-truncation");
});

it("flags vertical truncation (scrollHeight exceeds clientHeight) under a clipping ancestor", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="window">
<div id="label">Two crammed lines of copy</div>
</div>
</div>
`;
defineScrollMetrics("label", {
clientWidth: 200,
scrollWidth: 200,
clientHeight: 24,
scrollHeight: 60,
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
window: rect({ left: 40, top: 60, width: 200, height: 24 }),
label: rect({ left: 40, top: 60, width: 200, height: 24 }),
},
{ window: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" } },
);
installAuditScript();

const truncated = runAudit().filter((issue) => issue.code === "text_truncated");
expect(truncated).toHaveLength(1);
expect(truncated[0]?.message).toContain("content height 60px exceeds visible 24px");
});

it("does not flag text that fits its box inside a clipping ancestor", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="window">
<div id="label">Fits fine</div>
</div>
</div>
`;
defineScrollMetrics("label", {
clientWidth: 200,
scrollWidth: 200,
clientHeight: 20,
scrollHeight: 20,
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
window: rect({ left: 40, top: 60, width: 200, height: 20 }),
label: rect({ left: 40, top: 60, width: 200, height: 20 }),
},
{ window: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" } },
);
installAuditScript();

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

it("does not flag content overflowing a NON-clipping container (visible overflow, not lost)", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="window">
<div id="label">Overflows but stays visible</div>
</div>
</div>
`;
defineScrollMetrics("label", {
clientWidth: 100,
scrollWidth: 240,
clientHeight: 20,
scrollHeight: 20,
});
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
window: rect({ left: 40, top: 60, width: 100, height: 20 }),
label: rect({ left: 40, top: 60, width: 100, height: 20 }),
});
installAuditScript();

// #window has default overflow:visible — nothing clips, so this belongs to bbox overflow checks, not truncation.
expect(runAudit().some((issue) => issue.code === "text_truncated")).toBe(false);
});

it("leaves self-clipping text with its own text node to clipped_text (no double-report)", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="label">Self clipped ellipsis label</div>
</div>
`;
defineScrollMetrics("label", {
clientWidth: 100,
scrollWidth: 240,
clientHeight: 20,
scrollHeight: 20,
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
label: rect({ left: 40, top: 60, width: 100, height: 20 }),
},
{ label: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" } },
);
installAuditScript();

const issues = runAudit();
expect(issues.some((issue) => issue.code === "clipped_text")).toBe(true);
expect(issues.some((issue) => issue.code === "text_truncated")).toBe(false);
});

it("suppresses truncation under data-layout-allow-truncation and data-layout-allow-overflow", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="window">
<div id="label">Intentionally truncated label</div>
</div>
</div>
`;
defineScrollMetrics("label", {
clientWidth: 100,
scrollWidth: 240,
clientHeight: 20,
scrollHeight: 20,
});
installGeometry(
{
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
window: rect({ left: 40, top: 60, width: 100, height: 20 }),
label: rect({ left: 40, top: 60, width: 100, height: 20 }),
},
{ window: { overflow: "hidden", overflowX: "hidden", overflowY: "hidden" } },
);
installAuditScript();

const truncatedCount = () =>
runAudit().filter((issue) => issue.code === "text_truncated").length;
expect(truncatedCount()).toBe(1);

document.querySelector("#label")?.setAttribute("data-layout-allow-truncation", "");
expect(truncatedCount()).toBe(0);
document.querySelector("#label")?.removeAttribute("data-layout-allow-truncation");

document.querySelector("#window")?.setAttribute("data-layout-allow-overflow", "");
expect(truncatedCount()).toBe(0);
});
});

it("is inert unless text or media candidates are explicitly requested", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,7 @@ function parseOverflow(value: unknown): LayoutIssue["overflow"] | null {
const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
"text_box_overflow",
"clipped_text",
"text_truncated",
"canvas_overflow",
"container_overflow",
"content_overlap",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils/layoutAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type LayoutOverflow = Partial<Record<"left" | "right" | "top" | "bottom",
export type LayoutIssueCode =
| "text_box_overflow"
| "clipped_text"
// Content clipped by an overflow-hidden ancestor: scrollWidth/Height exceeds the visible client box though the clipped bbox shows no spill — the gap bbox checks miss.
| "text_truncated"
| "canvas_overflow"
| "container_overflow"
| "content_overlap"
Expand Down Expand Up @@ -209,6 +211,7 @@ const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2;
const PERSISTENCE_TIERED_CODES: ReadonlySet<LayoutIssueCode> = new Set([
"text_box_overflow",
"clipped_text",
"text_truncated",
"canvas_overflow",
"container_overflow",
"content_overlap",
Expand Down
Loading