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:
co-authored by
Claude Opus 4.8
parent
03226f8a53
commit
27cccd8964
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user