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
@@ -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