Don't lock content-derived button widths; nowrap icon+text chips

Two general fixes for single-line chips/pills whose labels wrapped onto
a second line in generated output:

1. fixedWidthButtonLike treated any constant-width button as authored
   fixed-width, but a content-sized chip whose label never changes paints
   the same width at every viewport. When the sizing probe proved
   width:auto reproduces the box at its own max-content at every painted
   sample (with genuinely wrappable text inside), the width is
   content-derived - don't lock it. Baking that px invited the emitters'
   quantization (snapLen 0.1px rounding, snapBase spacing-scale snap) to
   land fractionally below the intrinsic single-line width, wrapping the
   last word. Authored intent (sourceFixedWidth/sourceFixedSize) still
   locks.

2. nowrapWrapVulnerable bailed on any element child, so an icon svg
   beside the label disqualified the classic icon+text chip shape.
   Replaced children (svg/img/...) can't wrap and their constant width is
   already inside the probe's wMin/wMax, so they're now permitted; any
   non-replaced element child still bails.

525 tests (5 new), typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-06 13:36:09 -07:00
co-authored by Claude Opus 4.8
parent 03226f8a53
commit 27cccd8964
2 changed files with 92 additions and 2 deletions
+20 -2
View File
@@ -1952,6 +1952,7 @@ function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean {
if (node.tag !== "a" && node.tag !== "button") return false; if (node.tag !== "a" && node.tag !== "button") return false;
const widths: number[] = []; const widths: number[] = [];
let sampled = 0; let sampled = 0;
let contentFit = true;
for (const vp of viewports) { for (const vp of viewports) {
const cs = node.computedByVp[vp]; const cs = node.computedByVp[vp];
const bb = node.bboxByVp[vp]; const bb = node.bboxByVp[vp];
@@ -1962,7 +1963,21 @@ function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean {
if (pf(cs.borderTopLeftRadius) < 12 && pf(cs.borderTopRightRadius) < 12) return false; if (pf(cs.borderTopLeftRadius) < 12 && pf(cs.borderTopRightRadius) < 12) return false;
if (bb.width < 120 || bb.height < 28) return false; if (bb.width < 120 || bb.height < 28) return false;
widths.push(bb.width); widths.push(bb.width);
// Content-fit evidence: the sizing probe proved `width:auto` reproduces this box AND it paints
// exactly at its own max-content width, with genuinely wrappable text inside (wMin < wMax).
const sz = node.sizingByVp?.[vp];
if (!(sz?.wAuto === true && sz.wMax != null && sz.wMin != null && sz.wMin < sz.wMax - 2 &&
Math.abs(bb.width - sz.wMax) <= Math.max(1.5, 0.01 * sz.wMax))) contentFit = false;
} }
// A constant width alone is weak evidence of an AUTHORED fixed width: a content-sized chip whose
// label never changes paints the same width at every viewport it's shown at. When the probe proved
// width:auto reproduces the box at its max-content at EVERY sample, the width is content-derived —
// don't lock it. Baking that px invites the emitters' quantization (snapLen's 0.1px rounding,
// snapBase's ≤0.25px spacing-scale snap) to land fractionally BELOW the intrinsic single-line
// width, wrapping the label onto a second line; the probe verdict (sizingVerdict → width:auto)
// reproduces the box intrinsically at every width instead. Authored intent (sourceFixedWidth /
// sourceFixedSize, checked by the caller) still locks.
if (sampled >= 1 && contentFit) return false;
return sampled >= 2 && widths.length >= 2 && Math.max(...widths) - Math.min(...widths) <= 2; return sampled >= 2 && widths.length >= 2 && Math.max(...widths) - Math.min(...widths) <= 2;
} }
@@ -3120,7 +3135,10 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
* column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit * column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit
* `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture. * `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture.
* Conservative by construction every guard must hold at every painted viewport: * Conservative by construction every guard must hold at every painted viewport:
* text leaf (direct text, no element children), whitespace not already preserved (`pre*`); * text leaf (direct text; element children only if REPLACED an icon <svg>/<img> beside the
* label, the icon+text chip shape. A replaced box can't wrap and its constant width is already
* inside the probe's wMin/wMax, so the math below is unchanged; any other element child may
* carry its own text and bails), whitespace not already preserved (`pre*`);
* single line: box height one line box (line-height + vertical padding/border), so a genuinely * single line: box height one line box (line-height + vertical padding/border), so a genuinely
* wrapping paragraph (2 line boxes tall) is excluded; * wrapping paragraph (2 line boxes tall) is excluded;
* wrap-vulnerable: max-content (unwrapped) width available container width 2 and it + 1 * wrap-vulnerable: max-content (unwrapped) width available container width 2 and it + 1
@@ -3130,7 +3148,7 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
* Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent no emit. */ * Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent no emit. */
function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean { function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode) return false; if (!parentNode) return false;
if (hasElementChild(node)) return false; if (node.children.some((c) => !isTextChild(c) && !REPLACED.has(c.tag))) return false;
if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false; if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false; if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
let painted = 0; let painted = 0;
+72
View File
@@ -566,6 +566,78 @@ describe("generateCss wrap-vulnerable single-line text", () => {
}); });
}); });
// BUG C, icon+text chip variant — the label sits beside a replaced icon (<svg>), so the vulnerable
// node is NOT a pure text leaf and the leaf-only guard skipped it. The chip's button ancestor bakes
// a quantized px width that can land fractionally below the intrinsic single-line width, wrapping
// the label ("Recreate a / screenshot"). The inner [icon, text] row must still earn nowrap: the icon
// can't wrap, its constant width is already inside the probe's wMin/wMax, and only the text is at risk.
describe("generateCss wrap-vulnerable icon+text chip", () => {
// Captured shape: pill button 193.44×32 (px 12 + 1px border per side) → inner row 167.44×20
// (icon 15 + gap 8 + text), line-height 20, wMin 94.88 < wMax 167.44 == avail == bbox.width.
function chipRow(iconTag: string) {
const szAt = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true, wMin: 94.88, wMax: 167.44 });
const iconPer = (): XPerVp => ({ cs: { display: "inline-block" }, bbox: { x: 13, y: 8.5, width: 15, height: 15 } });
const icon = xNode("n2", iconTag, { 375: iconPer(), 768: iconPer(), 1280: iconPer() });
const rowPer = (): XPerVp => ({ cs: { display: "flex", lineHeight: "20px" }, bbox: { x: 13, y: 6, width: 167.44, height: 20 }, sizing: szAt() });
const row = xNode("n1", "div", { 375: rowPer(), 768: rowPer(), 1280: rowPer() }, [icon, { text: "Recreate a screenshot" }]);
const btnPer = (): XPerVp => ({
cs: { display: "flex", cursor: "pointer", paddingLeft: "12px", paddingRight: "12px", borderLeftWidth: "1px", borderRightWidth: "1px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
bbox: { x: 0, y: 0, width: 193.44, height: 32 },
});
const btn = xNode("n0", "button", { 375: btnPer(), 768: btnPer(), 1280: btnPer() }, [row]);
return btn;
}
it("emits white-space:nowrap for a single-line [icon, text] row flush against its pill's content edge", () => {
const css = generateCss(xIr(chipRow("svg")), new Map());
assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `icon+text chip row should get nowrap, got: ${baseRule(css, "n1")}`);
});
it("does NOT emit nowrap when the sibling is a non-replaced element (it may carry its own text)", () => {
const css = generateCss(xIr(chipRow("span")), new Map());
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `non-replaced sibling must keep the leaf-only bail, got: ${baseRule(css, "n1")}`);
});
});
// A pill <button> whose captured width is CONSTANT across viewports (the label never changes) looks
// authored-fixed to the button-width heuristic — but the sizing probe proved `width:auto` reproduces
// the box AND it paints exactly at its own max-content width at every sample: the width is content,
// not authored. Baking it invites the emitters' quantization (0.1px rounding / spacing-scale snap) to
// land fractionally below the intrinsic single-line width and wrap the label; the width must be
// dropped so the clone re-derives it intrinsically at every band.
describe("generateCss content-fit button width (probe-proven auto beats the constant-width lock)", () => {
function pillBody(sizing?: () => RawSizing) {
const per = (): XPerVp => ({
cs: { display: "flex", cursor: "pointer", width: "144.188px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
bbox: { x: 0, y: 0, width: 144.19, height: 32 },
...(sizing ? { sizing: sizing() } : {}),
});
const btn = xNode("n1", "button", { 375: per(), 768: per(), 1280: per() }, [{ text: "Clone LinkedIn" }]);
return xNode("n0", "body", {
375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 32 } },
768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 32 } },
1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 32 } },
}, [btn]);
}
it("drops the width when the probe proves auto AND the button paints at its max-content everywhere", () => {
const sz = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
const css = generateCss(xIr(pillBody(sz)), new Map());
assert.ok(!allRulesX(css, "n1").includes("width:144"), `content-fit pill width must be dropped, got: ${allRulesX(css, "n1")}`);
});
it("still bakes the width without probe evidence (no sizing data)", () => {
const css = generateCss(xIr(pillBody()), new Map());
assert.ok(allRulesX(css, "n1").includes("width:144"), `unproven button width must stay locked, got: ${allRulesX(css, "n1")}`);
});
it("still bakes the width when the probe proves it load-bearing (wAuto:false)", () => {
const sz = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
const css = generateCss(xIr(pillBody(sz)), new Map());
assert.ok(allRulesX(css, "n1").includes("width:144"), `load-bearing button width must stay locked, got: ${allRulesX(css, "n1")}`);
});
});
// Cross-band transform identity — a node with a NON-identity transform at one band must emit the // Cross-band transform identity — a node with a NON-identity transform at one band must emit the
// explicit identity `transform:none` at the bands where the source is untransformed, so the // explicit identity `transform:none` at the bands where the source is untransformed, so the
// transform can't cascade across bands and freeze at a width the source left untransformed. // transform can't cascade across bands and freeze at a width the source left untransformed.