7.1 KiB
id, title, created, depends_on, est_files
| id | title | created | depends_on | est_files | |
|---|---|---|---|---|---|
| 003-self-host-fonts-89466b | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | 2026-09-29 |
|
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:
<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
- Create
scripts/fetch-fonts.js(Node ≥18, no deps):Run it:// 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); });node scripts/fetch-fonts.js. Expectwrote 6 font files + fonts.css(3 families × 2 subsets). If the network is blocked, STOP and report — do not hand-write fonts. - Open
frontend/fonts/fonts.cssand confirm every block hasfont-display: swap;; if a block lacks it, add it. 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:(Use the exact filenames the script produced — check with<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" />ls frontend/fonts.)frontend/sw.jsSHELLarray — after'/styles.css',add'/fonts/fonts.css',and one'/fonts/<file>.woff2',line per generated woff2 file.frontend/index.htmlline 16 — the Content-Security-Policy<meta>hasfont-src https://fonts.gstatic.com data:which would BLOCK self-hosted fonts. Change that part tofont-src 'self' https://fonts.gstatic.com data:(leave the rest of the policy unchanged;style-srcalready allows'self').frontend/admin.htmlhas no Google Fonts link — leave it alone.package.jsonscripts — 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.jsfetch handler orUTILITY_CACHES. - Do not subset further (e.g. glyph-level subsetting) — out of scope.
Verification
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:
git diff --statplus unified diff of text files (not the woff2 binaries).- Raw output of the Verification commands.
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.css200 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.