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:
co-authored by
Claude Fable 5
parent
5b60ded62d
commit
fae44aab93
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 <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")}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user