fix(webapp): ship the web fonts, and fail the build when a url() dangles (#891) #892

Merged
claude-bot merged 2 commits from fix/891-web-fonts into main 2026-09-27 10:24:47 +00:00
Collaborator

Closes #891.

The production frontend image has shipped no web fonts since lane 0. Every .woff2 request got nginx's SPA fallback, i.e. index.html with a 200. So the app, dev and prod included, rendered in the browser's fallback sans.

Root cause

The problem is how Vite 8 and @tailwindcss/postcss 4.3 interact:

  1. The fontsource sheets were @imported from src/ui/index.css, inside Tailwind's pipeline.
  2. Vite's postcss-import inlined them before Tailwind ran.
  3. Tailwind rebuilds the whole tree and stamps every node as coming from src/index.css.
  4. Vite's url rewriter then resolved ./files/*.woff2 against src/, found nothing, and left the urls dangling.

I confirmed this with a probe plugin: node origins change from node_modules/@fontsource-variable/... to src/index.css after Tailwind, and removing Tailwind emits the fonts.

Fix

  • src/ui/fonts.js imports the three fontsource sheets from JS, in main.jsx before index.css. Each becomes its own CSS module outside Tailwind, so Vite resolves the urls properly. All 21 font files are emitted with hashed names.
  • A second bug the guard found: one subset (1.8 KB) was under assetsInlineLimit, so it was inlined as data:font/woff2, which font-src 'self' blocks. vite.config.js now never inlines fonts, and other assets keep the default.
  • The guard, scripts/check-dist-urls.mjs, runs as part of npm run build, and so in CI and the Dockerfile. Every url() in dist/**/*.css must resolve to a file in dist/. Remote URLs and inlined fonts fail.
    • On main it fails with 21 dangling references.
    • With only the import fix, it fails on the inlined font.
    • With the full fix, it passes.
  • nginx: location /assets/ { try_files $uri =404; }, so a missing asset is a real 404, not index.html. It adds no add_header, so the security headers are still inherited.

Verified in a real frontend-prod image

  • A font URL returns 200 with font/woff2 and a wOF2 body.
  • /assets/nope.woff2 returns 404.
  • /campaigns/abc/wiki still returns index.html.
  • The CSP header is byte-for-byte unchanged.

Checks

  • eslint: 0 errors.
  • vitest: timeouts under local machine load on two full runs, all in files unrelated to this change. Those files pass on their own (111/111), and CI will confirm the full run.
  • Changelog: a changelog.d/ fragment.

Follow-ups, not changed here:

  • The service worker now precaches all 21 font files (~484 KB). They could be trimmed to latin-only.
  • /assets/ could get immutable cache headers.

🤖 Generated with Claude Code

Closes #891. **The production frontend image has shipped no web fonts since lane 0.** Every `.woff2` request got nginx's SPA fallback, i.e. `index.html` with a 200. So the app, **dev and prod included**, rendered in the browser's fallback sans. ## Root cause The problem is how Vite 8 and `@tailwindcss/postcss` 4.3 interact: 1. The fontsource sheets were `@import`ed from `src/ui/index.css`, inside Tailwind's pipeline. 2. Vite's postcss-import inlined them *before* Tailwind ran. 3. Tailwind rebuilds the whole tree and stamps every node as coming from `src/index.css`. 4. Vite's url rewriter then resolved `./files/*.woff2` against `src/`, found nothing, and left the urls dangling. I confirmed this with a probe plugin: node origins change from `node_modules/@fontsource-variable/...` to `src/index.css` after Tailwind, and removing Tailwind emits the fonts. ## Fix - **`src/ui/fonts.js`** imports the three fontsource sheets from JS, in `main.jsx` before `index.css`. Each becomes its own CSS module outside Tailwind, so Vite resolves the urls properly. All 21 font files are emitted with hashed names. - **A second bug the guard found:** one subset (1.8 KB) was under `assetsInlineLimit`, so it was inlined as `data:font/woff2`, which `font-src 'self'` blocks. `vite.config.js` now never inlines fonts, and other assets keep the default. - **The guard, `scripts/check-dist-urls.mjs`,** runs as part of `npm run build`, and so in CI and the Dockerfile. Every `url()` in `dist/**/*.css` must resolve to a file in `dist/`. Remote URLs and inlined fonts fail. - On main it fails with 21 dangling references. - With only the import fix, it fails on the inlined font. - With the full fix, it passes. - **nginx:** `location /assets/ { try_files $uri =404; }`, so a missing asset is a real 404, not `index.html`. It adds no `add_header`, so the security headers are still inherited. ## Verified in a real frontend-prod image - A font URL returns 200 with `font/woff2` and a `wOF2` body. - `/assets/nope.woff2` returns 404. - `/campaigns/abc/wiki` still returns index.html. - **The CSP header is byte-for-byte unchanged.** ## Checks - **eslint:** 0 errors. - **vitest:** timeouts under local machine load on two full runs, all in files unrelated to this change. Those files pass on their own (111/111), and CI will confirm the full run. - **Changelog:** a `changelog.d/` fragment. **Follow-ups, not changed here:** - The service worker now precaches all 21 font files (~484 KB). They could be trimmed to latin-only. - `/assets/` could get immutable cache headers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(webapp): ship the web fonts, and fail the build when a url() dangles (#891)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m18s
CI / Backend lint (ruff) (pull_request) Successful in 1m7s
CI / Bot/backend version sync (pull_request) Successful in 25s
CI / Feedback site tests (pull_request) Successful in 1m32s
CI / Bot tests and audit (pull_request) Successful in 3m59s
CI / Does this change touch the recording path? (pull_request) Successful in 31s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Docker image build (pull_request) Successful in 4m5s
CI / Frontend tests, audit, and build (pull_request) Failing after 20m50s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Has been cancelled
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
beb0162e76
No frontend-prod build has shipped a font file since lane 0 moved to
@fontsource. The faces were @imported from src/ui/index.css. Vite's
postcss-import inlines them before the user PostCSS plugins, keeping each
rule's source file, but @tailwindcss/postcss rebuilds the tree and stamps
every node as coming from src/index.css. Vite's url rewriter, which runs
last, then resolved fontsource's url(./files/*.woff2) against src/, found
nothing, warned, and left the reference as written. nginx answered each
font request with index.html (200 text/html), and the app rendered in the
fallback sans-serif.

- Import the faces from JS (src/ui/fonts.js, from main.jsx) so each
  fontsource stylesheet is its own CSS module Tailwind leaves alone; Vite
  now emits all 21 files with hashed names.
- build.assetsInlineLimit never inlines a font: Unbounded's cyrillic-ext
  subset (1.8 KB) was otherwise a data: URI, which font-src 'self' blocks.
- scripts/check-dist-urls.mjs, run by `npm run build` (so both CI and the
  frontend-builder stage), fails on any url() in dist CSS that is not a
  file in dist/, is remote, or inlines a font.
- nginx: /assets/ is try_files $uri =404, so a missing asset is a real
  404 rather than the SPA fallback. Client routes still get index.html;
  the CSP and other server headers are inherited unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge main into fix/891-web-fonts
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m4s
CI / Backend lint (ruff) (pull_request) Successful in 30s
CI / Bot tests and audit (pull_request) Successful in 2m3s
CI / Feedback site tests (pull_request) Successful in 42s
CI / Bot/backend version sync (pull_request) Successful in 28s
CI / Does this change touch the recording path? (pull_request) Successful in 17s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Docker image build (pull_request) Successful in 57s
CI / Frontend tests, audit, and build (pull_request) Successful in 21m15s
CI / Backend migration, tests, and audit (pull_request) Successful in 45m31s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 41m25s
025297646d
Sign in to join this conversation.
No description provided.