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

7.1 KiB
Raw Permalink Blame History

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
002-compress-and-etag-shell-bd459c
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

  1. Create scripts/fetch-fonts.js (Node ≥18, no deps):
    // 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:
     <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

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.