--- 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 ``` 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 "/* */" 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 `` through the closing `/>` of the Google stylesheet link with: ```html ``` (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/.woff2',` line per generated woff2 file. 5. `frontend/index.html` line 16 — the Content-Security-Policy `` 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.