From 27cccd896474abc8e2eab33581cd65c6fa874b8e Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Mon, 6 Jul 2026 13:36:09 -0700 Subject: [PATCH] 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 --- compiler/src/generate/css.ts | 22 ++++++++++- compiler/test/css.test.ts | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index 74eb531..fef91aa 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -1952,6 +1952,7 @@ function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean { if (node.tag !== "a" && node.tag !== "button") return false; const widths: number[] = []; let sampled = 0; + let contentFit = true; for (const vp of viewports) { const cs = node.computedByVp[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 (bb.width < 120 || bb.height < 28) return false; 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; } @@ -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 * `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: - * • text leaf (direct text, no element children), whitespace not already preserved (`pre*`); + * • text leaf (direct text; element children only if REPLACED — an icon / 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 * wrapping paragraph (≥2 line boxes tall) is excluded; * • 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. */ function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean { 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 (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false; let painted = 0; diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 9458cb1..4241d3b 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -566,6 +566,78 @@ describe("generateCss wrap-vulnerable single-line text", () => { }); }); +// BUG C, icon+text chip variant — the label sits beside a replaced icon (), 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