Skip to content

Commit 549a2f5

Browse files
fix(layout): flag nowrap text that still sticks out of a container that does not clip (#4379)
* fix(cli): flag nowrap text that still sticks out of a container that does not clip Cause: container_overflow only inspected containers that clip, so overflow:visible removed the box from the check while nowrap text stayed wider than that box. Rationale: a nowrap text child is still outside its container when the container stops clipping. data-layout-allow-overflow still clears the finding. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(layout): ignore font-height spill and wrapping siblings A container that does not clip was checking every child on both axes once any child refused to wrap. Font-height spill and a wrapping sibling then looked like overflow. * fix(layout): skip an empty decoration next to a nowrap label The nowrap-only check looked at whitespace and not whether the child had text. An empty decoration inherited nowrap and was reported as overflow. * fix(layout): flag nowrap text only when it is wider than the box A narrow label that sits or slides outside a box that does not clip was reported as overflow. Only a line wider than that box is overflow. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 625bdd6 commit 549a2f5

2 files changed

Lines changed: 201 additions & 3 deletions

File tree

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@
2727
return Math.round(value * 100) / 100;
2828
}
2929

30+
function horizontalOverflow(subject, container, tolerance) {
31+
if (subject.width <= container.width + tolerance) return null;
32+
const overflow = overflowFor(subject, container, tolerance);
33+
if (!overflow) return null;
34+
const horizontal = {};
35+
if (overflow.left != null) horizontal.left = overflow.left;
36+
if (overflow.right != null) horizontal.right = overflow.right;
37+
return Object.keys(horizontal).length > 0 ? horizontal : null;
38+
}
39+
3040
function overflowFor(subject, container, tolerance, vTolerance) {
3141
// Horizontal axis uses `tolerance`; vertical axis uses `vTolerance` (defaults to the same).
3242
// A separate vertical tolerance lets text overflow checks absorb glyph ink that exceeds a
@@ -550,28 +560,50 @@
550560
return issues;
551561
}
552562

563+
function isNowrapTextChild(child) {
564+
if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) return false;
565+
if (getComputedStyle(child).whiteSpace !== "nowrap") return false;
566+
return (child.textContent || "").trim().length > 0;
567+
}
568+
569+
function hasNowrapTextChild(element) {
570+
return Array.from(element.children).some(isNowrapTextChild);
571+
}
572+
553573
function containerOverflowIssues(root, time, tolerance) {
554574
const issues = [];
555575
const containers = Array.from(root.querySelectorAll("*")).filter((element) => {
556576
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) return false;
557577
const style = getComputedStyle(element);
558-
return clipsOverflow(style) || element.hasAttribute("data-layout-boundary");
578+
return (
579+
clipsOverflow(style) ||
580+
element.hasAttribute("data-layout-boundary") ||
581+
hasNowrapTextChild(element)
582+
);
559583
});
560584

561585
for (const container of containers) {
586+
const style = getComputedStyle(container);
587+
const checksEveryChild =
588+
clipsOverflow(style) || container.hasAttribute("data-layout-boundary");
562589
const containerRect = toRect(container.getBoundingClientRect());
563590
for (const child of Array.from(container.children)) {
564591
if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) continue;
592+
if (!checksEveryChild && !isNowrapTextChild(child)) continue;
565593
const childRect = toRect(child.getBoundingClientRect());
566-
const overflow = overflowFor(childRect, containerRect, tolerance);
594+
const overflow = checksEveryChild
595+
? overflowFor(childRect, containerRect, tolerance)
596+
: horizontalOverflow(childRect, containerRect, tolerance);
567597
if (!overflow) continue;
568598
issues.push({
569599
code: "container_overflow",
570600
severity: "warning",
571601
time,
572602
selector: selectorFor(child),
573603
containerSelector: selectorFor(container),
574-
message: "Element extends outside a clipping layout container.",
604+
message: checksEveryChild
605+
? "Element extends outside a clipping layout container."
606+
: "Nowrap text is wider than its container.",
575607
rect: childRect,
576608
containerRect,
577609
overflow,

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,172 @@ describe("layout-audit.browser", () => {
272272
expect(issues[0]?.selector).toBe('[data-layout-name="headline"]');
273273
});
274274

275+
it("flags nowrap text wider than a container that does not clip", () => {
276+
document.body.innerHTML = `
277+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
278+
<div id="box">
279+
<span id="word">OVERPAYING</span>
280+
</div>
281+
</div>
282+
`;
283+
284+
installGeometry(
285+
{
286+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
287+
box: rect({ left: 96, top: 533, width: 576, height: 140 }),
288+
word: rect({ left: 96, top: 533, width: 823, height: 140 }),
289+
},
290+
{ word: { whiteSpace: "nowrap" } },
291+
);
292+
293+
installAuditScript();
294+
295+
const overflow = runAudit().find((issue) => issue.code === "container_overflow");
296+
expect(overflow).toMatchObject({
297+
selector: "#word",
298+
containerSelector: "#box",
299+
});
300+
});
301+
302+
it("clears nowrap container overflow when the container allows overflow", () => {
303+
document.body.innerHTML = `
304+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
305+
<div id="box" data-layout-allow-overflow>
306+
<span id="word">OVERPAYING</span>
307+
</div>
308+
</div>
309+
`;
310+
311+
installGeometry(
312+
{
313+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
314+
box: rect({ left: 96, top: 533, width: 576, height: 140 }),
315+
word: rect({ left: 96, top: 533, width: 823, height: 140 }),
316+
},
317+
{ word: { whiteSpace: "nowrap" } },
318+
);
319+
320+
installAuditScript();
321+
322+
expect(runAudit().some((issue) => issue.code === "container_overflow")).toBe(false);
323+
});
324+
325+
it("does not flag font-height spill from nowrap text that fits its width", () => {
326+
document.body.innerHTML = `
327+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
328+
<div id="box">
329+
<span id="hi">Hi</span>
330+
</div>
331+
</div>
332+
`;
333+
334+
installGeometry(
335+
{
336+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
337+
box: rect({ left: 0, top: 0, width: 150, height: 64 }),
338+
hi: rect({ left: 0, top: -4, width: 60.44, height: 72 }),
339+
},
340+
{ hi: { whiteSpace: "nowrap", fontSize: "64px" } },
341+
);
342+
343+
installAuditScript();
344+
345+
expect(runAudit().some((issue) => issue.code === "container_overflow")).toBe(false);
346+
});
347+
348+
it("does not flag a narrow nowrap label that sits outside a box that does not clip", () => {
349+
document.body.innerHTML = `
350+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
351+
<div id="box">
352+
<span id="badge">NEW</span>
353+
</div>
354+
</div>
355+
`;
356+
357+
installGeometry(
358+
{
359+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
360+
box: rect({ left: 0, top: 0, width: 200, height: 80 }),
361+
badge: rect({ left: 184, top: 0, width: 32, height: 20 }),
362+
},
363+
{ badge: { whiteSpace: "nowrap" } },
364+
);
365+
366+
installAuditScript();
367+
368+
expect(runAudit().some((issue) => issue.code === "container_overflow")).toBe(false);
369+
});
370+
371+
it("does not flag an empty nowrap decoration beside a fitting label", () => {
372+
document.body.innerHTML = `
373+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
374+
<div id="box">
375+
<span id="label">OK</span>
376+
<div id="decoration"></div>
377+
</div>
378+
</div>
379+
`;
380+
381+
installGeometry(
382+
{
383+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
384+
box: rect({ left: 0, top: 0, width: 150, height: 40 }),
385+
label: rect({ left: 0, top: 0, width: 40, height: 20 }),
386+
decoration: rect({ left: 0, top: 0, width: 240, height: 20 }),
387+
},
388+
{ label: { whiteSpace: "nowrap" }, decoration: { whiteSpace: "nowrap" } },
389+
);
390+
391+
installAuditScript();
392+
393+
expect(runAudit().some((issue) => issue.code === "container_overflow")).toBe(false);
394+
});
395+
396+
it("does not flag a wrapping sibling because another child is nowrap", () => {
397+
document.body.innerHTML = `
398+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
399+
<div id="box">
400+
<span id="label">OK</span>
401+
<span id="body">a wrapping line</span>
402+
</div>
403+
</div>
404+
`;
405+
406+
installGeometry(
407+
{
408+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
409+
box: rect({ left: 0, top: 0, width: 150, height: 80 }),
410+
label: rect({ left: 0, top: 0, width: 40, height: 20 }),
411+
body: rect({ left: 0, top: 24, width: 240, height: 40 }),
412+
},
413+
{ label: { whiteSpace: "nowrap" }, body: { whiteSpace: "normal" } },
414+
);
415+
416+
installAuditScript();
417+
418+
expect(runAudit().some((issue) => issue.code === "container_overflow")).toBe(false);
419+
});
420+
421+
it("does not flag a container that does not clip when its text wraps", () => {
422+
document.body.innerHTML = `
423+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
424+
<div id="box">
425+
<span id="word">OVERPAYING</span>
426+
</div>
427+
</div>
428+
`;
429+
430+
installGeometry({
431+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
432+
box: rect({ left: 96, top: 533, width: 576, height: 140 }),
433+
word: rect({ left: 96, top: 533, width: 823, height: 140 }),
434+
});
435+
436+
installAuditScript();
437+
438+
expect(runAudit().some((issue) => issue.code === "container_overflow")).toBe(false);
439+
});
440+
275441
it("respects layout ignore and allow-overflow opt-outs", () => {
276442
document.body.innerHTML = `
277443
<div data-composition-id="main" data-width="640" data-height="360">

0 commit comments

Comments
 (0)