Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/font-fallbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudhal/cli": patch
---

Blog starter: no more text jumping when the web fonts load. Font files move to `/fonts/v1/` and are cached for a year (`public/_headers`), so reloads draw text in its font from the first paint, and each web font gets a metric-matched fallback face (`font-fallbacks.css`), so a first visit swaps fonts without moving the text.
6 changes: 6 additions & 0 deletions .changeset/render-font-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/mcp-server": patch
"@cloudhal/cli": patch
---

`render_page` and `cloudhal render` gain a `font-fallback` check: text set in a web font with no metric-matched fallback after it in its stack, which moves when the font loads.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
- run: pnpm lint
# Size ratchet (docs/engineering/code-structure.md): no new oversized files or functions.
- run: pnpm size:check
- run: node scripts/font-fallbacks.ts --check
- run: pnpm turbo run typecheck
# Each vitest starts a worker per core; more than two packages at once oversubscribes the
# 4-vCPU runner and slow tests (auth, D1, workerd) hit their timeouts.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
},
"type": "module",
"scripts": {
"check": "biome check --diagnostic-level=error . && node scripts/size-check.ts && turbo run typecheck && turbo run test --concurrency=3",
"check": "biome check --diagnostic-level=error . && node scripts/size-check.ts && node scripts/font-fallbacks.ts --check && turbo run typecheck && turbo run test --concurrency=3",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"size:check": "node scripts/size-check.ts",
"size:report": "node scripts/code-size.ts",
"fonts:fallbacks": "node scripts/font-fallbacks.ts",
"design:lint": "node apps/studio/scripts/design-lint.ts",
"design:shots": "node apps/studio/scripts/screenshots.ts",
"i18n:export": "node scripts/i18n-exchange.ts export",
Expand All @@ -34,6 +35,7 @@
},
"devDependencies": {
"@biomejs/biome": "catalog:",
"@capsizecss/unpack": "catalog:",
"@changesets/cli": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:"
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/test/render-chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* RFC-0021 §4: render_page's checks in the local Chrome, on a page with one of each problem.
* Skipped where this machine has no Chrome (set CHROME_PATH).
*/
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
Expand Down Expand Up @@ -250,6 +250,27 @@ header{position:sticky;top:0;height:80px;color:#fff;margin-bottom:-80px}header a
expect(crops("image-fit")?.examples).toEqual([
expect.stringMatching(/keeps 25% of the picture with no focal point/),
]);
// RFC-0023 §2: a web font straight onto a system font moves the text when it loads; one
// followed by its metric-matched fallback does not. (A data URL: file:// pages load no font files.)
const woff2 = await readFile(
path.join(
import.meta.dirname,
"../../../starters/blog/public/fonts/v1/inter-latin-wght-normal.woff2",
),
"base64",
);
const src = `url(data:font/woff2;base64,${woff2}) format("woff2")`;
const fonts = await run(
"fonts.html",
`<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width"><style>
@font-face{font-family:Bare;src:${src}}@font-face{font-family:Tuned;src:${src}}
@font-face{font-family:"Tuned Fallback";src:local("Arial");size-adjust:107%;ascent-override:90%}
</style></head><body><main><h1 style="font-family:Tuned,'Tuned Fallback',sans-serif">Matched</h1>
<p style="font-family:Bare,sans-serif">Jumps</p></main></body></html>`,
);
expect(fonts("font-fallback")?.examples).toEqual([
expect.stringMatching(/p "Jumps" — "bare" has no metric-matched fallback/),
]);
}, 90_000);
});

Expand Down
1 change: 1 addition & 0 deletions packages/mcp-server/src/render-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const WHAT: Record<(typeof RENDER_CHECK_IDS)[number], string> = {
"carousel-focus": "carousel focus",
"carousel-autoplay": "carousel autoplay",
"image-fit": "image crops",
"font-fallback": "font swap shift",
};

function summary(r: RenderPageResult): string {
Expand Down
30 changes: 30 additions & 0 deletions packages/mcp-server/src/render/checks/fonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* In-page checks, part of `RENDER_CHECK_SCRIPT`: font-fallback.
* Plain source text, run inside the page; it shares the script's locals (see ./index.ts).
*/
export const FONT_CHECKS = ` // font-fallback (RFC-0023 §2): text set in a web font whose stack has no metric-matched fallback
// right after it (a face with size-adjust or ascent-override). Without one, the text is first
// drawn in a system font of another size and moves when the web font arrives.
if (document.fonts) {
const tuned = new Set(), web = new Set();
const name = (f) => f.trim().replace(/^["']|["']$/g, "").toLowerCase();
for (const f of document.fonts) {
const fam = name(f.family);
if ((f.sizeAdjust && f.sizeAdjust !== "100%") || (f.ascentOverride && f.ascentOverride !== "normal")) tuned.add(fam);
else if (f.status === "loaded") web.add(fam);
}
const seen = new Set();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const el = n.parentElement;
if (!el || !n.textContent.trim()) continue;
const stack = getComputedStyle(el).fontFamily;
if (seen.has(stack)) continue;
seen.add(stack);
const fams = stack.split(",").map(name);
if (!web.has(fams[0]) || tuned.has(fams[0]) || tuned.has(fams[1] || "")) continue;
if (!visible(el)) { seen.delete(stack); continue; }
add("font-fallback", el, JSON.stringify(fams[0]) + " has no metric-matched fallback after it, so the text moves when the font loads");
}
}
`;
2 changes: 2 additions & 0 deletions packages/mcp-server/src/render/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { COMPONENT_CHECKS } from "./components.ts";
import { CONTENT_CHECKS } from "./content.ts";
import { CONTRAST_CHECKS } from "./contrast.ts";
import { FONT_CHECKS } from "./fonts.ts";
import { LAYOUT_CHECKS } from "./layout.ts";
import { MOTION_CHECKS } from "./motion.ts";
import { NAVIGATION_CHECKS } from "./navigation.ts";
Expand All @@ -21,6 +22,7 @@ export const RENDER_CHECK_SCRIPT = [
LAYOUT_CHECKS,
CONTRAST_CHECKS,
CONTENT_CHECKS,
FONT_CHECKS,
MOTION_CHECKS,
NAVIGATION_CHECKS,
COMPONENT_CHECKS,
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp-server/src/render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export const RENDER_CHECK_IDS = [
"carousel-autoplay",
// RFC-0028: an image cropped hard by cover with no focal point to keep its subject in view.
"image-fit",
// RFC-0023 §2: a web font with no metric-matched fallback, so its text moves when it loads.
"font-fallback",
] as const;
export type RenderCheckId = (typeof RENDER_CHECK_IDS)[number];

Expand Down
178 changes: 178 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ packages:
catalogMode: strict

catalog:
"@capsizecss/unpack": 4.0.1
"@biomejs/biome": 2.5.15
"@changesets/cli": 3.0.3
"@phosphor-icons/core": 2.1.1
Expand Down
Loading
Loading