Self-host the three web fonts and drop the render-blocking Google Fonts CSS

This commit is contained in:
Claude
2026-09-30 06:47:04 +00:00
parent 1001b85f40
commit 663a33573a
13 changed files with 101 additions and 8 deletions

View File

@@ -0,0 +1,137 @@
---
id: 003-self-host-fonts-89466b
title: Self-host the three web fonts and drop the render-blocking Google Fonts CSS
created: 2026-09-29
depends_on: [002-compress-and-etag-shell-bd459c]
est_files: 5
---
# 003 — Self-host the three web fonts
## Objective
`frontend/index.html` loads a render-blocking stylesheet from `fonts.googleapis.com`
(3 families, 10 weights), which costs two extra origins (DNS+TLS each) before first
paint and only works offline through the SW's `ytplayer-fonts` runtime cache. After
this plan the fonts are files under `frontend/fonts/`, declared in
`frontend/fonts/fonts.css`, precached with the shell, and the display face is
preloaded. Visual result must be identical.
## Context the executor must NOT rediscover
`frontend/index.html:20-26` today:
```html
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700;12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
```
CSS tokens (`frontend/styles.css:27-29`) reference the family names
`"Bricolage Grotesque"`, `"Hanken Grotesk"`, `"JetBrains Mono"` — keep those names.
`frontend/sw.js:55-71` — the `SHELL` array (precache list). Every new shell file must be
listed there or it will be missing offline. `frontend/sw.js:165` keeps a runtime rule for
the Google hosts — leave it (harmless, and old clients may still request them).
Google serves one variable `woff2` per family per subset when asked with a modern UA.
`fitLyricLines` already re-fits on `document.fonts` load (CLAUDE.md), so swap is safe.
## Steps
1. Create `scripts/fetch-fonts.js` (Node ≥18, no deps):
```js
// Downloads the app's Google fonts once (latin + latin-ext subsets) into
// frontend/fonts/ and writes frontend/fonts/fonts.css pointing at them.
// Re-run only when the font list changes.
const fs = require('node:fs');
const path = require('node:path');
const CSS_URL = 'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600..800&family=Hanken+Grotesk:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
const OUT = path.join(__dirname, '..', 'frontend', 'fonts');
(async () => {
fs.mkdirSync(OUT, { recursive: true });
const css = await (await fetch(CSS_URL, { headers: { 'User-Agent': UA } })).text();
// parts[k] ends with the "/* <subset> */" comment of the block in parts[k+1].
const parts = css.split('@font-face');
let out = '/* Generated by scripts/fetch-fonts.js — do not edit by hand. */\n';
let n = 0;
for (let k = 1; k < parts.length; k++) {
const subset = (parts[k - 1].match(/\/\*\s*([\w-]+)\s*\*\/\s*$/) || [])[1];
const b = parts[k].slice(0, parts[k].indexOf('}') + 1); // just this block
if (subset !== 'latin' && subset !== 'latin-ext') continue;
const url = (b.match(/url\((https:[^)]+\.woff2)\)/) || [])[1];
const fam = (b.match(/font-family:\s*'([^']+)'/) || [])[1];
if (!url || !fam) continue;
const file = `${fam.replace(/\s+/g, '')}-${subset}.woff2`;
const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
fs.writeFileSync(path.join(OUT, file), buf);
out += '@font-face' + b.replace(url, file).trimEnd() + '\n';
n++;
}
fs.writeFileSync(path.join(OUT, 'fonts.css'), out);
console.log(`wrote ${n} font files + fonts.css`);
})().catch((e) => { console.error(e); process.exit(1); });
```
Run it: `node scripts/fetch-fonts.js`. Expect `wrote 6 font files + fonts.css`
(3 families × 2 subsets). If the network is blocked, STOP and report — do not hand-write fonts.
2. Open `frontend/fonts/fonts.css` and confirm every block has `font-display: swap;`; if a
block lacks it, add it.
3. `frontend/index.html` — replace the 5 lines from `<link rel="preconnect" href="https://fonts.googleapis.com" />`
through the closing `/>` of the Google stylesheet link with:
```html
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="fonts/fonts.css" />
```
(Use the exact filenames the script produced — check with `ls frontend/fonts`.)
4. `frontend/sw.js` `SHELL` array — after `'/styles.css',` add `'/fonts/fonts.css',` and one
`'/fonts/<file>.woff2',` line per generated woff2 file.
5. `frontend/index.html` line 16 — the Content-Security-Policy `<meta>` has
`font-src https://fonts.gstatic.com data:` which would BLOCK self-hosted fonts. Change that part to
`font-src 'self' https://fonts.gstatic.com data:` (leave the rest of the policy unchanged;
`style-src` already allows `'self'`). `frontend/admin.html` has no Google Fonts link — leave it alone.
6. `package.json` scripts — add `"fetch-fonts": "node scripts/fetch-fonts.js"`.
## Out of scope / do NOT touch
- Do not rename the font families or edit `styles.css`.
- Do not remove the fonts rule in `sw.js` fetch handler or `UTILITY_CACHES`.
- Do not subset further (e.g. glyph-level subsetting) — out of scope.
## Verification
```bash
cd /home/user/ytplayer && ls -la frontend/fonts
grep -c 'href="https://fonts.googleapis' frontend/index.html
grep -c "font-src 'self'" frontend/index.html
for f in $(ls frontend/fonts/*.woff2); do grep -c "/fonts/$(basename $f)" frontend/sw.js; done
node --test frontend/*.test.js 2>&1 | tail -3
cd server && [ -e public ] || ln -s ../frontend public; PORT=3998 bun server.js >/tmp/ytp003.log 2>&1 & SRV=$!; sleep 4
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:3998/fonts/fonts.css
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3998/fonts/$(ls ../frontend/fonts | grep woff2 | head -1)
kill $SRV; true
```
Expected: 6 `.woff2` + `fonts.css`; google link grep `0`; CSP grep `1`; each sw.js grep `1`; tests `fail 0`;
`200 text/css…` and `200`.
## Report format (executor: follow exactly)
Output ONLY the following, no other prose:
1. `git diff --stat` plus unified diff of text files (not the woff2 binaries).
2. Raw output of the Verification commands.
3. `Findings:` — max 10 lines.
Do not commit. Do not push. Do not touch files outside the Steps.
## Execution log
- Executor: in-session Agent (haiku). Attempts: 1. Fix rounds: 0.
- Orchestrator re-ran Verification: 6 woff2 + fonts.css present, Google Fonts link count 0, CSP `font-src 'self'` 1, each woff2 precached in sw.js once, 52 frontend tests pass, `/fonts/fonts.css` 200 text/css and woff2 200 font/woff2 from a real server.
- Executor Findings (verbatim): Plan executed successfully. All 6 font files generated (3 families x 2 subsets). CSP updated to allow 'self' fonts. SW.js updated to precache all fonts. Google Fonts links completely removed. Server responds correctly to font requests. Unit tests pass.