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 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 06:21:07 -07:00
co-authored by Claude Fable 5
parent 5b60ded62d
commit fae44aab93
5 changed files with 215 additions and 7 deletions
+6 -2
View File
@@ -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
+97 -4
View File
@@ -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<string>();
const terminalByFamily = new Map<string, boolean>();
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<string[]> {
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<unknown>; 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<string>();
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<string>();
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 */ }
+3
View File
@@ -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 });
+60
View File
@@ -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 06 stay unmoved).
describe("generateCss sticky full-width wrapper is viewport-tracking (no px freeze)", () => {
// Sticky banner directly under <body>; 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")}`);
});
});
+49 -1
View File
@@ -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 = "";