diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index d6f87b0..92e3c58 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -1559,7 +1559,7 @@ function fillsToCapWidth(node: IRNode, parentNode: IRNode | undefined, viewports * margins (those make the width load-bearing). */ function fluidPercentByVp(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): Record | null { if (!parentNode || REPLACED.has(node.tag) || node.tag.includes("-")) return null; - type S = { vp: number; ratio: number; container: number; w: number; flexRow: boolean }; + type S = { vp: number; ratio: number; container: number; w: number; flexRow: boolean; wMin?: number; wMax?: number }; const samples: S[] = []; for (const vp of viewports) { const cs = node.computedByVp[vp]; const pcs = parentNode.computedByVp[vp]; @@ -1592,7 +1592,8 @@ function fluidPercentByVp(node: IRNode, parentNode: IRNode | undefined, viewport const ratio = nb.width / container; if (!(ratio > 0.04 && ratio < 1.02)) return null; // not a clean fraction of the container const flexRow = /(?:^|-)flex$/.test(pdisp) && /^(row|row-reverse)$/.test(pcs.flexDirection || "row"); - samples.push({ vp, ratio, container, w: nb.width, flexRow }); + const sz = node.sizingByVp?.[vp]; + samples.push({ vp, ratio, container, w: nb.width, flexRow, wMin: sz?.wMin, wMax: sz?.wMax }); } if (samples.length < 2) return null; const ws = samples.map((s) => s.w); @@ -1618,7 +1619,21 @@ function fluidPercentByVp(node: IRNode, parentNode: IRNode | undefined, viewport // Each sample is a clean fraction that reproduces its captured px. Snap to 0.5% and verify. const out: Record = {}; for (const s of samples) { - const pct = Math.round(s.ratio * 200) / 2; // nearest 0.5% + // Default: nearest 0.5% (no geometry churn for ordinary boxes). But a CONTENT-FIT box — one sitting + // at its own max-content width (a nowrap pill: captured w ≈ wMax, and genuinely wrappable so wMin < + // wMax) — has NO slack: rounding the % DOWN emits a width below its intrinsic content, and the clone + // wraps it onto a second line (task #26: a 71.70% pill → 71.5% → 245.24px < 245.94px content → wrap, + // +23.5px section). For such a node snap UP (ceil to the 0.5% step) only when the nearest-rounded px + // falls below wMax by more than 0.25px, so the emitted width never lands short of the content it must + // hold. All other nodes keep round-to-nearest. + const nearest = Math.round(s.ratio * 200) / 2; + let pct = nearest; + const wMax = s.wMax, wMin = s.wMin; + const contentFit = wMax != null && wMin != null && wMin < wMax - 2 && + Math.abs(s.w - wMax) <= Math.max(1.5, 0.01 * wMax); + if (contentFit && (nearest / 100) * s.container < wMax! - 0.25) { + pct = Math.ceil(s.ratio * 200) / 2; // snap UP to clear intrinsic content width + } if (Math.abs((pct / 100) * s.container - s.w) > Math.max(1.5, 0.01 * s.w)) return null; out[s.vp] = `${pct}%`; } diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 89e778a..28d2a03 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -775,6 +775,79 @@ describe("generateCss content-sized flex row (probe veto for wrapping text)", () }); }); +describe("generateCss fluid percent snap-up for content-fit nowrap pill (task #26)", () => { + // A nowrap flex pill, sole child of a flex COLUMN, that paints at its own max-content width + // (bbox.width == wMax) at every viewport — a load-bearing % width that, rounded to the nearest + // 0.5%, lands BELOW the intrinsic content and wraps to a second line. Real evidence: a footer + // pill 245.94px in a 343px column at 375 (71.70% → 71.5% → 245.24px < 245.94 → wrap). + // Numbers below are the captured run's (375 / 768 / 1280). + // Per-vp pill widths mirror the real run: at 768 the column is narrower than the pill's content, so + // the pill shrinks to fill it (221.33 == container); at 375/1280 it sits at its max-content (245.94). + // A CONSTANT max-content across vps keeps contentSizedColumnItem from firing (its widths don't vary), + // so the width falls through to the fluid-percent (percentVp) path — exactly the real code path. + const wMax = 245.94; + function pillIn(colWidths: [number, number, number], wMinOrWMax: { wMin: number; wMax: number }) { + const pillW: Record = { 375: 245.94, 768: 221.33, 1280: 245.94 }; + const sz = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: true, ...wMinOrWMax }); + const pill = xNode("n1", "a", { + 375: { cs: { display: "flex", whiteSpace: "nowrap" }, bbox: { x: 0, y: 0, width: pillW[375]!, height: 40 }, sizing: sz() }, + 768: { cs: { display: "flex", whiteSpace: "nowrap" }, bbox: { x: 0, y: 0, width: pillW[768]!, height: 40 }, sizing: sz() }, + 1280: { cs: { display: "flex", whiteSpace: "nowrap" }, bbox: { x: 0, y: 0, width: pillW[1280]!, height: 40 }, sizing: sz() }, + }, [{ text: "Certified B Corporation" }]); + return xNode("n0", "body", { + 375: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: colWidths[0], height: 40 } }, + 768: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: colWidths[1], height: 40 } }, + 1280: { cs: { display: "flex", flexDirection: "column" }, bbox: { x: 0, y: 0, width: colWidths[2], height: 40 } }, + }, [pill]); + } + + /** Collect every emitted `width:N%` for `id` across the base rule and all @media bands. */ + function emittedPercents(css: string, id: string) { + const bodies: string[] = []; + // base rule + const base = css.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`)); + if (base) bodies.push(base[1]!); + // banded rules + for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) { + const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`)); + if (r) bodies.push(r[1]!); + } + const pcts: number[] = []; + for (const b of bodies) { + const w = b.match(/width:([\d.]+)%/); + if (w) pcts.push(parseFloat(w[1]!)); + } + return { pcts, bodies }; + } + + it("snaps the percent UP so the emitted width never lands below the pill's content (no wrap)", () => { + // 245.94 in 343 → 71.70% (nearest 71.5% = 245.24 < wMax); in 392 → 62.74% (nearest 62.5% = 245.0 < wMax). + const css = generateCss(xIr(pillIn([343, 221.33, 392], { wMin: 189.72, wMax })), new Map()); + const { pcts } = emittedPercents(css, "n1"); + assert.ok(pcts.length > 0, `pill should carry a fluid width:%, got none`); + // Every emitted % must clear the content width against the vp where it applies. The two content- + // fit viewports are 343 (71.70% band) and 392 (62.74% band); resolve each candidate % and require + // at least the 343-container % ≥ 245.94. + const at343 = pcts.filter((p) => Math.abs((p / 100) * 343 - 245.94) <= 3); + assert.ok(at343.some((p) => (p / 100) * 343 >= 245.94 - 0.01), + `375 pill % must resolve ≥ 245.94px in a 343px column, got pcts=${pcts.join(",")} → ${at343.map((p) => ((p / 100) * 343).toFixed(2)).join(",")}px`); + // Nearest-rounding would have emitted 71.5% (= 245.24). Assert the fix bumped it up to 72%. + assert.ok(pcts.includes(72) || pcts.some((p) => (p / 100) * 343 >= 245.94), + `expected snap-UP to 72% (246.96px), got ${pcts.join(",")}`); + }); + + it("keeps round-to-NEAREST for an ordinary box comfortably inside its container (no churn)", () => { + // Same geometry but NOT content-fit: bbox 245.94 sits well below wMax (400), so there is slack — + // wrapping can't happen, and the default nearest rounding (71.5%) must be preserved. + const rootPill = pillIn([343, 221.33, 392], { wMin: 100, wMax: 400 }); + const css2 = generateCss(xIr(rootPill), new Map()); + const { pcts } = emittedPercents(css2, "n1"); + // 71.70% and 62.74% both round to nearest (71.5%, 63.0%... actually 62.74→62.5) — assert 71.5 present. + assert.ok(pcts.includes(71.5), `an ordinary box must keep nearest rounding (71.5%), got ${pcts.join(",")}`); + assert.ok(!pcts.includes(72), `an ordinary box must NOT be bumped up to 72%, got ${pcts.join(",")}`); + }); +}); + // Defect C (generate side) — a CSS marquee (infinite `@keyframes` animation) gated to a breakpoint // (Webflow's `max-lg` logo/text tracks) is `animation:none` at the base but runs at the narrow band. // The per-band transform delta at the animated width is a FROZEN mid-scroll phase; it must be