Files
ytplayer/plans/done/003-self-host-fonts-89466b.md

138 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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.