From fae44aab9317c5b0a1cc5cab9b23472c527830ae Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Sat, 4 Jul 2026 06:21:07 -0700 Subject: [PATCH] Validation waits for webfonts before measuring; sticky wrappers keep fluid width - The render walk and screenshots now await document.fonts.ready plus a bounded state-based poll until every declared face is terminal (loaded/error); pending families surface as a font_wait_warning event instead of skewing text metrics with fallback-font boxes - position:sticky full-width wrappers share the static/relative fluid-fill branch (a sticky box fills its containing block like any in-flow box) instead of freezing at canonical px; fixed-width sticky rails still freeze 447 tests pass (12 new), typecheck clean. Co-Authored-By: Claude Fable 5 --- compiler/src/generate/css.ts | 8 ++- compiler/src/validate/render.ts | 101 ++++++++++++++++++++++++++++-- compiler/src/validate/validate.ts | 3 + compiler/test/css.test.ts | 60 ++++++++++++++++++ compiler/test/serveRange.test.ts | 50 ++++++++++++++- 5 files changed, 215 insertions(+), 7 deletions(-) diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index f256e44..d6f87b0 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -244,9 +244,13 @@ function isFluidFullBleed(node: IRNode, parentNode: IRNode | undefined, viewport const pos = cs.position || "static"; if (pos === "absolute" || pos === "fixed") { if (!(nearZero(cs.left) && nearZero(cs.right))) return false; // width is real unless both insets pin it - } else if (pos === "sticky") { - return false; } else { + // static | relative | sticky. A sticky box generates a normal in-flow box: its WIDTH fills the + // containing block exactly as static/relative does (position:sticky only shifts the box's offset + // while scrolling, never its resolved width). So a sticky wrapper that spans the viewport at + // every captured width was authored fluid (width:100%/auto) and must NOT freeze to the canonical + // px — frozen at 1280, a `justify-content:center` sticky banner pushes its inner content off a + // 375 viewport. Treat sticky identically to the static/relative fluid-fill branch. if (!(zeroOrAuto(cs.left) && zeroOrAuto(cs.right))) return false; // positioned breakout: width is real const pdisp = parentNode?.computedByVp[vp]?.display || ""; if (/flex|grid/.test(pdisp)) return false; // flex/grid item: auto width ≠ fill diff --git a/compiler/src/validate/render.ts b/compiler/src/validate/render.ts index aabfa57..784ec36 100644 --- a/compiler/src/validate/render.ts +++ b/compiler/src/validate/render.ts @@ -260,8 +260,87 @@ export type RenderResult = { runtimeErrors: string[]; httpStatus: number; failedResources: string[]; + /** Non-fatal diagnostics: families the app declared via @font-face that never reached + * `status:"loaded"` within the wait bound (their text was measured with a fallback face). */ + fontWarnings: string[]; }; +/** A single entry of `document.fonts` as reported in-page, reduced to the fields the + * load decision needs. Serializable so the decision logic can be unit-tested outside a browser. */ +export type FontFaceStatus = { family: string; weight: string; style: string; status: string }; + +/** Pure decision for the font-load wait: given the current `document.fonts` entries, return the + * families that are DECLARED (an @font-face exists for them) but not yet `"loaded"`. A family is + * considered pending while ANY of its faces is still `"unloaded"` or `"loading"`; a face that + * `"error"`ed is treated as terminal (it will never load, so waiting longer is pointless — it is + * not reported as pending). Empty return ⇒ every declared face has reached a terminal state and + * the DOM may be measured. The check is state-based (not time-based), so the caller's poll is + * deterministic: it ends the instant this returns empty, regardless of wall-clock timing. */ +export function pendingFontFamilies(faces: FontFaceStatus[]): string[] { + const pending = new Set(); + const terminalByFamily = new Map(); + for (const f of faces) { + const fam = f.family.replace(/^["']|["']$/g, ""); + if (f.status === "loaded" || f.status === "error") { + if (!terminalByFamily.has(fam)) terminalByFamily.set(fam, true); + } else { + // "unloaded" | "loading" (or any non-terminal state) ⇒ this family is still pending. + pending.add(fam); + terminalByFamily.set(fam, false); + } + } + return [...pending].sort(); +} + +/** In-page: await `document.fonts.ready`, then poll (bounded) until every declared @font-face has + * reached a terminal state (`loaded`/`error`). Returns the still-pending families when the bound + * expired, or an empty array once all faces settled. Runs entirely inside the browser context so + * it reads the live FontFaceSet. Bounded + state-based ⇒ deterministic (ends on state, never on a + * fixed sleep). `capMs` is a hard ceiling so a hung/erroring font request can't stall the render. */ +async function awaitFontsLoaded( + page: import("playwright").Page, + opts?: { capMs?: number; pollMs?: number }, +): Promise { + const capMs = opts?.capMs ?? 3000; + const pollMs = opts?.pollMs ?? 50; + try { + return await page.evaluate( + async ({ capMs, pollMs }) => { + const doc = document as Document; + const set = doc.fonts as unknown as { ready?: Promise; forEach?: (cb: (f: FontFaceStatus) => void) => void } | undefined; + if (!set) return []; + const snapshot = (): { family: string; weight: string; style: string; status: string }[] => { + const out: { family: string; weight: string; style: string; status: string }[] = []; + set.forEach?.((f) => out.push({ family: f.family, weight: f.weight, style: f.style, status: f.status })); + return out; + }; + const pending = (faces: { family: string; weight: string; style: string; status: string }[]): string[] => { + const p = new Set(); + for (const f of faces) { + const fam = f.family.replace(/^["']|["']$/g, ""); + if (f.status !== "loaded" && f.status !== "error") p.add(fam); + } + return [...p].sort(); + }; + // First give the browser's own aggregate signal a chance (bounded — a hung request must not + // hold ready forever). Then poll the per-face states until all terminal or the cap expires. + await Promise.race([set.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, capMs))]); + const deadline = Date.now() + capMs; + // eslint-disable-next-line no-constant-condition + while (true) { + const left = pending(snapshot()); + if (left.length === 0) return []; + if (Date.now() >= deadline) return left; + await new Promise((r) => setTimeout(r, pollMs)); + } + }, + { capMs, pollMs }, + ) as string[]; + } catch { + return []; // a page without a FontFaceSet (or an evaluate fault) never blocks the walk + } +} + /** Navigate with `waitUntil:"load"` then wait for a bounded window of network quiet, so * validation NEVER hard-fails on media that trickles requests. `waitUntil:"networkidle"` * is a hard timeout: an autoplaying long video (even chunked via 206) keeps issuing bounded @@ -335,6 +414,7 @@ export async function renderApp(opts: { const vh = viewportHeight(vw); const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: vw, height: vh }, deviceScaleFactor: 1 }); const runtimeErrors: string[] = []; + const fontWarnings: string[] = []; const failedResources = new Set(); let httpStatus = 0; try { @@ -345,7 +425,17 @@ export async function renderApp(opts: { page.on("requestfailed", (r) => failedResources.add(`failed ${r.url()}`)); const resp = await gotoAndSettle(page, opts.url); if (resp) httpStatus = resp.status(); - try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ } + // Webfonts must be APPLIED before the DOM walk and the screenshot: a rendered snapshot taken + // while the app's @font-face faces are still "unloaded" measures every text box in the + // fallback face (systematically narrower/wider glyphs → a bogus size delta attributed to the + // clone). Await document.fonts.ready AND poll (bounded, state-based) until every declared face + // is terminal, so this holds for both the walk below and the screenshot further down. + const pendingFonts = await awaitFontsLoaded(page); + if (pendingFonts.length) { + const msg = `font-wait: ${pendingFonts.length} @font-face families unloaded after wait bound at ${vw}px: ${pendingFonts.join(", ")}`; + fontWarnings.push(msg); + console.warn(`[render] ${msg}`); + } // Scroll to settle any lazy effects, then back to top. await page.evaluate(async () => { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -389,18 +479,19 @@ export async function renderApp(opts: { await page.screenshot({ path: shotPath, fullPage: true, timeout: 90_000, animations: "disabled" }); } } catch { /* ignore */ } - return { viewport: vw, snapshot, runtimeErrors, failedResources: [...failedResources], httpStatus }; + return { viewport: vw, snapshot, runtimeErrors, fontWarnings, failedResources: [...failedResources], httpStatus }; } finally { await ctx.close(); } }); const runtimeErrors = results.flatMap((r) => r.runtimeErrors); + const fontWarnings = results.flatMap((r) => r.fontWarnings); const failedResources = new Set(results.flatMap((r) => r.failedResources)); for (const r of results) snapshots[r.viewport] = r.snapshot; const non200 = results.find((r) => r.httpStatus && r.httpStatus !== 200); const httpStatus = non200?.httpStatus ?? results.find((r) => r.httpStatus)?.httpStatus ?? 0; ensureDir(join(opts.renderedDir, "computed")); - return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources] }; + return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources], fontWarnings }; } finally { await browser.close(); } @@ -425,7 +516,9 @@ export async function measureProbeWidths(opts: { url: string; widths: number[] } const page = await ctx.newPage(); await page.addInitScript(ESBUILD_SHIM); await gotoAndSettle(page, opts.url); - try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ } + // This probe walks the DOM and measures text boxes, so — exactly as renderApp does — webfonts + // must be applied first, else the probe reads fallback-font widths at the off-band widths. + await awaitFontsLoaded(page); await page.evaluate(() => { const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void }; try { win.__dittoMotionStopped = true; } catch { /* ignore */ } diff --git a/compiler/src/validate/validate.ts b/compiler/src/validate/validate.ts index 0be55b4..070df00 100644 --- a/compiler/src/validate/validate.ts +++ b/compiler/src/validate/validate.ts @@ -108,6 +108,9 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string; try { const r = await renderApp({ url: server.url + "/", viewports, renderedDir }); snapshots = r.snapshots; runtimeErrors = r.runtimeErrors; httpStatus = r.httpStatus; failedResources = r.failedResources; + // Non-fatal: webfonts that never loaded before the render walk (text was measured in a fallback + // face). Surfaced as a warning event, NOT a runtime error, so it never fails gate 0. + if (r.fontWarnings.length) log({ event: "font_wait_warning", families: r.fontWarnings }); probeSnaps = await measureProbeWidths({ url: server.url + "/", widths: probeWidthsFor(viewports) }); if (capture.interaction) { interactionGate = await driveInteractionGate({ url: server.url + "/", viewports, ir, interaction: capture.interaction }); diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index 115fd86..89e778a 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -1210,3 +1210,63 @@ describe("collectNodeRules picture fill child keeps authored height (T2)", () => assert.ok(/560px|500px|480px/.test(all), `authored height must survive with a picture fill child, got: ${all}`); }); }); + +// A `position:sticky` full-width banner wrapper: its box spans the viewport at EVERY captured width +// (375/768/1280), so it was authored fluid (width:100%/auto) — sticky only shifts the box's offset +// while scrolling, never its resolved width. Freezing it to the canonical 1280px means at a 375 +// viewport its `justify-content:center` pushes the inner content off-screen. A viewport-tracking +// sticky wrapper must emit no frozen width (→ fluid `width:auto`, resolving to the identical px at +// every captured width, so gates 0–6 stay unmoved). +describe("generateCss sticky full-width wrapper is viewport-tracking (no px freeze)", () => { + // Sticky banner directly under ; spans the full viewport width at every width, centred inner. + function stickyBanner() { + const inner = xNode("n2", "span", { + 375: { cs: { display: "inline-block" }, bbox: { x: 120, y: 8, width: 135, height: 20 } }, + 768: { cs: { display: "inline-block" }, bbox: { x: 316, y: 8, width: 135, height: 20 } }, + 1280: { cs: { display: "inline-block" }, bbox: { x: 572, y: 8, width: 135, height: 20 } }, + }, [{ text: "Enrollment is open" } as IRChild]); + const banner = xNode("n1", "div", { + 375: { cs: { display: "flex", position: "sticky", justifyContent: "center", top: "0px", width: "375px" }, bbox: { x: 0, y: 0, width: 375, height: 36 } }, + 768: { cs: { display: "flex", position: "sticky", justifyContent: "center", top: "0px", width: "768px" }, bbox: { x: 0, y: 0, width: 768, height: 36 } }, + 1280: { cs: { display: "flex", position: "sticky", justifyContent: "center", top: "0px", width: "1280px" }, bbox: { x: 0, y: 0, width: 1280, height: 36 } }, + }, [inner]); + return xNode("n0", "body", { + 375: { bbox: { x: 0, y: 0, width: 375, height: 800 } }, + 768: { bbox: { x: 0, y: 0, width: 768, height: 800 } }, + 1280: { bbox: { x: 0, y: 0, width: 1280, height: 800 } }, + }, [banner]); + } + + it("does not freeze the sticky banner width to the canonical px", () => { + const css = generateCss(xIr(stickyBanner()), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(!/width:1280px/.test(all), `sticky full-width banner must not freeze to canonical px, got: ${all}`); + assert.ok(!/width:768px/.test(all) && !/width:375px/.test(all), `no per-band px freeze either, got: ${all}`); + }); + + it("does not inject auto margins (fix drops the width only, not centring)", () => { + // The d3ec154 guard: mx-auto only for true auto-margin centring. A sticky banner centres its + // INNER content via justify-content, so the wrapper itself must not gain auto side margins. + const css = generateCss(xIr(stickyBanner()), new Map()); + const all = allRulesX(css, "n1"); + assert.ok(!/margin-left:auto/.test(all) && !/margin-right:auto/.test(all), `must not add auto margins, got: ${all}`); + }); + + it("still freezes a genuinely fixed-width sticky element (not viewport-spanning)", () => { + // A sticky sidebar rail whose width is a real fixed px (constant 280px, never == the viewport) + // must keep its baked width — the fluid-full-bleed detector only fires when the box spans the + // viewport at every width. + const rail = xNode("n1", "div", { + 375: { cs: { display: "block", position: "sticky", top: "0px", width: "280px" }, bbox: { x: 0, y: 0, width: 280, height: 400 } }, + 768: { cs: { display: "block", position: "sticky", top: "0px", width: "280px" }, bbox: { x: 0, y: 0, width: 280, height: 400 } }, + 1280: { cs: { display: "block", position: "sticky", top: "0px", width: "280px" }, bbox: { x: 0, y: 0, width: 280, height: 400 } }, + }); + const root = xNode("n0", "body", { + 375: { bbox: { x: 0, y: 0, width: 375, height: 800 } }, + 768: { bbox: { x: 0, y: 0, width: 768, height: 800 } }, + 1280: { bbox: { x: 0, y: 0, width: 1280, height: 800 } }, + }, [rail]); + const css = generateCss(xIr(root), new Map()); + assert.ok(/width:280px/.test(allRulesX(css, "n1")), `a fixed-width sticky rail keeps its px, got: ${allRulesX(css, "n1")}`); + }); +}); diff --git a/compiler/test/serveRange.test.ts b/compiler/test/serveRange.test.ts index 6bb6ee0..fdcc6d9 100644 --- a/compiler/test/serveRange.test.ts +++ b/compiler/test/serveRange.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { serveStatic, parseRangeHeader } from "../src/validate/render.js"; +import { serveStatic, parseRangeHeader, pendingFontFamilies, type FontFaceStatus } from "../src/validate/render.js"; // ---- Pure helper: parseRangeHeader ---- describe("parseRangeHeader", () => { @@ -52,6 +52,54 @@ describe("parseRangeHeader", () => { }); }); +// ---- Pure helper: pendingFontFamilies (the font-load wait decision) ---- +// The render walk must run after webfonts are APPLIED, else text boxes measure the fallback face +// (a systematic width delta wrongly attributed to the clone). This helper is the state-based (not +// time-based) predicate the in-page poll ends on: it returns the DECLARED families not yet terminal. +describe("pendingFontFamilies", () => { + const face = (family: string, status: string, weight = "400", style = "normal"): FontFaceStatus => ({ family, weight, style, status }); + + it("no faces declared → nothing pending", () => { + assert.deepEqual(pendingFontFamilies([]), []); + }); + + it("all faces loaded → nothing pending (walk may proceed)", () => { + assert.deepEqual(pendingFontFamilies([face("Avenir", "loaded"), face("Inter", "loaded")]), []); + }); + + it("an unloaded face makes its family pending", () => { + assert.deepEqual(pendingFontFamilies([face("Avenir", "unloaded")]), ["Avenir"]); + }); + + it("a loading face makes its family pending", () => { + assert.deepEqual(pendingFontFamilies([face("Avenir", "loading")]), ["Avenir"]); + }); + + it("errored faces are terminal — never reported pending (waiting longer is pointless)", () => { + assert.deepEqual(pendingFontFamilies([face("Avenir", "error")]), []); + }); + + it("a family with mixed weights is pending if ANY weight is not terminal", () => { + const faces = [face("Avenir", "loaded", "400"), face("Avenir", "loading", "700")]; + assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]); + }); + + it("a family whose faces are all terminal (loaded or errored) is not pending", () => { + const faces = [face("Avenir", "loaded", "400"), face("Avenir", "error", "700")]; + assert.deepEqual(pendingFontFamilies(faces), []); + }); + + it("strips surrounding quotes from the reported family name and dedupes+sorts", () => { + const faces = [face('"Source Serif"', "unloaded"), face("Avenir", "loading"), face("Avenir", "unloaded")]; + assert.deepEqual(pendingFontFamilies(faces), ["Avenir", "Source Serif"]); + }); + + it("only the unloaded families are returned when some are loaded", () => { + const faces = [face("Inter", "loaded"), face("Avenir", "unloaded"), face("JetBrains", "loaded")]; + assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]); + }); +}); + // ---- Integration: serveStatic answers real Range requests with 206/416 ---- describe("serveStatic Range support (integration)", () => { let rootDir = "";