From 2432ffbe9bfa112dc087d1cd3e3ce4de902e85f6 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 20 Aug 2026 15:50:18 +1000 Subject: [PATCH 1/4] fix: make built stylesheet asset URLs relative so static builds resolve them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remix rewrites every `url()` in a bundled stylesheet to `${publicPath}_assets/` — absolute, because `publicPath` is also how it loads JS chunks and so cannot itself be relative. Under `myst start` that resolves, since `template/server.js` mounts `public/build` at exactly that path. A static `myst build --html` has no such route, and mystmd's asset rewriter only touches `.html`, `.js` and `.json`, never `.css` — so the path inside the stylesheet kept pointing at a directory the output does not contain. The effect: the KaTeX stylesheet self-hosted in #125 loads in a static build, and all 60 of its font references 404. Maths then renders with system fallback glyphs — the degradation that change set out to prevent, now on every statically built site rather than only where jsdelivr is blocked. Verified against a real `myst build --html` of the visual fixture: the `` is rewritten to `/build/_assets/…` and loads, no `myst_assets_folder/` directory exists in the output, and the fonts sit in `build/_assets/` unreferenced. The stylesheets and the files they reference are emitted into the same `_assets/` directory in both layouts, so a reference relative to the stylesheet resolves in either — and under a `baseurl` too, where the absolute path was equally wrong and would have broken the per-PR preview deployments. Adds a post-build step that rewrites those references and then checks every rewritten target exists beside its stylesheet, so a wrong assumption fails the build rather than shipping silent 404s. On the same fixture the static build now resolves 60 of 60. Only the KaTeX stylesheet is affected today; `app.css` and `thebe-core.css` emit no `url()` references at all. Closes #138 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 ++++++ package.json | 2 +- scripts/relative-css-asset-urls.mjs | 75 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 scripts/relative-css-asset-urls.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d7a16da..26527cc7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unchanged. Note that nested cells now participate in **Run all**, so solution and exercise cells execute along with the rest of the page. ([#117](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/117)) +- Self-hosted stylesheet assets no longer 404 in static builds. Remix rewrites + every `url()` in a bundled stylesheet to an absolute `/myst_assets_folder/…` + path, which resolves under `myst start` — the theme's own server mounts + `public/build` there — but not in `myst build --html` output, where the assets + land under `build/_assets/` and mystmd's rewriter only fixes up `.html`, `.js` + and `.json`, never `.css`. So the KaTeX stylesheet self-hosted in + [#125](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/125) loaded + while all 60 of its font references failed, and maths fell back to system + glyphs on every statically built site — the degradation that change set out to + prevent. The build now rewrites those references to be relative to the + stylesheet, which resolves identically under `myst start`, in a static build, + and under a `baseurl` (where the absolute path was also wrong, affecting the + per-PR preview deployments). Every rewritten target is checked to exist beside + its stylesheet, so a wrong assumption fails the build instead of shipping + silent 404s ([#138](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/138)). ## [2.3.0] - 2026-08-20 diff --git a/package.json b/package.json index db59b7598..6ffaf457b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "clean": "rimraf public/build build api", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "build:thebe": "copy-thebe-assets ./public", - "prod:build": "npm run prod:copy && npm run build:thebe && npm run build:css && remix build", + "prod:build": "npm run prod:copy && npm run build:thebe && npm run build:css && remix build && node scripts/relative-css-asset-urls.mjs", "dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css", "dev": "npm run dev:copy && npm run build:thebe && concurrently \"npm run dev:css\" \"remix dev\"", "start": "npm run build:css && remix dev", diff --git a/scripts/relative-css-asset-urls.mjs b/scripts/relative-css-asset-urls.mjs new file mode 100644 index 000000000..6da3b543a --- /dev/null +++ b/scripts/relative-css-asset-urls.mjs @@ -0,0 +1,75 @@ +/** + * Rewrite absolute asset URLs in the built stylesheets to be relative to the + * stylesheet itself (#138). + * + * Remix rewrites every `url()` in a bundled stylesheet to + * `${publicPath}_assets/` — an absolute path, because `publicPath` is + * also how it loads JS chunks and so cannot itself be relative. Under + * `myst start` that resolves: `template/server.js` mounts `public/build` at + * exactly that path. A static `myst build --html` has no such route, and + * mystmd's asset rewriter only touches `.html`, `.js` and `.json` — never + * `.css` — so the path inside the stylesheet keeps pointing at a directory the + * output does not contain, and every font 404s. + * + * The stylesheets and the files they reference are emitted into the same + * `_assets/` directory in both layouts, so a reference relative to the + * stylesheet resolves in all of them: + * + * myst start /myst_assets_folder/_assets/x.css -> /myst_assets_folder/_assets/font.woff2 + * static build /build/_assets/x.css -> /build/_assets/font.woff2 + * under baseurl /build/_assets/x.css -> /build/_assets/font.woff2 + * + * The last of those is a bug fixed in passing: an absolute `/myst_assets_folder` + * ignores `baseurl` and breaks on project-scoped deployments such as the + * per-PR GitHub Pages previews. + * + * Every rewritten target is checked to exist on disk, so a wrong assumption + * here fails the build rather than shipping silent 404s. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { publicPath = '/' } = require('../remix.config.prod.js'); + +const assetsDir = path.resolve('public/build/_assets'); +const prefix = `${publicPath.endsWith('/') ? publicPath : `${publicPath}/`}_assets/`; +// url( optional-quote PREFIX file optional-quote ) +const URL_RE = new RegExp(`url\\((\\s*['"]?)${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'); + +if (!fs.existsSync(assetsDir)) { + console.error(`[css-assets] ${assetsDir} not found — run this after \`remix build\`.`); + process.exit(1); +} + +let rewritten = 0; +let filesTouched = 0; +const missing = []; + +for (const name of fs.readdirSync(assetsDir).filter((f) => f.endsWith('.css'))) { + const file = path.join(assetsDir, name); + const before = fs.readFileSync(file, 'utf8'); + const after = before.replace(URL_RE, 'url($1./'); + if (after === before) continue; + + for (const [, target] of after.matchAll(/url\(\s*['"]?\.\/([^)'"]+)['"]?\s*\)/g)) { + const resolved = path.join(assetsDir, target.split(/[?#]/)[0]); + if (!fs.existsSync(resolved)) missing.push(`${name} -> ${target}`); + } + + rewritten += before.split(prefix).length - 1; + filesTouched += 1; + fs.writeFileSync(file, after); +} + +if (missing.length) { + console.error( + `[css-assets] ${missing.length} reference(s) do not exist beside their stylesheet:\n ${missing.join('\n ')}` + ); + process.exit(1); +} + +console.log( + `[css-assets] rewrote ${rewritten} asset URL(s) in ${filesTouched} stylesheet(s) to be stylesheet-relative` +); From 49a2e69b692d062eddbfa5a30ec29cc0e1d0d13c Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 20 Aug 2026 16:18:42 +1000 Subject: [PATCH 2/4] fix: validate all asset references before writing any stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two points from Copilot's review of #139. The script wrote each rewritten stylesheet as it went and only failed afterwards, so a missing target left the build output half-corrected — confusing to debug, and worse to inherit if a later step ever ran despite the non-zero exit. Rewrites are now buffered and applied only once every reference has been checked; the failure path says "nothing written". The assets directory was also hardcoded as public/build/_assets while remix.config.prod.js already declares assetsBuildDirectory. Both that and publicPath now come from the config, so the script cannot drift out of step with where the build actually puts things. Verified by seeding a reference to a file that does not exist: the script reports it, exits 1, and the stylesheet is left exactly as it was. Co-Authored-By: Claude Fable 5 --- scripts/relative-css-asset-urls.mjs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/relative-css-asset-urls.mjs b/scripts/relative-css-asset-urls.mjs index 6da3b543a..0886263ae 100644 --- a/scripts/relative-css-asset-urls.mjs +++ b/scripts/relative-css-asset-urls.mjs @@ -31,9 +31,11 @@ import path from 'node:path'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const { publicPath = '/' } = require('../remix.config.prod.js'); +// Both read from the Remix config rather than hardcoded, so this cannot drift +// out of step with where the build actually puts things. +const { publicPath = '/', assetsBuildDirectory = 'public/build' } = require('../remix.config.prod.js'); -const assetsDir = path.resolve('public/build/_assets'); +const assetsDir = path.resolve(assetsBuildDirectory, '_assets'); const prefix = `${publicPath.endsWith('/') ? publicPath : `${publicPath}/`}_assets/`; // url( optional-quote PREFIX file optional-quote ) const URL_RE = new RegExp(`url\\((\\s*['"]?)${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'); @@ -44,8 +46,11 @@ if (!fs.existsSync(assetsDir)) { } let rewritten = 0; -let filesTouched = 0; const missing = []; +// Validate everything before writing anything: a partial rewrite would leave +// the build output in a half-corrected state that is confusing to debug and +// worse to inherit if a later step ever runs despite the failure. +const pending = []; for (const name of fs.readdirSync(assetsDir).filter((f) => f.endsWith('.css'))) { const file = path.join(assetsDir, name); @@ -59,17 +64,19 @@ for (const name of fs.readdirSync(assetsDir).filter((f) => f.endsWith('.css'))) } rewritten += before.split(prefix).length - 1; - filesTouched += 1; - fs.writeFileSync(file, after); + pending.push([file, after]); } if (missing.length) { console.error( - `[css-assets] ${missing.length} reference(s) do not exist beside their stylesheet:\n ${missing.join('\n ')}` + `[css-assets] ${missing.length} reference(s) do not exist beside their stylesheet:\n ${missing.join('\n ')}\n[css-assets] nothing written.` ); process.exit(1); } +for (const [file, contents] of pending) fs.writeFileSync(file, contents); +const filesTouched = pending.length; + console.log( `[css-assets] rewrote ${rewritten} asset URL(s) in ${filesTouched} stylesheet(s) to be stylesheet-relative` ); From 7ce693a17572afd13d930725981331092cb4367e Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 20 Aug 2026 15:54:47 +1000 Subject: [PATCH 3/4] perf: self-host the Source Sans 3 webfont MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `styles/app.css` opened with a CSS `@import` to fonts.googleapis.com. Google Fonts is blocked in mainland China, a significant share of the QuantEcon readership, and unlike the CDN stylesheets dropped in 2.3.0 this one is the body font on every page rather than the maths on some of them — when it fails, the lectures render in the system sans throughout. It was also the worst possible shape for a critical-path request. An `@import` is discovered only once app.css has downloaded and parsed, so the browser cannot preload it, and the chain ran app.css -> Google's CSS -> gstatic woff2 across two extra origins before any text could paint in the intended face. The font now ships from `@fontsource-variable/source-sans-3`, imported from `app/links.ts` — the module #125 introduced for KaTeX — so esbuild rewrites the `url()`s and emits the 14 woff2 files alongside every other bundled asset. It cannot be `@import`ed from `styles/app.css` instead: Tailwind does not rebase `url()` inside an imported stylesheet, so the paths would resolve against the Tailwind output file and 404. The links are declared on the root route rather than the two page routes like `KatexCSS`, because root's are the only ones that also apply when the root ErrorBoundary renders — the body font has to be right on a 404 too. The package declares the family as "Source Sans 3 Variable", so tailwind.config.js and the inlined CRITICAL_CSS name it that way as well, keeping plain "Source Sans 3" next in the stack for a locally installed copy. The FOUC guard is tightened to match the head of the stack rather than a substring, since "Source Sans 3 Variable" contains "Source Sans 3" and the looser regex would have kept passing if those two declarations ever drifted apart. Verified end to end on a real `myst build --html`: no absolute asset URLs remain in any stylesheet, all 74 references resolve, and the 14 woff2 files are present in the output. Rendered text is unchanged — the full visual suite passes against untouched baselines. Closes #131 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 18 ++++++++++++++++++ app/links.ts | 38 ++++++++++++++++++++++++++++++++++++++ app/root.tsx | 15 ++++++++++++++- package-lock.json | 12 +++++++++++- package.json | 1 + styles/app.css | 4 +++- tailwind.config.js | 7 ++++++- tests/visual/fouc.spec.ts | 9 ++++++++- 8 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26527cc7b..026842ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it is asserted against the DOM ([#121](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/121)). +### Changed +- Source Sans 3 is now self-hosted instead of `@import`ed from + `fonts.googleapis.com`. Google Fonts is blocked in mainland China, a + significant share of the QuantEcon readership, and unlike the CDN stylesheets + removed in 2.3.0 this one is the *body* font on every page rather than the + maths on some of them. It also had the worst possible shape for a + critical-path request: a CSS `@import`, discovered only after `app.css` had + downloaded and parsed, so the browser could not preload it and the chain ran + `app.css` → Google's CSS → `fonts.gstatic.com` woff2 across two extra origins. + The font now ships from `@fontsource-variable/source-sans-3` through the same + Remix import route the KaTeX CSS uses, so its 14 `.woff2` files are served + from the site's own origin. The family is declared as `Source Sans 3 + Variable`, so `tailwind.config.js` and the inlined critical CSS in + `app/root.tsx` name it that way too, with plain `Source Sans 3` kept next in + the stack for a locally installed copy. Rendered text is unchanged — the + visual suite passes against untouched baselines + ([#131](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/131)). + ### Fixed - Code cells nested inside a directive (`{exercise}`, `{solution}`, `{note}`, …) are now registered with the kernel, so their run button works and their diff --git a/app/links.ts b/app/links.ts index 3a47004ce..018f43220 100644 --- a/app/links.ts +++ b/app/links.ts @@ -1,5 +1,10 @@ import type { HtmlLinkDescriptor } from '@remix-run/react'; import katexCss from 'katex/dist/katex.min.css'; +// Explicit `.css` subpaths, not the bare specifier: the package ships its own +// `index.d.css.ts` (`export {}`), which would win over Remix's +// `declare module "*.css"` and fail `npm run compile`. +import sourceSans3Css from '@fontsource-variable/source-sans-3/index.css'; +import sourceSans3ItalicCss from '@fontsource-variable/source-sans-3/wght-italic.css'; /** * Self-hosted KaTeX stylesheet. @@ -30,3 +35,36 @@ export const KatexCSS: HtmlLinkDescriptor = { rel: 'stylesheet', href: katexCss, }; + +/** + * Self-hosted Source Sans 3 (variable), replacing the + * `@import url('https://fonts.googleapis.com/css2?family=Source+Sans+3…')` that + * used to open `styles/app.css`. + * + * Same two reasons as KaTeX above, both sharper here. Google Fonts is blocked + * in mainland China, and this is the *body* font on every page rather than the + * maths on some of them. And an `@import` is the worst shape a critical-path + * request can have: it is discovered only once `app.css` has downloaded and + * parsed, so it cannot be preloaded, and the chain ran app.css → Google's CSS → + * gstatic woff2 across two extra origins. + * + * The import lives here rather than in `styles/app.css` because Tailwind does + * not rebase `url()` inside an `@import`ed stylesheet — the font paths would be + * emitted relative to the Tailwind *output* file and 404. Imported from a + * module, Remix's esbuild pass rewrites them and emits the woff2 files into + * `public/build/_assets/`, the same route KaTeX's fonts take. (Those emitted + * URLs are then made stylesheet-relative by `scripts/relative-css-asset-urls.mjs`, + * without which they resolve only under `myst start` and not in static builds.) + * + * Two stylesheets, not one: the package splits upright from italic, and lecture + * prose uses both. Without the italic faces the browser synthesises an oblique + * from the upright, which measures wider and shifts the layout. + * + * The family is declared as `Source Sans 3 Variable`, which is why + * `tailwind.config.js` and the inlined `CRITICAL_CSS` in `app/root.tsx` name it + * that way too — those three have to stay in step. + */ +export const SourceSans3CSS: HtmlLinkDescriptor[] = [ + { rel: 'stylesheet', href: sourceSans3Css }, + { rel: 'stylesheet', href: sourceSans3ItalicCss }, +]; diff --git a/app/root.tsx b/app/root.tsx index 18261ad8d..4e4b4aaf7 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,6 +1,7 @@ import type { LinksFunction, V2_MetaFunction, LoaderFunction } from '@remix-run/node'; import tailwind from '~/styles/app.css'; import thebeCoreCss from 'thebe-core/dist/lib/thebe-core.css'; +import { SourceSans3CSS } from '~/links'; import { getConfig } from '~/backend/loaders.server'; import type { SiteLoader } from '@myst-theme/common'; import { @@ -62,6 +63,12 @@ export const meta: V2_MetaFunction = ({ data }) => { * * Keep the values in sync with their sources of truth: * - font stack: tailwind.config.js -> theme.extend.fontFamily.sans + * The `@font-face` rules for "Source Sans 3 Variable" are + * self-hosted via app/links.ts, so they arrive in a + * and are NOT available at this first paint. The + * `sans-serif` tail is what renders here and the webfont + * swaps in once that stylesheet lands — as it did with the + * Google Fonts @import this replaced. * - grid columns: tailwind.config.js -> theme.extend.gridTemplateColumns * (`simple-sm` / `simple-xl`), applied by `.simple-center-grid` * - dark bg: matches the page , which @myst-theme/site renders as @@ -81,7 +88,7 @@ export const meta: V2_MetaFunction = ({ data }) => { * the panel does not push the article down while it waits. */ const CRITICAL_CSS = ` -:where(html){font-family:"Source Sans 3",sans-serif} +:where(html){font-family:"Source Sans 3 Variable","Source Sans 3",sans-serif} :where(body){margin:0;background-color:#fff} :where(.dark body){background-color:#1c1917} :where([hidden],.hidden){display:none} @@ -97,6 +104,12 @@ export const links: LinksFunction = () => { rel: 'icon', href: '/favicon.ico', }, + // Self-hosted Source Sans 3 (see app/links.ts). Declared on the *root* + // route rather than the two page routes like KatexCSS, because root's + // links() are the only ones that also apply when the root ErrorBoundary + // renders — a 404, or the missing-site response thrown below — and the body + // font has to be right on those pages too. + ...SourceSans3CSS, { rel: 'stylesheet', href: tailwind }, { rel: 'stylesheet', href: thebeCoreCss }, { rel: 'stylesheet', href: '/myst-theme.css' }, diff --git a/package-lock.json b/package-lock.json index e46dfd0a4..8470a6ff5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.3.0", "hasInstallScript": true, "dependencies": { + "@fontsource-variable/source-sans-3": "^5.3.0", "@myst-theme/common": "^1.3.0", "@myst-theme/icons": "^1.3.0", "@myst-theme/jupyter": "^1.3.0", @@ -46,7 +47,7 @@ "@vercel/node": "^2.15.1", "concurrently": "^9.1.2", "patch-package": "^8.0.0", - "prettier": "*", + "prettier": "latest", "tailwindcss": "^3.4.17", "typescript": "~5.9.0" }, @@ -2895,6 +2896,15 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@fontsource-variable/source-sans-3": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/source-sans-3/-/source-sans-3-5.3.0.tgz", + "integrity": "sha512-dpi0GZk7EQe2tYpg6Q0Fx0OmUgnuGu+0rHPgtXqtKhglYmJuP7jZ12Mu1uN+NfRaJRY1Emn8AQJEWzmoZ4wa9g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fortawesome/fontawesome-free": { "version": "5.15.4", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", diff --git a/package.json b/package.json index 6ffaf457b..3a102de24 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:fouc": "playwright test --project=webkit-fouc" }, "dependencies": { + "@fontsource-variable/source-sans-3": "^5.3.0", "@myst-theme/common": "^1.3.0", "@myst-theme/icons": "^1.3.0", "@myst-theme/jupyter": "^1.3.0", diff --git a/styles/app.css b/styles/app.css index 23921c15f..7267002fe 100644 --- a/styles/app.css +++ b/styles/app.css @@ -1,4 +1,6 @@ -@import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap'); +/* Source Sans 3 is self-hosted and imported from app/links.ts, not @import-ed + here: Tailwind does not rebase url() inside an @import-ed stylesheet, so the + font paths would resolve relative to this file's *output* and 404. */ @import '@myst-theme/styles'; @import './lists.css'; @import './mpl-widget.css'; diff --git a/tailwind.config.js b/tailwind.config.js index 3ad99a63c..b48d96e0e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -66,7 +66,12 @@ module.exports = { 'qeborder-blue': 'rgb(0 114 188)', }, fontFamily: { - sans: ['"Source Sans 3"', 'sans-serif'], + // "Source Sans 3 Variable" is the family name declared by + // @fontsource-variable/source-sans-3, self-hosted via app/links.ts. + // Plain "Source Sans 3" comes next so a locally installed copy is used + // while the webfont swaps in, or if it fails to load. Must stay in step + // with CRITICAL_CSS in app/root.tsx. + sans: ['"Source Sans 3 Variable"', '"Source Sans 3"', 'sans-serif'], }, keyframes: { slideDownAndFade: { diff --git a/tests/visual/fouc.spec.ts b/tests/visual/fouc.spec.ts index 5e50a620a..14ebaf42e 100644 --- a/tests/visual/fouc.spec.ts +++ b/tests/visual/fouc.spec.ts @@ -108,7 +108,14 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain expect(state.appliedExternal).toBe(false); // The reported FOUC symptoms must be absent on first paint: expect(state.gridDisplay).toBe("grid"); // grid not collapsed to block - expect(state.bodyFont).toMatch(/Source Sans 3/); // sans, not the serif default + // Head of the stack, not just a substring: "Source Sans 3 Variable" (the + // family name of the self-hosted webfont) contains "Source Sans 3", so the + // looser regex would keep passing if CRITICAL_CSS and tailwind.config.js + // drifted apart. This reads the *declared* stack — the @font-face rules + // live in a , which this test aborts, so what actually paints is the + // `sans-serif` tail. That is the point: the guard is about sans-vs-serif, + // not about the webfont having arrived. + expect(state.bodyFont).toMatch(/Source Sans 3 Variable/); expect( state.sidebarRight, "`.qe-contents-sidebar` not found — the hook the critical CSS targets was renamed or removed" From 17ea92a67e6fe0863cd87c4ddff2369c5998e864 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 20 Aug 2026 16:19:59 +1000 Subject: [PATCH 4/4] test: anchor the FOUC font assertion to the head of the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the assertion checked the head of the declared stack, but an unanchored regex matches anywhere in it — so the guard would still pass with "Source Sans 3 Variable" demoted behind another family, which is the drift it exists to catch. Raised by Copilot on #140. Co-Authored-By: Claude Fable 5 --- tests/visual/fouc.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/visual/fouc.spec.ts b/tests/visual/fouc.spec.ts index 14ebaf42e..e999da775 100644 --- a/tests/visual/fouc.spec.ts +++ b/tests/visual/fouc.spec.ts @@ -115,7 +115,7 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain // live in a , which this test aborts, so what actually paints is the // `sans-serif` tail. That is the point: the guard is about sans-vs-serif, // not about the webfont having arrived. - expect(state.bodyFont).toMatch(/Source Sans 3 Variable/); + expect(state.bodyFont).toMatch(/^["']?Source Sans 3 Variable["']?\s*,/); expect( state.sidebarRight, "`.qe-contents-sidebar` not found — the hook the critical CSS targets was renamed or removed"