diff --git a/frontend/fonts/BricolageGrotesque-latin-ext.woff2 b/frontend/fonts/BricolageGrotesque-latin-ext.woff2
new file mode 100644
index 0000000..6e8caa8
Binary files /dev/null and b/frontend/fonts/BricolageGrotesque-latin-ext.woff2 differ
diff --git a/frontend/fonts/BricolageGrotesque-latin.woff2 b/frontend/fonts/BricolageGrotesque-latin.woff2
new file mode 100644
index 0000000..fcc4eb1
Binary files /dev/null and b/frontend/fonts/BricolageGrotesque-latin.woff2 differ
diff --git a/frontend/fonts/HankenGrotesk-latin-ext.woff2 b/frontend/fonts/HankenGrotesk-latin-ext.woff2
new file mode 100644
index 0000000..1dc0e89
Binary files /dev/null and b/frontend/fonts/HankenGrotesk-latin-ext.woff2 differ
diff --git a/frontend/fonts/HankenGrotesk-latin.woff2 b/frontend/fonts/HankenGrotesk-latin.woff2
new file mode 100644
index 0000000..518e2c4
Binary files /dev/null and b/frontend/fonts/HankenGrotesk-latin.woff2 differ
diff --git a/frontend/fonts/JetBrainsMono-latin-ext.woff2 b/frontend/fonts/JetBrainsMono-latin-ext.woff2
new file mode 100644
index 0000000..82f9668
Binary files /dev/null and b/frontend/fonts/JetBrainsMono-latin-ext.woff2 differ
diff --git a/frontend/fonts/JetBrainsMono-latin.woff2 b/frontend/fonts/JetBrainsMono-latin.woff2
new file mode 100644
index 0000000..4d09cda
Binary files /dev/null and b/frontend/fonts/JetBrainsMono-latin.woff2 differ
diff --git a/frontend/fonts/fonts.css b/frontend/fonts/fonts.css
new file mode 100644
index 0000000..b56a2c5
--- /dev/null
+++ b/frontend/fonts/fonts.css
@@ -0,0 +1,51 @@
+/* Generated by scripts/fetch-fonts.js — do not edit by hand. */
+@font-face {
+ font-family: 'Bricolage Grotesque';
+ font-style: normal;
+ font-weight: 600 800;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(BricolageGrotesque-latin-ext.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Bricolage Grotesque';
+ font-style: normal;
+ font-weight: 600 800;
+ font-stretch: 100%;
+ font-display: swap;
+ src: url(BricolageGrotesque-latin.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: 'Hanken Grotesk';
+ font-style: normal;
+ font-weight: 400 700;
+ font-display: swap;
+ src: url(HankenGrotesk-latin-ext.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: 'Hanken Grotesk';
+ font-style: normal;
+ font-weight: 400 700;
+ font-display: swap;
+ src: url(HankenGrotesk-latin.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400 700;
+ font-display: swap;
+ src: url(JetBrainsMono-latin-ext.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 400 700;
+ font-display: swap;
+ src: url(JetBrainsMono-latin.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/frontend/index.html b/frontend/index.html
index 90eef71..aba8703 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -13,16 +13,13 @@
YT Player
-
-
-
+
+
+
diff --git a/frontend/sw.js b/frontend/sw.js
index 684f754..bf34b05 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -68,6 +68,13 @@ const SHELL = [
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png',
+ '/fonts/fonts.css',
+ '/fonts/BricolageGrotesque-latin-ext.woff2',
+ '/fonts/BricolageGrotesque-latin.woff2',
+ '/fonts/HankenGrotesk-latin-ext.woff2',
+ '/fonts/HankenGrotesk-latin.woff2',
+ '/fonts/JetBrainsMono-latin-ext.woff2',
+ '/fonts/JetBrainsMono-latin.woff2',
];
// ---- Install: pre-cache the app shell ----
diff --git a/package.json b/package.json
index 5992785..07e0c6a 100755
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"update-ytdlp": "node scripts/setup-ytdlp.js --force",
"make-icon": "node scripts/make-icon.js",
"make-pwa-icons": "node scripts/make-pwa-icons.js",
+ "fetch-fonts": "node scripts/fetch-fonts.js",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
diff --git a/plans/INDEX.md b/plans/INDEX.md
index 43ed99c..d260751 100644
--- a/plans/INDEX.md
+++ b/plans/INDEX.md
@@ -10,7 +10,7 @@ green, app boots with no JS errors, P2P on by default, offline boot works).
|-----|----|-------|--------|--------|-------|
| 001 | 001-perf-timing-marks-105acc | Add startup, search and play timing marks plus yt-dlp duration logs | done | Add startup, search and play timing marks plus yt-dlp duration logs | baseline numbers for the rest |
| 002 | 002-compress-and-etag-shell-bd459c | Serve the app shell gzip/brotli-compressed with ETags | done | Serve the app shell gzip/brotli-compressed with ETags | app.js 426 KB → 94 KB (br) |
-| 003 | 003-self-host-fonts-89466b | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | in-progress | | needs network once; CSP font-src fix |
+| 003 | 003-self-host-fonts-89466b | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | done | Self-host the three web fonts and drop the render-blocking Google Fonts CSS | needs network once; CSP font-src fix |
| 004 | 004-coalesce-stream-resolves-a92d40 | Coalesce concurrent resolveStreams calls for the same video | queued | | |
| 005 | 005-warm-streams-on-intent-47b3d3 | Warm the stream cache for likely next plays | queued | | cold play 7.5 s → cached 1.2 s |
| 006 | 006-innertube-search-48066b | Answer searches from YouTube InnerTube directly with yt-dlp fallback | queued | | search 4–5 s → ~0.8 s |
diff --git a/plans/active/003-self-host-fonts-89466b.md b/plans/done/003-self-host-fonts-89466b.md
similarity index 91%
rename from plans/active/003-self-host-fonts-89466b.md
rename to plans/done/003-self-host-fonts-89466b.md
index 1346fe7..486fba2 100644
--- a/plans/active/003-self-host-fonts-89466b.md
+++ b/plans/done/003-self-host-fonts-89466b.md
@@ -129,3 +129,9 @@ Output ONLY the following, no other prose:
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.
diff --git a/scripts/fetch-fonts.js b/scripts/fetch-fonts.js
new file mode 100644
index 0000000..9c76b5e
--- /dev/null
+++ b/scripts/fetch-fonts.js
@@ -0,0 +1,31 @@
+// 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); });