diff --git a/.changeset/font-fallbacks.md b/.changeset/font-fallbacks.md new file mode 100644 index 00000000..2672f88e --- /dev/null +++ b/.changeset/font-fallbacks.md @@ -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. diff --git a/.changeset/render-font-fallback.md b/.changeset/render-font-fallback.md new file mode 100644 index 00000000..5a630742 --- /dev/null +++ b/.changeset/render-font-fallback.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed907f20..f01925e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/package.json b/package.json index f78d7268..64d4df65 100644 --- a/package.json +++ b/package.json @@ -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", @@ -34,6 +35,7 @@ }, "devDependencies": { "@biomejs/biome": "catalog:", + "@capsizecss/unpack": "catalog:", "@changesets/cli": "catalog:", "turbo": "catalog:", "typescript": "catalog:" diff --git a/packages/cli/test/render-chrome.test.ts b/packages/cli/test/render-chrome.test.ts index aff951d3..8ef7a9f4 100644 --- a/packages/cli/test/render-chrome.test.ts +++ b/packages/cli/test/render-chrome.test.ts @@ -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"; @@ -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", + `

Matched

+

Jumps

`, + ); + expect(fonts("font-fallback")?.examples).toEqual([ + expect.stringMatching(/p "Jumps" — "bare" has no metric-matched fallback/), + ]); }, 90_000); }); diff --git a/packages/mcp-server/src/render-tools.ts b/packages/mcp-server/src/render-tools.ts index 0b8f448d..f9c0bc7e 100644 --- a/packages/mcp-server/src/render-tools.ts +++ b/packages/mcp-server/src/render-tools.ts @@ -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 { diff --git a/packages/mcp-server/src/render/checks/fonts.ts b/packages/mcp-server/src/render/checks/fonts.ts new file mode 100644 index 00000000..0bc620cc --- /dev/null +++ b/packages/mcp-server/src/render/checks/fonts.ts @@ -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"); + } + } +`; diff --git a/packages/mcp-server/src/render/checks/index.ts b/packages/mcp-server/src/render/checks/index.ts index 14361d15..7f0967b2 100644 --- a/packages/mcp-server/src/render/checks/index.ts +++ b/packages/mcp-server/src/render/checks/index.ts @@ -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"; @@ -21,6 +22,7 @@ export const RENDER_CHECK_SCRIPT = [ LAYOUT_CHECKS, CONTRAST_CHECKS, CONTENT_CHECKS, + FONT_CHECKS, MOTION_CHECKS, NAVIGATION_CHECKS, COMPONENT_CHECKS, diff --git a/packages/mcp-server/src/render/types.ts b/packages/mcp-server/src/render/types.ts index 693f2fd5..f0f4aeab 100644 --- a/packages/mcp-server/src/render/types.ts +++ b/packages/mcp-server/src/render/types.ts @@ -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]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab1d3107..532ec545 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ catalogs: '@biomejs/biome': specifier: 2.5.15 version: 2.5.15 + '@capsizecss/unpack': + specifier: 4.0.1 + version: 4.0.1 '@changesets/cli': specifier: 3.0.3 version: 3.0.3 @@ -373,6 +376,9 @@ importers: '@biomejs/biome': specifier: 'catalog:' version: 2.5.15 + '@capsizecss/unpack': + specifier: 'catalog:' + version: 4.0.1 '@changesets/cli': specifier: 'catalog:' version: 3.0.3 @@ -512,6 +518,178 @@ importers: specifier: 'catalog:' version: 5.0.3(@types/node@26.6.5)(happy-dom@21.0.3)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + examples/newsroom: + dependencies: + '@astrojs/cloudflare': + specifier: 'catalog:' + version: 14.3.4(@types/node@26.6.5)(astro@7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(wrangler@4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5))(yaml@2.9.1) + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../../packages/astro-renderer + '@cloudhal/core-ts': + specifier: workspace:* + version: link:../../packages/core-ts + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + astro: + specifier: 'catalog:' + version: 7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + better-sqlite3: + specifier: 'catalog:' + version: 13.0.3 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + devDependencies: + '@cloudhal/cloudflare': + specifier: workspace:* + version: link:../../packages/cloudflare + wrangler: + specifier: 'catalog:' + version: 4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5) + + examples/newsroom-ja: + dependencies: + '@astrojs/cloudflare': + specifier: 'catalog:' + version: 14.3.4(@types/node@26.6.5)(astro@7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(wrangler@4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5))(yaml@2.9.1) + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../../packages/astro-renderer + '@cloudhal/core-ts': + specifier: workspace:* + version: link:../../packages/core-ts + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + astro: + specifier: 'catalog:' + version: 7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + better-sqlite3: + specifier: 'catalog:' + version: 13.0.3 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + devDependencies: + '@cloudhal/cloudflare': + specifier: workspace:* + version: link:../../packages/cloudflare + wrangler: + specifier: 'catalog:' + version: 4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5) + + examples/smoke: + dependencies: + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../../packages/astro-renderer + '@cloudhal/core-ts': + specifier: workspace:* + version: link:../../packages/core-ts + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + astro: + specifier: 'catalog:' + version: 7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + better-sqlite3: + specifier: 'catalog:' + version: 13.0.3 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + + examples/starter-demo: + dependencies: + '@astrojs/cloudflare': + specifier: 'catalog:' + version: 14.3.4(@types/node@26.6.5)(astro@7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(wrangler@4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5))(yaml@2.9.1) + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../../packages/astro-renderer + '@cloudhal/cli': + specifier: workspace:* + version: link:../../packages/cli + '@cloudhal/core-ts': + specifier: workspace:* + version: link:../../packages/core-ts + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + astro: + specifier: 'catalog:' + version: 7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + better-sqlite3: + specifier: 'catalog:' + version: 13.0.3 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + devDependencies: + '@cloudhal/cloudflare': + specifier: workspace:* + version: link:../../packages/cloudflare + wrangler: + specifier: 'catalog:' + version: 4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5) + + examples/studio-demo: + dependencies: + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../../packages/astro-renderer + '@cloudhal/core-ts': + specifier: workspace:* + version: link:../../packages/core-ts + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + astro: + specifier: 'catalog:' + version: 7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + better-sqlite3: + specifier: 'catalog:' + version: 13.0.3 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + + examples/ui-audit: + dependencies: + '@astrojs/cloudflare': + specifier: 'catalog:' + version: 14.3.4(@types/node@26.6.5)(astro@7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(wrangler@4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5))(yaml@2.9.1) + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../../packages/astro-renderer + '@cloudhal/cli': + specifier: workspace:* + version: link:../../packages/cli + '@cloudhal/core-ts': + specifier: workspace:* + version: link:../../packages/core-ts + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.3.3(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + astro: + specifier: 'catalog:' + version: 7.3.6(@types/node@26.6.5)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + better-sqlite3: + specifier: 'catalog:' + version: 13.0.3 + tailwindcss: + specifier: 'catalog:' + version: 4.3.3 + devDependencies: + '@cloudhal/cloudflare': + specifier: workspace:* + version: link:../../packages/cloudflare + wrangler: + specifier: 'catalog:' + version: 4.148.0(@cloudflare/workers-types@5.20261007.1)(@types/node@26.6.5) + packages/agent-runtime: dependencies: '@cloudhal/core-contracts': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5afbf70a..8013d33f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/scripts/font-fallbacks.ts b/scripts/font-fallbacks.ts new file mode 100644 index 00000000..ae302afd --- /dev/null +++ b/scripts/font-fallbacks.ts @@ -0,0 +1,141 @@ +/** + * Writes starters/blog/src/styles/font-fallbacks.css: one metric-matched fallback face per web + * font of the type pairings (RFC-0023 §2). Each is a local system font (Arial, Times New Roman, + * Courier New) scaled and given the web font's ascent, descent and line gap, so the text set in + * it while the web font loads takes the same room, and `font-display: swap` moves almost nothing + * (no layout shift). The technique and the formulas are Next.js's and Fontaine's; the metrics are + * read from our own font files with Capsize. + * + * node scripts/font-fallbacks.ts # rewrite the file + * node scripts/font-fallbacks.ts --check # fail if it is stale (CI) + */ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { fromBuffer } from "@capsizecss/unpack"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const STARTER = path.join(ROOT, "starters/blog"); +const OUT = path.join(STARTER, "src/styles/font-fallbacks.css"); + +interface Metrics { + ascent: number; + descent: number; + lineGap: number; + unitsPerEm: number; + xWidthAvg: number; +} + +/** + * The local fonts the fallbacks stand on. Their metrics are constants (read with Capsize from the + * macOS copies; Windows ships the same fonts), so this script runs anywhere. + */ +const SYSTEM = { + sans: { + local: ["Arial", "ArialMT"], + metrics: { ascent: 1854, descent: -434, lineGap: 67, unitsPerEm: 2048, xWidthAvg: 913 }, + }, + serif: { + local: ["Times New Roman", "TimesNewRomanPSMT"], + metrics: { ascent: 1825, descent: -443, lineGap: 87, unitsPerEm: 2048, xWidthAvg: 832 }, + }, + mono: { + local: ["Courier New", "CourierNewPSMT"], + metrics: { ascent: 1705, descent: -615, lineGap: 0, unitsPerEm: 2048, xWidthAvg: 1229 }, + }, +} as const satisfies Record; + +/** + * The web fonts of the pairings (fonts.css) and the kind of local font each falls back to. + * `widthScale` corrects the average width Capsize reads from a variable font's default instance + * when that is not what text is drawn in: Source Sans 3's default is ExtraLight, Fraunces' has + * other optical-size and softness settings than body text at 16–18px. Measured in Chrome by + * setting a 640px paragraph in the font and in its fallback (line widths within ~1%). + */ +export const WEB_FONTS: readonly { + family: string; + file: string; + kind: keyof typeof SYSTEM; + widthScale?: number; +}[] = [ + { + family: "Fraunces Variable", + file: "fraunces-latin-wght-normal.woff2", + kind: "serif", + widthScale: 0.8945, + }, + { family: "Inter Variable", file: "inter-latin-wght-normal.woff2", kind: "sans" }, + { family: "Space Grotesk Variable", file: "space-grotesk-latin-wght-normal.woff2", kind: "sans" }, + { + family: "Source Sans 3 Variable", + file: "source-sans-3-latin-wght-normal.woff2", + kind: "sans", + widthScale: 1.0352, + }, + { + family: "Source Serif 4 Variable", + file: "source-serif-4-latin-wght-normal.woff2", + kind: "serif", + }, + { + family: "JetBrains Mono Variable", + file: "jetbrains-mono-latin-wght-normal.woff2", + kind: "mono", + }, +]; + +const pct = (n: number) => `${(n * 100).toFixed(2)}%`; + +/** The @font-face for `family`'s fallback: size-adjust and the three overrides (Fontaine's maths). */ +export function fallbackFace( + family: string, + web: Metrics, + opts: { kind: keyof typeof SYSTEM; widthScale?: number }, +): string { + const sys = SYSTEM[opts.kind]; + const sizeAdjust = + ((web.xWidthAvg / web.unitsPerEm) * (opts.widthScale ?? 1)) / + (sys.metrics.xWidthAvg / sys.metrics.unitsPerEm); + const em = web.unitsPerEm * sizeAdjust; + return [ + "@font-face {", + ` font-family: "${family} Fallback";`, + ` src: ${sys.local.map((n) => `local("${n}")`).join(", ")};`, + ` size-adjust: ${pct(sizeAdjust)};`, + ` ascent-override: ${pct(web.ascent / em)};`, + ` descent-override: ${pct(Math.abs(web.descent) / em)};`, + ` line-gap-override: ${pct(web.lineGap / em)};`, + "}", + ].join("\n"); +} + +async function render(): Promise { + const faces: string[] = []; + for (const font of WEB_FONTS) { + const m = await fromBuffer(await readFile(path.join(STARTER, "public/fonts/v1", font.file))); + faces.push(fallbackFace(font.family, m, font)); + } + return [ + "/*", + " * Generated by scripts/font-fallbacks.ts: do not edit. Metric-matched fallback faces for the", + " * pairings' web fonts (tokens.css lists each right after its web font), so the swap to the web", + " * font does not move the text.", + " */", + ...faces, + "", + ].join("\n"); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const css = await render(); + if (process.argv.includes("--check")) { + const current = await readFile(OUT, "utf8").catch(() => ""); + if (current !== css) { + console.error(`${path.relative(ROOT, OUT)} is stale: run node scripts/font-fallbacks.ts`); + process.exit(1); + } + } else { + await writeFile(OUT, css); + console.log(`wrote ${path.relative(ROOT, OUT)}`); + } +} diff --git a/starters/blog/README.md b/starters/blog/README.md index 60f36aa9..95994dfc 100644 --- a/starters/blog/README.md +++ b/starters/blog/README.md @@ -22,7 +22,9 @@ Everything but `.gitignore`'s list. `cloudhal.json` holds the project's settings - `search.json.ts` — the search modal's answers (RFC-0029), where the deployment answers searches (`cloudhal dev`, the Cloudflare Site Worker). `components/SearchDialog.astro` is the modal's markup and words; Cloudhal's `/_cloudhal/search.1.js` wires it, and without it the header form opens `/search`. ## Styles (`src/styles`) -`global.css` (imported by `layouts/Base.astro`) loads Tailwind, `fonts.css` and one file per feature from `styles/features/` (`tokens.css` with `@theme`, `prose.css`, `surfaces.css`, `motion.css`, `chrome.css`, `carousel.css` …) in cascade order. The rules are unlayered and later files override earlier ones, so keep the order; add a feature as a new file and import it from `global.css`. `source("..")` on the Tailwind import is relative to `global.css`, so that import stays there. +`global.css` (imported by `layouts/Base.astro`) loads Tailwind, `fonts.css`, `font-fallbacks.css` and one file per feature from `styles/features/` (`tokens.css` with `@theme`, `prose.css`, `surfaces.css`, `motion.css`, `chrome.css`, `carousel.css` …) in cascade order. The rules are unlayered and later files override earlier ones, so keep the order; add a feature as a new file and import it from `global.css`. `source("..")` on the Tailwind import is relative to `global.css`, so that import stays there. + +Fonts are self-hosted under `public/fonts/v1/` and cached for a year (`public/_headers`): files never change under a path, so a new font set goes in `public/fonts/v2/`. Each web font in the pairing stacks (`tokens.css`) is followed by a metric-matched fallback from `font-fallbacks.css` (a system font scaled to the web font's width, ascent and descent), so the swap when the web font arrives does not move the text. That file is generated: after changing a font, run `pnpm fonts:fallbacks` in the monorepo. ## Theme settings (`src/theme.ts`) - `ARCHIVE_PAGE_SIZE` — posts per archive page (20). diff --git a/starters/blog/public/_headers b/starters/blog/public/_headers new file mode 100644 index 00000000..383d4a88 --- /dev/null +++ b/starters/blog/public/_headers @@ -0,0 +1,5 @@ +# Font files never change under a path: a new font set gets a new directory (/fonts/v2/…). +# Cached for a year, the browser uses them on every reload without asking the server, so the +# text is drawn in its font from the first paint instead of swapping in later. +/fonts/v1/* + Cache-Control: public, max-age=31536000, immutable diff --git a/starters/blog/public/fonts/fraunces-OFL.txt b/starters/blog/public/fonts/v1/fraunces-OFL.txt similarity index 100% rename from starters/blog/public/fonts/fraunces-OFL.txt rename to starters/blog/public/fonts/v1/fraunces-OFL.txt diff --git a/starters/blog/public/fonts/fraunces-latin-ext-wght-normal.woff2 b/starters/blog/public/fonts/v1/fraunces-latin-ext-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/fraunces-latin-ext-wght-normal.woff2 rename to starters/blog/public/fonts/v1/fraunces-latin-ext-wght-normal.woff2 diff --git a/starters/blog/public/fonts/fraunces-latin-wght-normal.woff2 b/starters/blog/public/fonts/v1/fraunces-latin-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/fraunces-latin-wght-normal.woff2 rename to starters/blog/public/fonts/v1/fraunces-latin-wght-normal.woff2 diff --git a/starters/blog/public/fonts/inter-OFL.txt b/starters/blog/public/fonts/v1/inter-OFL.txt similarity index 100% rename from starters/blog/public/fonts/inter-OFL.txt rename to starters/blog/public/fonts/v1/inter-OFL.txt diff --git a/starters/blog/public/fonts/inter-latin-ext-wght-normal.woff2 b/starters/blog/public/fonts/v1/inter-latin-ext-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/inter-latin-ext-wght-normal.woff2 rename to starters/blog/public/fonts/v1/inter-latin-ext-wght-normal.woff2 diff --git a/starters/blog/public/fonts/inter-latin-wght-normal.woff2 b/starters/blog/public/fonts/v1/inter-latin-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/inter-latin-wght-normal.woff2 rename to starters/blog/public/fonts/v1/inter-latin-wght-normal.woff2 diff --git a/starters/blog/public/fonts/jetbrains-mono-OFL.txt b/starters/blog/public/fonts/v1/jetbrains-mono-OFL.txt similarity index 100% rename from starters/blog/public/fonts/jetbrains-mono-OFL.txt rename to starters/blog/public/fonts/v1/jetbrains-mono-OFL.txt diff --git a/starters/blog/public/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 b/starters/blog/public/fonts/v1/jetbrains-mono-latin-ext-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 rename to starters/blog/public/fonts/v1/jetbrains-mono-latin-ext-wght-normal.woff2 diff --git a/starters/blog/public/fonts/jetbrains-mono-latin-wght-normal.woff2 b/starters/blog/public/fonts/v1/jetbrains-mono-latin-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/jetbrains-mono-latin-wght-normal.woff2 rename to starters/blog/public/fonts/v1/jetbrains-mono-latin-wght-normal.woff2 diff --git a/starters/blog/public/fonts/source-sans-3-OFL.txt b/starters/blog/public/fonts/v1/source-sans-3-OFL.txt similarity index 100% rename from starters/blog/public/fonts/source-sans-3-OFL.txt rename to starters/blog/public/fonts/v1/source-sans-3-OFL.txt diff --git a/starters/blog/public/fonts/source-sans-3-latin-ext-wght-normal.woff2 b/starters/blog/public/fonts/v1/source-sans-3-latin-ext-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/source-sans-3-latin-ext-wght-normal.woff2 rename to starters/blog/public/fonts/v1/source-sans-3-latin-ext-wght-normal.woff2 diff --git a/starters/blog/public/fonts/source-sans-3-latin-wght-normal.woff2 b/starters/blog/public/fonts/v1/source-sans-3-latin-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/source-sans-3-latin-wght-normal.woff2 rename to starters/blog/public/fonts/v1/source-sans-3-latin-wght-normal.woff2 diff --git a/starters/blog/public/fonts/source-serif-4-OFL.txt b/starters/blog/public/fonts/v1/source-serif-4-OFL.txt similarity index 100% rename from starters/blog/public/fonts/source-serif-4-OFL.txt rename to starters/blog/public/fonts/v1/source-serif-4-OFL.txt diff --git a/starters/blog/public/fonts/source-serif-4-latin-ext-wght-normal.woff2 b/starters/blog/public/fonts/v1/source-serif-4-latin-ext-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/source-serif-4-latin-ext-wght-normal.woff2 rename to starters/blog/public/fonts/v1/source-serif-4-latin-ext-wght-normal.woff2 diff --git a/starters/blog/public/fonts/source-serif-4-latin-wght-normal.woff2 b/starters/blog/public/fonts/v1/source-serif-4-latin-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/source-serif-4-latin-wght-normal.woff2 rename to starters/blog/public/fonts/v1/source-serif-4-latin-wght-normal.woff2 diff --git a/starters/blog/public/fonts/space-grotesk-OFL.txt b/starters/blog/public/fonts/v1/space-grotesk-OFL.txt similarity index 100% rename from starters/blog/public/fonts/space-grotesk-OFL.txt rename to starters/blog/public/fonts/v1/space-grotesk-OFL.txt diff --git a/starters/blog/public/fonts/space-grotesk-latin-ext-wght-normal.woff2 b/starters/blog/public/fonts/v1/space-grotesk-latin-ext-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/space-grotesk-latin-ext-wght-normal.woff2 rename to starters/blog/public/fonts/v1/space-grotesk-latin-ext-wght-normal.woff2 diff --git a/starters/blog/public/fonts/space-grotesk-latin-wght-normal.woff2 b/starters/blog/public/fonts/v1/space-grotesk-latin-wght-normal.woff2 similarity index 100% rename from starters/blog/public/fonts/space-grotesk-latin-wght-normal.woff2 rename to starters/blog/public/fonts/v1/space-grotesk-latin-wght-normal.woff2 diff --git a/starters/blog/src/fonts.ts b/starters/blog/src/fonts.ts index a75d746d..a48cca92 100644 --- a/starters/blog/src/fonts.ts +++ b/starters/blog/src/fonts.ts @@ -4,12 +4,18 @@ * (Turkish and other accented letters) load on demand through fonts.css's unicode ranges. */ export const PAIRING_PRELOADS: Readonly> = { - editorial: ["/fonts/fraunces-latin-wght-normal.woff2", "/fonts/inter-latin-wght-normal.woff2"], - modern: ["/fonts/space-grotesk-latin-wght-normal.woff2", "/fonts/inter-latin-wght-normal.woff2"], - humanist: ["/fonts/source-sans-3-latin-wght-normal.woff2"], + editorial: [ + "/fonts/v1/fraunces-latin-wght-normal.woff2", + "/fonts/v1/inter-latin-wght-normal.woff2", + ], + modern: [ + "/fonts/v1/space-grotesk-latin-wght-normal.woff2", + "/fonts/v1/inter-latin-wght-normal.woff2", + ], + humanist: ["/fonts/v1/source-sans-3-latin-wght-normal.woff2"], technical: [ - "/fonts/jetbrains-mono-latin-wght-normal.woff2", - "/fonts/inter-latin-wght-normal.woff2", + "/fonts/v1/jetbrains-mono-latin-wght-normal.woff2", + "/fonts/v1/inter-latin-wght-normal.woff2", ], - classic: ["/fonts/source-serif-4-latin-wght-normal.woff2"], + classic: ["/fonts/v1/source-serif-4-latin-wght-normal.woff2"], }; diff --git a/starters/blog/src/styles/features/tokens.css b/starters/blog/src/styles/features/tokens.css index 5eb4c956..7d65a1f9 100644 --- a/starters/blog/src/styles/features/tokens.css +++ b/starters/blog/src/styles/features/tokens.css @@ -55,29 +55,41 @@ font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; } /* - * Font pairings (RFC-0023 §2): the families come from fonts.css and load only when used. They win + * Font pairings (RFC-0023 §2): the families come from fonts.css and load only when used; each is + * followed by its metric-matched fallback (font-fallbacks.css), so the swap does not move the + * text. They win * over the older typeface setting; `system` keeps it. */ [data-ch-type-pairing="editorial"] { - --ch-font-display: "Fraunces Variable", ui-serif, Georgia, serif; - --ch-font-text: "Inter Variable", ui-sans-serif, system-ui, sans-serif; + --ch-font-display: "Fraunces Variable", "Fraunces Variable Fallback", ui-serif, Georgia, serif; + --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif; } [data-ch-type-pairing="modern"] { - --ch-font-display: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif; - --ch-font-text: "Inter Variable", ui-sans-serif, system-ui, sans-serif; + --ch-font-display: + "Space Grotesk Variable", "Space Grotesk Variable Fallback", ui-sans-serif, system-ui, + sans-serif; + --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif; } [data-ch-type-pairing="humanist"] { - --ch-font-display: "Source Sans 3 Variable", ui-sans-serif, system-ui, sans-serif; - --ch-font-text: "Source Sans 3 Variable", ui-sans-serif, system-ui, sans-serif; + --ch-font-display: + "Source Sans 3 Variable", "Source Sans 3 Variable Fallback", ui-sans-serif, system-ui, + sans-serif; + --ch-font-text: + "Source Sans 3 Variable", "Source Sans 3 Variable Fallback", ui-sans-serif, system-ui, + sans-serif; } [data-ch-type-pairing="technical"] { - --ch-font-display: "JetBrains Mono Variable", ui-monospace, monospace; - --ch-font-text: "Inter Variable", ui-sans-serif, system-ui, sans-serif; - --ch-font-code: "JetBrains Mono Variable", ui-monospace, monospace; + --ch-font-display: + "JetBrains Mono Variable", "JetBrains Mono Variable Fallback", ui-monospace, monospace; + --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif; + --ch-font-code: + "JetBrains Mono Variable", "JetBrains Mono Variable Fallback", ui-monospace, monospace; } [data-ch-type-pairing="classic"] { - --ch-font-display: "Source Serif 4 Variable", ui-serif, Georgia, serif; - --ch-font-text: "Source Serif 4 Variable", ui-serif, Georgia, serif; + --ch-font-display: + "Source Serif 4 Variable", "Source Serif 4 Variable Fallback", ui-serif, Georgia, serif; + --ch-font-text: + "Source Serif 4 Variable", "Source Serif 4 Variable Fallback", ui-serif, Georgia, serif; } [data-ch-type-pairing]:not([data-ch-type-pairing="system"]) .ch-body { font-family: var(--ch-font-text); diff --git a/starters/blog/src/styles/font-fallbacks.css b/starters/blog/src/styles/font-fallbacks.css new file mode 100644 index 00000000..495c7e8a --- /dev/null +++ b/starters/blog/src/styles/font-fallbacks.css @@ -0,0 +1,53 @@ +/* + * Generated by scripts/font-fallbacks.ts: do not edit. Metric-matched fallback faces for the + * pairings' web fonts (tokens.css lists each right after its web font), so the swap to the web + * font does not move the text. + */ +@font-face { + font-family: "Fraunces Variable Fallback"; + src: local("Times New Roman"), local("TimesNewRomanPSMT"); + size-adjust: 113.62%; + ascent-override: 86.08%; + descent-override: 22.44%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Inter Variable Fallback"; + src: local("Arial"), local("ArialMT"); + size-adjust: 107.12%; + ascent-override: 90.44%; + descent-override: 22.52%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Space Grotesk Variable Fallback"; + src: local("Arial"), local("ArialMT"); + size-adjust: 109.47%; + ascent-override: 89.89%; + descent-override: 26.67%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Source Sans 3 Variable Fallback"; + src: local("Arial"), local("ArialMT"); + size-adjust: 92.42%; + ascent-override: 110.80%; + descent-override: 43.28%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Source Serif 4 Variable Fallback"; + src: local("Times New Roman"), local("TimesNewRomanPSMT"); + size-adjust: 117.91%; + ascent-override: 87.87%; + descent-override: 28.41%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "JetBrains Mono Variable Fallback"; + src: local("Courier New"), local("CourierNewPSMT"); + size-adjust: 99.98%; + ascent-override: 102.02%; + descent-override: 30.00%; + line-gap-override: 0.00%; +} diff --git a/starters/blog/src/styles/fonts.css b/starters/blog/src/styles/fonts.css index fea61f85..3317914b 100644 --- a/starters/blog/src/styles/fonts.css +++ b/starters/blog/src/styles/fonts.css @@ -8,7 +8,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(/fonts/fraunces-latin-ext-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/fraunces-latin-ext-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -17,7 +17,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(/fonts/fraunces-latin-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/fraunces-latin-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @@ -26,7 +26,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(/fonts/inter-latin-ext-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/inter-latin-ext-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -35,7 +35,7 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(/fonts/inter-latin-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/inter-latin-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @@ -44,7 +44,7 @@ font-style: normal; font-display: swap; font-weight: 100 800; - src: url(/fonts/jetbrains-mono-latin-ext-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/jetbrains-mono-latin-ext-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -53,7 +53,7 @@ font-style: normal; font-display: swap; font-weight: 100 800; - src: url(/fonts/jetbrains-mono-latin-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/jetbrains-mono-latin-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @@ -62,7 +62,7 @@ font-style: normal; font-display: swap; font-weight: 200 900; - src: url(/fonts/source-sans-3-latin-ext-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/source-sans-3-latin-ext-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -71,7 +71,7 @@ font-style: normal; font-display: swap; font-weight: 200 900; - src: url(/fonts/source-sans-3-latin-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/source-sans-3-latin-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @@ -80,7 +80,7 @@ font-style: normal; font-display: swap; font-weight: 200 900; - src: url(/fonts/source-serif-4-latin-ext-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/source-serif-4-latin-ext-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -89,7 +89,7 @@ font-style: normal; font-display: swap; font-weight: 200 900; - src: url(/fonts/source-serif-4-latin-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/source-serif-4-latin-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @@ -98,7 +98,7 @@ font-style: normal; font-display: swap; font-weight: 300 700; - src: url(/fonts/space-grotesk-latin-ext-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/space-grotesk-latin-ext-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -107,7 +107,7 @@ font-style: normal; font-display: swap; font-weight: 300 700; - src: url(/fonts/space-grotesk-latin-wght-normal.woff2) format("woff2-variations"); + src: url(/fonts/v1/space-grotesk-latin-wght-normal.woff2) format("woff2-variations"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } diff --git a/starters/blog/src/styles/global.css b/starters/blog/src/styles/global.css index 74e6152f..7717714a 100644 --- a/starters/blog/src/styles/global.css +++ b/starters/blog/src/styles/global.css @@ -1,6 +1,7 @@ /* Scan `src/` only: automatic detection may walk up the file system (docs/design/starter-references.md §6). */ @import "tailwindcss" source(".."); @import "./fonts.css"; +@import "./font-fallbacks.css"; /* * One file per feature (docs/engineering/code-structure.md §8), in cascade order. Unlayered on