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