From 151be60b03a40172605c2511393fa059968eb28b Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Tue, 30 Jun 2026 23:27:34 +0530 Subject: [PATCH] feat(monospace): print-preview iframe uses bundled monospace font Inlines @font-face as base64 data URI so the iframe srcdoc can render JetBrains Mono / Fira Code without depending on the parent window's loaded @font-face sets. Reads family + ligature state from the renderer-wide cache populated by applyMonospaceClasses(). Amit Haridas --- .../2026-06-30-monospace-font-embedding.md | 1617 +++++++++++++++++ src/print-preview.js | 65 +- src/renderer.js | 20 +- 3 files changed, 1698 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-30-monospace-font-embedding.md diff --git a/docs/superpowers/plans/2026-06-30-monospace-font-embedding.md b/docs/superpowers/plans/2026-06-30-monospace-font-embedding.md new file mode 100644 index 0000000..e6f3a5d --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-monospace-font-embedding.md @@ -0,0 +1,1617 @@ +# Monospace Font Embedding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Guarantee ASCII alignment in MarkdownConverter's preview **and** every supported export format (PDF, DOCX, HTML, EPUB, LaTeX, ODT, RTF) by bundling JetBrains Mono + Fira Code TTFs inside the app and embedding them in every output. No OS font dependency, no internet required. + +**Architecture:** `MonospaceFontConfig` is the single source of truth for the active monospace family + weight. It maps to bundled TTF paths (dev vs packaged/asar.unpacked). `PdfFontHeader`, `DocxFontEmbedder`, `EpubFontEmbedder`, and `ExportCss` are the four per-format adapters. Body-class toggles in the renderer flip a CSS custom property consumed by `styles-modern.css` and friends. + +**Tech Stack:** Electron (main + renderer + preload), CommonJS, vanilla JS. `jszip ^3.10.1` (already a dep) for DOCX/EPUB surgery. `fontspec` via xelatex for PDF. No new NPM dependencies. + +**Spec:** `docs/superpowers/specs/2026-06-30-monospace-font-embedding-design.md` + +--- + +## File Structure + +**New files (8 + 3 font assets):** + +``` +src/main/MonospaceFontConfig.js # active family → TTF path resolver +src/main/PdfFontHeader.js # builds xelatex fontspec header +src/main/DocxFontEmbedder.js # embeds TTF into pandoc DOCX output +src/main/EpubFontEmbedder.js # wraps pandoc --epub-embed-font + manifest patch +src/main/ExportCss.js # @font-face with base64-woff2, self-contained +src/main/settings/monospaceSettings.js # shared read/default of monospace settings +tests/monospace-font-config.test.js +tests/pdf-font-header.test.js +tests/docx-font-embedder.test.js +tests/epub-font-embedder.test.js +tests/export-css.test.js +tests/monospace-settings.test.js +assets/fonts/FiraCode-Regular.ttf # new asset +assets/fonts/FiraCode-Bold.ttf # new asset +assets/fonts/FiraCode-LICENSE.txt # new asset +``` + +**Modified files:** + +``` +src/fonts.css # add Fira Code @font-face +src/styles/tokens.css # add --font-mono-active + --font-mono-feature tokens +src/styles-concreteinfo.css # class-driven token values +src/styles-modern.css # .editor-textarea, .preview-content code/pre use new tokens +src/ascii-generator.html # swap Google Fonts CDN for local fonts.css +src/print-preview.js # embed ExportCss in iframe srcdoc +src/main.js # 5 export pipelines + asar config + pandoc version flag detection +src/renderer.js # body-class toggle on settings change +src/preload.js # expose monospaceSettings IPC +scripts/download-tools.js # add Fira Code downloader +package.json # build.asarUnpack + Fira font files +``` + +**Convention note:** All new modules are CommonJS (`require` / `module.exports`). Tests are in `tests/`, follow the existing pattern (require the module directly, no electron mocks for these). + +--- + +## Phase A — Settings + path resolver + +### Task 1: Settings schema with safe defaults + +**Files:** +- Create: `src/main/settings/monospaceSettings.js` +- Test: `tests/monospace-settings.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/monospace-settings.test.js +const { getDefaults, getActiveMonoFont, isLigaturesEnabled } = require('../src/main/settings/monospaceSettings'); + +describe('monospaceSettings', () => { + test('getDefaults returns sane defaults', () => { + const d = getDefaults(); + expect(d.monospaceFont).toBe('jetbrains-mono'); + expect(d.monospaceLigatures).toBe(false); + }); + + test('getActiveMonoFont returns the active family', () => { + expect(getActiveMonoFont({ monospaceFont: 'fira-code' })).toBe('Fira Code'); + expect(getActiveMonoFont({})).toBe('JetBrains Mono'); + expect(getActiveMonoFont({ monospaceFont: 'bogus' })).toBe('JetBrains Mono'); + }); + + test('isLigaturesEnabled reads boolean strictly', () => { + expect(isLigaturesEnabled({ monospaceLigatures: true })).toBe(true); + expect(isLigaturesEnabled({ monospaceLigatures: false })).toBe(false); + expect(isLigaturesEnabled({})).toBe(false); + expect(isLigaturesEnabled({ monospaceLigatures: 'yes' })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `npm test -- tests/monospace-settings.test.js` +Expected: FAIL — `Cannot find module '../src/main/settings/monospaceSettings'` + +- [ ] **Step 3: Implement** + +```js +// src/main/settings/monospaceSettings.js +'use strict'; + +const FAMILY_BY_KEY = { + 'jetbrains-mono': 'JetBrains Mono', + 'fira-code': 'Fira Code', +}; + +function getDefaults() { + return Object.freeze({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }); +} + +function getActiveMonoFont(settings) { + const key = settings && settings.monospaceFont; + return FAMILY_BY_KEY[key] || 'JetBrains Mono'; +} + +function isLigaturesEnabled(settings) { + return Boolean(settings && settings.monospaceLigatures === true); +} + +module.exports = { getDefaults, getActiveMonoFont, isLigaturesEnabled, FAMILY_BY_KEY }; +``` + +- [ ] **Step 4: Run the test, verify it passes** + +Run: `npm test -- tests/monospace-settings.test.js` +Expected: PASS — 3 tests + +- [ ] **Step 5: Commit** + +```bash +git add src/main/settings/monospaceSettings.js tests/monospace-settings.test.js +git commit -m "feat(monospace): add settings schema + safe defaults + +getDefaults(), getActiveMonoFont(), isLigaturesEnabled() with TDD." +``` + +--- + +### Task 2: `MonospaceFontConfig` resolves dev + packaged TTF paths + +**Files:** +- Create: `src/main/MonospaceFontConfig.js` +- Test: `tests/monospace-font-config.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/monospace-font-config.test.js +jest.mock('electron', () => ({ + app: { getPath: () => '/fake/userData' }, +})); +jest.mock('fs', () => ({ existsSync: jest.fn(), statSync: jest.fn() })); + +const path = require('path'); +const fs = require('fs'); +const MonospaceFontConfig = require('../src/main/MonospaceFontConfig'); + +describe('MonospaceFontConfig', () => { + afterEach(() => { jest.clearAllMocks(); }); + + test('returns dev repo path when no packaged layout exists', () => { + fs.existsSync.mockReturnValue(false); + const p = MonospaceFontConfig.getMonoFontTtfPath('jetbrains-mono', 400); + expect(p).toMatch(/assets\/fonts\/JetBrainsMono-Regular\.ttf$/); + }); + + test('returns packaged asar.unpacked path when present and file exists', () => { + fs.existsSync.mockImplementation((p) => p.includes('app.asar.unpacked') && p.endsWith('FiraCode-Regular.ttf')); + const p = MonospaceFontConfig.getMonoFontTtfPath('fira-code', 400); + expect(p).toContain('app.asar.unpacked'); + expect(p).toContain('FiraCode-Regular.ttf'); + }); + + test('returns null and warns when file is missing', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + fs.existsSync.mockReturnValue(false); + const p = MonospaceFontConfig.getMonoFontTtfPath('fira-code', 700); + expect(p).toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/FiraCode-Bold\.ttf/)); + warn.mockRestore(); + }); + + test('ligaturesEnabled maps from settings', () => { + expect(MonospaceFontConfig.ligaturesEnabled({ monospaceLigatures: true })).toBe(true); + expect(MonospaceFontConfig.ligaturesEnabled({ monospaceLigatures: false })).toBe(false); + expect(MonospaceFontConfig.ligaturesEnabled({})).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `npm test -- tests/monospace-font-config.test.js` +Expected: FAIL — `Cannot find module` + +- [ ] **Step 3: Implement** + +```js +// src/main/MonospaceFontConfig.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { app } = require('electron'); +const { getActiveMonoFont, isLigaturesEnabled, FAMILY_BY_KEY } = require('./settings/monospaceSettings'); + +const WEIGHT_BY_KEY = { 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' }; + +function getAppRoot() { + // __dirname in packaged builds = .../app.asar/src/main; we need the app dir. + // process.resourcesPath points to the unpacked root on all platforms. + if (process.resourcesPath && fs.existsSync(path.join(process.resourcesPath, 'app.asar.unpacked'))) { + return process.resourcesPath; + } + // Dev: walk up from src/main to repo root. + return path.resolve(__dirname, '..', '..'); +} + +function getCandidatePaths(family, weight) { + const familyDir = family === 'Fira Code' ? 'FiraCode' : 'JetBrainsMono'; + const weightName = WEIGHT_BY_KEY[weight] || 'Regular'; + const filename = `${familyDir}-${weightName}.ttf`; + + const candidates = []; + // 1. Repo dev path (used in `npm start`) + candidates.push(path.resolve(getAppRoot(), 'assets', 'fonts', filename)); + // 2. Packaged asar.unpacked + const packagedRoot = process.resourcesPath || getAppRoot(); + candidates.push(path.join(packagedRoot, 'app.asar.unpacked', 'assets', 'fonts', filename)); + return candidates; +} + +function getMonoFontTtfPath(familyKey, weight = 400) { + const family = FAMILY_BY_KEY[familyKey] || 'JetBrains Mono'; + for (const p of getCandidatePaths(family, weight)) { + if (fs.existsSync(p)) return p; + } + console.warn(`[MonospaceFontConfig] bundled font missing for ${family} weight ${weight}; falling back to system`); + return null; +} + +function ligaturesEnabled(settings) { return isLigaturesEnabled(settings); } + +function getActiveFamily(settings) { return getActiveMonoFont(settings); } + +module.exports = { getMonoFontTtfPath, ligaturesEnabled, getActiveFamily }; +``` + +- [ ] **Step 4: Run the test, verify it passes** + +Run: `npm test -- tests/monospace-font-config.test.js` +Expected: PASS — 4 tests + +- [ ] **Step 5: Commit** + +```bash +git add src/main/MonospaceFontConfig.js tests/monospace-font-config.test.js +git commit -m "feat(monospace): add MonospaceFontConfig path resolver + +Resolves dev vs packaged (asar.unpacked) TTF paths. Logs warn, returns null +when bundled font is missing." +``` + +--- + +## Phase B — Bundled fonts + +### Task 3: Add Fira Code TTF + LICENSE assets + +**Files:** +- Create: `assets/fonts/FiraCode-Regular.ttf` +- Create: `assets/fonts/FiraCode-Bold.ttf` +- Create: `assets/fonts/FiraCode-LICENSE.txt` + +- [ ] **Step 1: Confirm JetBrains Mono assets are tracked** + +Run: `git ls-files assets/fonts/JetBrainsMono-*` — expect at minimum Regular, Bold, LICENSE listed. If `JetBrainsMono-Regular.ttf` and `JetBrainsMono-Bold.ttf` are still untracked (from prior session), `git add` them now. + +- [ ] **Step 2: Download Fira Code TTFs + license from upstream release** + +Run: `node scripts/download-tools.js --font fira-code` +Expected: writes `assets/fonts/FiraCode-Regular.ttf`, `FiraCode-Bold.ttf`, `FiraCode-LICENSE.txt`. + +If the helper script doesn't accept `--font fira-code` yet, do this temporarily: + +```bash +curl -L -o assets/fonts/FiraCode-Regular.ttf \ + https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Regular.ttf +curl -L -o assets/fonts/FiraCode-Bold.ttf \ + https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Bold.ttf +curl -L -o assets/fonts/FiraCode-LICENSE.txt \ + https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE +``` + +- [ ] **Step 3: Verify file sizes are reasonable** + +Run: `ls -la assets/fonts/Fira*` — each TTF should be roughly 100–500 KB. + +- [ ] **Step 4: Commit** + +```bash +git add assets/fonts/FiraCode-Regular.ttf assets/fonts/FiraCode-Bold.ttf assets/fonts/FiraCode-LICENSE.txt assets/fonts/JetBrainsMono-Regular.ttf assets/fonts/JetBrainsMono-Bold.ttf assets/fonts/JetBrainsMono-LICENSE.txt +git commit -m "feat(monospace): bundle JetBrainsMono + FiraCode TTF assets + +Both families are SIL OFL. TTF (not just woff2) is required so xelatex can +embed into PDF and jszip can inject into DOCX." +``` + +--- + +### Task 4: Extend `scripts/download-tools.js` with Fira Code support + +**Files:** +- Modify: `scripts/download-tools.js` + +- [ ] **Step 1: Read existing tool and identify the pandoc download pattern** + +Look for how `download-pandoc` is structured (URL, version pin, mkdir, writeFile). + +- [ ] **Step 2: Add Fira Code section** + +Add a new exported function near the existing tools: + +```js +async function downloadFiraCode() { + const base = 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf'; + const targets = [ + { url: `${base}/FiraCode-Regular.ttf`, out: 'FiraCode-Regular.ttf' }, + { url: `${base}/FiraCode-Bold.ttf`, out: 'FiraCode-Bold.ttf' }, + ]; + fs.mkdirSync('assets/fonts', { recursive: true }); + for (const t of targets) { + await downloadTo(t.url, path.join('assets/fonts', t.out)); + } + await downloadTo( + 'https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE', + path.join('assets/fonts', 'FiraCode-LICENSE.txt') + ); +} +``` + +- [ ] **Step 3: Wire into the existing CLI dispatcher** + +If the script uses `if (cmd === '...')` blocks, add `else if (cmd === 'fira-code' || cmd === '--font fira-code') downloadFiraCode();`. If it auto-runs every helper, no extra wiring needed. + +- [ ] **Step 4: Run it as a smoke test** + +Run: `node scripts/download-tools.js fira-code` (or whatever your dispatch is) — verify no errors; rerun Task 3's `ls` to confirm files exist. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/download-tools.js +git commit -m "chore(monospace): extend download-tools with Fira Code downloader + +Matches the existing version-pinned approach for Pandoc." +``` + +--- + +## Phase C — CSS layer + +### Task 5: Add Fira Code `@font-face` in `src/fonts.css` + +**Files:** +- Modify: `src/fonts.css` (append after the existing JetBrains Mono block) + +- [ ] **Step 1: Add the new `@font-face` rules** + +Append at the end of `src/fonts.css`: + +```css +/* Fira Code Font Family — bundled with the app */ +@font-face { + font-family: 'Fira Code'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../assets/fonts/FiraCode-Regular.woff2') format('woff2'), + url('../assets/fonts/FiraCode-Regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'Fira Code'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../assets/fonts/FiraCode-Bold.woff2') format('woff2'), + url('../assets/fonts/FiraCode-Bold.ttf') format('truetype'); +} +``` + +(`FiraCode-Regular.woff2` and `FiraCode-Bold.woff2` are optional. Chromium will pick whichever loads first; without them Chromium falls back to TTF. Skip the woff2 line if those files don't exist.) + +- [ ] **Step 2: Visual smoke test in `npm start`** + +Run: `npm start`. In the editor, set font-family on a paragraph to `'Fira Code', monospace` via DevTools; verify the active font shows the Fira signature slightly taller x-height. Revert the DevTools change. + +- [ ] **Step 3: Commit** + +```bash +git add src/fonts.css +git commit -m "feat(monospace): register Fira Code @font-face in renderer + +Two weights: 400 (Regular) and 700 (Bold). Falls back to TTF if woff2 +isn't bundled." +``` + +--- + +### Task 6: CSS tokens for active family + ligatures + +**Files:** +- Modify: `src/styles/tokens.css` +- Modify: `src/styles-concreteinfo.css` + +- [ ] **Step 1: Read existing tokens and find the `--font-mono` declaration** + +```bash +grep -n "font-mono" src/styles/tokens.css src/styles-concreteinfo.css +``` + +Note the line where `--font-mono` is defined. + +- [ ] **Step 2: Add the two new tokens** + +In `src/styles/tokens.css`, add right after `--font-mono`: + +```css + --font-mono-active: 'JetBrains Mono', monospace; + --font-mono-feature: 'liga' 0, 'calt' 0, 'dlig' 0; +``` + +- [ ] **Step 3: Add body-class overrides in `src/styles-concreteinfo.css`** + +Append: + +```css +body.mono-fira { --font-mono-active: 'Fira Code', monospace; } +body.mono-fira.mono-ligatures-on { --font-mono-feature: normal; } +body.mono-jetbrains { --font-mono-active: 'JetBrains Mono', monospace; } +body.mono-ligatures-on { --font-mono-feature: normal; } +``` + +(The `.mono-jetbrains` family rule is the default; the explicit declaration is for clarity and to make Lighthouse detect both branches.) + +- [ ] **Step 4: Update the existing `--font-mono` declaration to use the new token** + +Find the line that says `--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;` and replace with: + +```css + --font-mono: var(--font-mono-active); +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/styles/tokens.css src/styles-concreteinfo.css +git commit -m "feat(monospace): add --font-mono-active / --font-mono-feature tokens + +Body classes (.mono-fira, .mono-ligatures-on) flip the tokens for live +switching without re-rendering." +``` + +--- + +### Task 7: `styles-modern.css` honours the new tokens + +**Files:** +- Modify: `src/styles-modern.css` + +- [ ] **Step 1: Replace hard-coded font strings with the token** + +At each occurrence of a literal `'JetBrains Mono'` or `'JetBrains Mono', 'Fira Code', monospace`, replace with `var(--font-mono-active)`. Specifically: + +```diff +- font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace; ++ font-family: var(--font-mono-active); +``` + +Apply to: + +- `.editor-textarea` +- `.codemirror-container .cm-editor` +- `.preview-content code` +- `.preview-content pre` +- `.source-code` / any pre/code block found in `styles-modern.css` + +- [ ] **Step 2: Add ligature control to pre/code/textarea** + +Append a new rule at the end of the file: + +```css +.editor-textarea, +.preview-content code, +.preview-content pre, +.codemirror-container .cm-editor { + font-feature-settings: var(--font-mono-feature); +} +``` + +- [ ] **Step 3: Manual visual check** + +`npm start`. In DevTools, add `body.mono-fira` to `` — confirm family flips; add `body.mono-ligatures-on` — confirm ligatures enable. Revert. + +- [ ] **Step 4: Commit** + +```bash +git add src/styles-modern.css +git commit -m "feat(monospace): wire preview + editor to --font-mono-active token" +``` + +--- + +### Task 8: Renderer toggles body classes on settings change + +**Files:** +- Modify: `src/renderer.js` + +- [ ] **Step 1: Locate the existing settings-changed handler** + +```bash +grep -n "settings-changed\|on('settings\|applyTheme\|currentSettings\|loadSettings" src/renderer.js | head +``` + +Pick the function that already applies user preferences on init / change. (Examples: `applyThemeSettings`, `applyUserSettings`, or wherever header/footer settings are wired.) + +- [ ] **Step 2: Implement the body-class flip** + +Inside the settings-applied function, add: + +```js +function applyMonospaceClasses(settings) { + const body = document.body; + body.classList.toggle('mono-jetbrains', settings.monospaceFont !== 'fira-code'); + body.classList.toggle('mono-fira', settings.monospaceFont === 'fira-code'); + body.classList.toggle('mono-ligatures-on', settings.monospaceLigatures === true); + body.classList.toggle('mono-ligatures-off', settings.monospaceLigatures !== true); +} +``` + +Call `applyMonospaceClasses(currentSettings)` on initial load (wherever other current-settings are applied). Call it again inside the existing settings-changed listener. + +- [ ] **Step 3: Request user contribution (learning mode)** + +**Your turn:** between initial-load application and the settings-changed listener, the exact wiring depends on the codebase's existing structure. Open `src/renderer.js`, find both call sites, and place the two calls to `applyMonospaceClasses(...)`. Trade-off: tightest coupling is right next to the other settings reads, loosest is a dedicated effect block — pick whichever matches the surrounding code's idiom. + +- [ ] **Step 4: Manual smoke test** + +`npm start`. Open Settings (if it exists in the UI yet) — otherwise toggle the class via DevTools to confirm CSS responds. Revert when done. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer.js +git commit -m "feat(monospace): renderer toggles body classes on settings change" +``` + +--- + +## Phase D — Helpers for non-CSS surfaces + +### Task 9: ASCII Generator window uses local fonts + +**Files:** +- Modify: `src/ascii-generator.html` + +- [ ] **Step 1: Replace the Google Fonts `` with local `fonts.css`** + +In `src/ascii-generator.html`, find the `` near line 8 and replace with: + +```html + +``` + +`fonts.css` already wires `@font-face` for both JetBrains Mono and Fira Code (after Task 5). + +- [ ] **Step 2: Ensure the window body has the right default class** + +Around the `` tag, add the default class: + +```html + +``` + +(If the file is opened by the renderer with explicit settings, the opener script can override via `document.body.classList.add/remove` — that wiring lives in the export-opening code path; consult `src/main.js` around `show-ascii-generator` for the open path.) + +- [ ] **Step 3: Ensure CSS fallback is local** + +Find the `.preview-content` rule (around line 142 of the file). Confirm it sets `font-family: 'JetBrains Mono', monospace;` or update it to use `var(--font-mono-active)`. If you reference the token, ensure the file links `tokens.css` or inlines the variable definition. + +Quick fix if you want the simplest behaviour: + +```css +.preview-content { + font-family: 'JetBrains Mono', monospace; + font-feature-settings: 'liga' 0, 'calt' 0, 'dlig' 0; +} +``` + +- [ ] **Step 4: Verify ascii-generator still works** + +Run: `npm start`. Open the ASCII generator from the menu (or `Ctrl+Alt+A` if there's a binding). Confirm preview text uses JetBrains Mono and aligns correctly. + +- [ ] **Step 5: Commit** + +```bash +git add src/ascii-generator.html +git commit -m "fix(ascii): replace Google Fonts CDN with local fonts.css + +ASCII generator now renders in bundled JetBrains Mono without internet, +matching the preview pane." +``` + +--- + +### Task 10: `ExportCss` — self-contained CSS with embedded woff2 + +**Files:** +- Create: `src/main/ExportCss.js` +- Test: `tests/export-css.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/export-css.test.js +const fs = require('fs'); +const ExportCss = require('../src/main/ExportCss'); + +describe('ExportCss.build', () => { + const fakeFontPath = require('path').join(__dirname, 'fixtures', 'fake.woff2'); + const fixture = Buffer.from('woff2-binary-fake-data'); + + beforeAll(() => { + fs.mkdirSync(require('path').dirname(fakeFontPath), { recursive: true }); + fs.writeFileSync(fakeFontPath, fixture); + }); + afterAll(() => fs.rmSync(require('path').dirname(fakeFontPath), { recursive: true, force: true })); + + test('emits a self-contained CSS with embedded @font-face', () => { + const css = ExportCss.build({ activeFontPath: fakeFontPath, family: 'JetBrains Mono', weight: 400, ligatures: false }); + expect(css).toMatch(/@font-face\s*\{[^}]*src:\s*url\('data:font\/woff2;base64,/); + expect(css).toContain('font-family: \'JetBrains Mono\''); + expect(css).toMatch(/font-feature-settings:[^;]*liga[^;]*0/); + }); + + test('falls back to family-only CSS when font path is missing', () => { + const css = ExportCss.build({ activeFontPath: null, family: 'Fira Code', weight: 700, ligatures: true }); + expect(css).not.toContain('data:font/woff2;'); + expect(css).toContain('font-family: \'Fira Code\''); + }); +}); +``` + +- [ ] **Step 2: Run, fail** + +Run: `npm test -- tests/export-css.test.js` +Expected: FAIL — `Cannot find module` + +- [ ] **Step 3: Implement** + +```js +// src/main/ExportCss.js +'use strict'; + +const fs = require('fs'); + +function toDataUri(filePath) { + const buf = fs.readFileSync(filePath); + return `data:font/woff2;base64,${buf.toString('base64')}`; +} + +function build({ activeFontPath, family, weight = 400, ligatures = false }) { + const features = ligatures ? 'normal' : "'liga' 0, 'calt' 0, 'dlig' 0"; + const faceBlock = activeFontPath + ? `@font-face { + font-family: '${family}'; + font-weight: ${weight}; + font-style: normal; + font-display: swap; + src: url('${toDataUri(activeFontPath)}') format('woff2'); +} +` + : ''; + + return `${faceBlock}code, pre, kbd, samp { + font-family: '${family}', monospace; + font-feature-settings: ${features}; +} +pre, code { + white-space: pre; + tab-size: 4; +} +`; +} + +module.exports = { build }; +``` + +(Note: for the test, the file needs to look like woff2 to `_not_` matter — the encode is the same. The real `activeFontPath` in production will be the actual `.woff2` file shipped with the app. There's no need to separately ship a `.ttf` here — `ExportCss` embeds the woff2.) + +- [ ] **Step 4: Run, pass** + +Run: `npm test -- tests/export-css.test.js` +Expected: PASS — 2 tests + +- [ ] **Step 5: Commit** + +```bash +git add src/main/ExportCss.js tests/export-css.test.js +git commit -m "feat(monospace): ExportCss embeds woff2 as base64 in CSS + +Self-contained CSS for HTML export and print-preview iframe." +``` + +--- + +### Task 11: `print-preview.js` injects the ExportCss + +**Files:** +- Modify: `src/print-preview.js` + +- [ ] **Step 1: Locate the srcdoc-building function** + +The file already has a `previewHtml` template literal around line 82. Inside its `