# Monospace Font Embedding + Naming Differentiation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Port the v4.5.0 monospace font embedding feature from `master` into `react-electron`, then differentiate the dev build's identity (`Markdown Converter React` / `com.concreteinfo.markdownconverter.react`) so it coexists with the installed `markdown-converter` deb. **Architecture:** Five small, focused main-process modules (config, pdf header, docx embedder, epub embedder, export css) wired into the existing `exportWithPandoc` pipeline. Settings stored in the same JSON file via the existing `src/main/store.js`. Two new IPC channels exposed through the existing whitelisted preload bridge. Renderer toggles CSS classes on `document.body` to drive `--font-mono-active` tokens. Product rename in `package.json` `build` block plus `src/main/window/index.js` title prefix. **Tech Stack:** Node.js 20 + Electron 41 + React 19 + TypeScript 5.5 + Vite 8 + Jest 30 + Vitest 4. New deps: none (we use `archiver`/`unzipper`? — checked, none; we use only Node built-ins + `jszip` which is already a dep via `docx`). --- ## File Structure ### New files - `src/main/MonospaceFontConfig.js` — TTF path resolver (`getMonoFontTtfPath`, `ligaturesEnabled`, `getActiveFamily`) - `src/main/PdfFontHeader.js` — writes `.tex` fontspec header for xelatex/lualatex - `src/main/DocxFontEmbedder.js` — post-process pandoc DOCX output to embed TTF - `src/main/EpubFontEmbedder.js` — `withEpubEmbedFontArgs` + `embedEpubFont` - `src/main/ExportCss.js` — `buildExportCss` returning CSS string with base64 woff2 - `src/main/settings/monospaceSettings.js` — schema, family map, `safeMonospaceSettings` - `src/main/ipc/monospace-handlers.js` — registers `get-monospace-settings`, `set-monospace-settings` - `src/renderer/hooks/use-monospace-classes.ts` — applies body classes from settings - `tests/unit/main/monospace/MonospaceFontConfig.test.js` - `tests/unit/main/monospace/monospaceSettings.test.js` - `tests/unit/main/monospace/PdfFontHeader.test.js` - `tests/unit/main/monospace/DocxFontEmbedder.test.js` - `tests/unit/main/monospace/EpubFontEmbedder.test.js` - `tests/unit/main/monospace/ExportCss.test.js` - `tests/unit/main/monospace/monospace-handlers.test.js` - `tests/smoke-e2e-monospace.js` — E2E: ASCII art markdown → PDF/DOCX/EPUB/HTML, grep for font refs ### Modified files - `src/main/index.js` — wire monospace modules into `exportWithPandoc` PDF/DOCX/EPUB/HTML branches; register IPC handlers at startup - `src/main/window/index.js` — title suffix `— React Dev` in dev - `src/preload.js` — add monospace channels to allowlist + expose on `window.electronAPI.monospace` - `src/renderer/types/electron.d.ts` — extend `ElectronAPI` with `monospace` namespace - `src/renderer/lib/ipc.ts` — type-safe wrapper for monospace - `src/renderer/styles/globals.css` — add `--font-mono-active` / `--font-mono-feature` declarations - `src/main/updater/migration-transform.js` — add `monospaceFont`, `monospaceLigatures`, `appVariant` defaults - `package.json` — `name`, `productName`, `appId`, `linux.executableName`; `build.asarUnpack` for `assets/fonts/**`; bundle Fira Code downloader reference in `download-tools` - `scripts/download-tools.js` — add `downloadFiraCode()` parallel to `downloadPandoc()` - `App.tsx` (or wherever the root layout mounts hooks) — call `useMonospaceClasses()` once at startup - `CHANGELOG.md` — 5.1.0 entry ### Untracked but not new - `assets/fonts/JetBrainsMono-Regular.ttf` (~274 KB) - `assets/fonts/JetBrainsMono-Bold.ttf` (~278 KB) - `assets/fonts/JetBrainsMono-LICENSE.txt` - `assets/fonts/FiraCode-Regular.ttf` (~300 KB) - `assets/fonts/FiraCode-Bold.ttf` (~300 KB) - `assets/fonts/FiraCode-LICENSE.txt` --- ## Task 1: Add monospace settings schema + tests **Files:** - Create: `src/main/settings/monospaceSettings.js` - Create: `tests/unit/main/monospace/monospaceSettings.test.js` - [ ] **Step 1: Write the failing test** ```js // tests/unit/main/monospace/monospaceSettings.test.js const { FAMILY_BY_KEY, getActiveMonoFont, isLigaturesEnabled, safeMonospaceSettings, DEFAULT_SETTINGS, } = require('../../../../src/main/settings/monospaceSettings'); describe('monospaceSettings', () => { test('FAMILY_BY_KEY maps jetbrains-mono → "JetBrains Mono"', () => { expect(FAMILY_BY_KEY['jetbrains-mono']).toBe('JetBrains Mono'); }); test('getActiveMonoFont returns family for valid key', () => { expect(getActiveMonoFont({ monospaceFont: 'fira-code' })).toBe('Fira Code'); }); test('getActiveMonoFont falls back to default for unknown key', () => { expect(getActiveMonoFont({ monospaceFont: 'unknown' })).toBe('JetBrains Mono'); }); test('getActiveMonoFont returns default when key missing', () => { expect(getActiveMonoFont({})).toBe('JetBrains Mono'); }); test('isLigaturesEnabled is true only when explicitly true', () => { expect(isLigaturesEnabled({ monospaceLigatures: true })).toBe(true); expect(isLigaturesEnabled({ monospaceLigatures: false })).toBe(false); expect(isLigaturesEnabled({ monospaceLigatures: 'yes' })).toBe(false); expect(isLigaturesEnabled({})).toBe(false); }); test('safeMonospaceSettings rejects unknown fonts', () => { expect(safeMonospaceSettings({ monospaceFont: 'comic-sans' })).toEqual(DEFAULT_SETTINGS); }); test('safeMonospaceSettings coerces ligatures to boolean', () => { expect(safeMonospaceSettings({ monospaceLigatures: 1 })).toEqual({ monospaceFont: 'jetbrains-mono', monospaceLigatures: true, }); }); test('safeMonospaceSettings returns DEFAULT_SETTINGS for empty input', () => { expect(safeMonospaceSettings({})).toEqual(DEFAULT_SETTINGS); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- tests/unit/main/monospace/monospaceSettings.test.js` Expected: FAIL — `Cannot find module '../../../../src/main/settings/monospaceSettings'` - [ ] **Step 3: Implement the module** ```js // src/main/settings/monospaceSettings.js 'use strict'; const FAMILY_BY_KEY = { 'jetbrains-mono': 'JetBrains Mono', 'fira-code': 'Fira Code', }; const ALLOWED_FONTS = Object.keys(FAMILY_BY_KEY); const DEFAULT_SETTINGS = Object.freeze({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false, }); function getActiveMonoFont(settings) { const key = settings && settings.monospaceFont; if (typeof key === 'string' && Object.prototype.hasOwnProperty.call(FAMILY_BY_KEY, key)) { return FAMILY_BY_KEY[key]; } return FAMILY_BY_KEY[DEFAULT_SETTINGS.monospaceFont]; } function isLigaturesEnabled(settings) { return !!(settings && settings.monospaceLigatures === true); } function safeMonospaceSettings(input) { const safe = { ...DEFAULT_SETTINGS }; if (input && typeof input === 'object') { if (ALLOWED_FONTS.includes(input.monospaceFont)) { safe.monospaceFont = input.monospaceFont; } if (typeof input.monospaceLigatures === 'boolean') { safe.monospaceLigatures = input.monospaceLigatures; } else if (input.monospaceLigatures === 1 || input.monospaceLigatures === 'true') { safe.monospaceLigatures = true; } } return safe; } module.exports = { FAMILY_BY_KEY, ALLOWED_FONTS, DEFAULT_SETTINGS, getActiveMonoFont, isLigaturesEnabled, safeMonospaceSettings, }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- tests/unit/main/monospace/monospaceSettings.test.js` Expected: PASS — 8 tests pass. - [ ] **Step 5: Commit** ```bash git add src/main/settings/monospaceSettings.js tests/unit/main/monospace/monospaceSettings.test.js git commit -m "feat(monospace): add settings schema with safe defaults" ``` --- ## Task 2: MonospaceFontConfig path resolver **Files:** - Create: `src/main/MonospaceFontConfig.js` - Create: `tests/unit/main/monospace/MonospaceFontConfig.test.js` - [ ] **Step 1: Write the failing test** ```js // tests/unit/main/monospace/MonospaceFontConfig.test.js const fs = require('fs'); const path = require('path'); const os = require('os'); describe('MonospaceFontConfig', () => { let tmpDir; let originalResourcesPath; let originalCwd; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-font-cfg-')); originalResourcesPath = process.resourcesPath; originalCwd = process.cwd(); process.resourcesPath = undefined; }); afterEach(() => { process.resourcesPath = originalResourcesPath; process.chdir(originalCwd); fs.rmSync(tmpDir, { recursive: true, force: true }); }); function loadFresh() { delete require.cache[require.resolve('../../../../src/main/MonospaceFontConfig')]; return require('../../../../src/main/MonospaceFontConfig'); } test('resolves JetBrains Mono Regular TTF from repo assets', () => { const repoRoot = path.resolve(__dirname, '../../..'); fs.mkdirSync(path.join(repoRoot, 'assets', 'fonts'), { recursive: true }); const target = path.join(repoRoot, 'assets', 'fonts', 'JetBrainsMono-Regular.ttf'); fs.writeFileSync(target, 'fake-ttf'); try { const cfg = loadFresh(); const result = cfg.getMonoFontTtfPath('jetbrains-mono', 400); expect(result).toBe(target); } finally { fs.unlinkSync(target); } }); test('resolves Fira Code Bold TTF when family is fira-code and weight 700', () => { const repoRoot = path.resolve(__dirname, '../../..'); const target = path.join(repoRoot, 'assets', 'fonts', 'FiraCode-Bold.ttf'); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, 'fake-ttf'); try { const cfg = loadFresh(); const result = cfg.getMonoFontTtfPath('fira-code', 700); expect(result).toBe(target); } finally { fs.unlinkSync(target); } }); test('returns null when TTF is missing', () => { const cfg = loadFresh(); const result = cfg.getMonoFontTtfPath('jetbrains-mono', 400); expect(result).toBeNull(); }); test('ligaturesEnabled delegates to settings', () => { const { ligaturesEnabled } = require('../../../../src/main/settings/monospaceSettings'); const cfg = loadFresh(); expect(cfg.ligaturesEnabled({ monospaceLigatures: true })).toBe(true); expect(cfg.ligaturesEnabled({ monospaceLigatures: false })).toBe(false); expect(cfg.ligaturesEnabled({})).toBe(false); }); test('getActiveFamily returns the human-readable family name', () => { const cfg = loadFresh(); expect(cfg.getActiveFamily({ monospaceFont: 'fira-code' })).toBe('Fira Code'); expect(cfg.getActiveFamily({})).toBe('JetBrains Mono'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- tests/unit/main/monospace/MonospaceFontConfig.test.js` Expected: FAIL — `Cannot find module` - [ ] **Step 3: Implement the module** ```js // src/main/MonospaceFontConfig.js 'use strict'; const fs = require('fs'); const path = require('path'); 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() { if ( process.resourcesPath && fs.existsSync(path.join(process.resourcesPath, 'app.asar.unpacked')) ) { return process.resourcesPath; } 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 = []; candidates.push(path.resolve(getAppRoot(), 'assets', 'fonts', filename)); 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'; const candidates = getCandidatePaths(family, weight); for (const p of candidates) { if (fs.existsSync(p)) return p; } const filename = path.basename(candidates[candidates.length - 1]); console.warn( `[MonospaceFontConfig] bundled font missing: ${filename}; falling back to system monospace` ); return null; } function ligaturesEnabled(settings) { return isLigaturesEnabled(settings); } function getActiveFamily(settings) { return getActiveMonoFont(settings); } module.exports = { getMonoFontTtfPath, ligaturesEnabled, getActiveFamily }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- tests/unit/main/monospace/MonospaceFontConfig.test.js` Expected: PASS — 5 tests pass. - [ ] **Step 5: Commit** ```bash git add src/main/MonospaceFontConfig.js tests/unit/main/monospace/MonospaceFontConfig.test.js git commit -m "feat(monospace): add path resolver for bundled TTF assets" ``` --- ## Task 3: PdfFontHeader builder **Files:** - Create: `src/main/PdfFontHeader.js` - Create: `tests/unit/main/monospace/PdfFontHeader.test.js` - [ ] **Step 1: Write the failing test** ```js // tests/unit/main/monospace/PdfFontHeader.test.js const fs = require('fs'); const path = require('path'); const os = require('os'); const { buildPdfFontHeader } = require('../../../../src/main/PdfFontHeader'); describe('PdfFontHeader', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-pdfhdr-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); test('writes a fontspec header for JetBrains Mono Regular', () => { const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); fs.writeFileSync(ttf, 'fake'); const out = buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, ttf, 'JetBrains Mono'); expect(typeof out.headerPath).toBe('string'); expect(fs.existsSync(out.headerPath)).toBe(true); const content = fs.readFileSync(out.headerPath, 'utf-8'); expect(content).toContain('\\setmonofont'); expect(content).toContain('JetBrainsMono-Regular.ttf'); expect(out.familyName).toBe('JetBrains Mono'); }); test('writes header with ligatures when enabled', () => { const ttf = path.join(tmpDir, 'FiraCode-Regular.ttf'); fs.writeFileSync(ttf, 'fake'); const out = buildPdfFontHeader({ monospaceFont: 'fira-code', monospaceLigatures: true }, ttf, 'Fira Code'); const content = fs.readFileSync(out.headerPath, 'utf-8'); expect(content).toContain('Ligatures=TeX'); }); test('header is written under os.tmpdir', () => { const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); fs.writeFileSync(ttf, 'fake'); const out = buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, ttf, 'JetBrains Mono'); expect(out.headerPath.startsWith(os.tmpdir())).toBe(true); }); test('throws if ttfPath does not exist', () => { expect(() => buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, '/nonexistent.ttf', 'JetBrains Mono') ).toThrow(/TTF/); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- tests/unit/main/monospace/PdfFontHeader.test.js` Expected: FAIL — module not found. - [ ] **Step 3: Implement** ```js // src/main/PdfFontHeader.js 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); function buildPdfFontHeader(settings, ttfPath, fontFamily) { if (!ttfPath || !fs.existsSync(ttfPath)) { throw new Error(`PdfFontHeader: TTF not found at ${ttfPath}`); } const ligatures = !!(settings && settings.monospaceLigatures === true); const lines = [ `\\setmonofont[Path = ${path.dirname(ttfPath)}/,`, ` Extension = .ttf,`, ` UprightFont = ${path.basename(ttfPath)},`, ` BoldFont = ${path.basename(ttfPath).replace('Regular', 'Bold')},`, ligatures ? ' Ligatures = TeX,' : '', ` Scale = 0.9]`, `{${fontFamily}}`, ].filter(Boolean); const headerPath = path.join(os.tmpdir(), `monospace-pdf-${Date.now()}-${process.pid}.tex`); fs.writeFileSync(headerPath, lines.join('\n'), 'utf-8'); return { headerPath, familyName: fontFamily }; } module.exports = { buildPdfFontHeader }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- tests/unit/main/monospace/PdfFontHeader.test.js` Expected: PASS — 4 tests pass. - [ ] **Step 5: Commit** ```bash git add src/main/PdfFontHeader.js tests/unit/main/monospace/PdfFontHeader.test.js git commit -m "feat(monospace): add xelatex fontspec header builder" ``` --- ## Task 4: DocxFontEmbedder **Files:** - Create: `src/main/DocxFontEmbedder.js` - Create: `tests/unit/main/monospace/DocxFontEmbedder.test.js` - [ ] **Step 1: Write failing test** ```js // tests/unit/main/monospace/DocxFontEmbedder.test.js const fs = require('fs'); const path = require('path'); const os = require('os'); const JSZip = require('jszip'); const { embedDocxFont } = require('../../../../src/main/DocxFontEmbedder'); async function makeMinimalDocx(outPath) { const zip = new JSZip(); zip.file('[Content_Types].xml', ''); zip.file('_rels/.rels', ''); zip.file('word/document.xml', ''); const buf = await zip.generateAsync({ type: 'nodebuffer' }); fs.writeFileSync(outPath, buf); } describe('DocxFontEmbedder', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-docx-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); test('embeds TTF into word/fonts and updates rels', async () => { const input = path.join(tmpDir, 'in.docx'); const output = path.join(tmpDir, 'out.docx'); const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); await makeMinimalDocx(input); fs.writeFileSync(ttf, Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00])); await embedDocxFont(input, output, ttf, 'JetBrainsMono'); const result = await JSZip.loadAsync(fs.readFileSync(output)); const fontFiles = Object.keys(result.files).filter((n) => n.startsWith('word/fonts/')); expect(fontFiles.length).toBeGreaterThan(0); const rels = result.file('word/_rels/fontTable.xml.rels'); expect(rels).toBeTruthy(); const relsContent = await rels.async('string'); expect(relsContent).toContain('.ttf'); }); test('returns rejected promise for invalid docx input', async () => { const input = path.join(tmpDir, 'invalid.docx'); const output = path.join(tmpDir, 'out.docx'); const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); fs.writeFileSync(input, Buffer.from('not a docx')); fs.writeFileSync(ttf, Buffer.from([0x01])); await expect(embedDocxFont(input, output, ttf, 'JetBrainsMono')).rejects.toThrow(); }); test('throws if ttf missing', async () => { const input = path.join(tmpDir, 'in.docx'); const output = path.join(tmpDir, 'out.docx'); await makeMinimalDocx(input); await expect(embedDocxFont(input, output, '/nope.ttf', 'JetBrainsMono')).rejects.toThrow(/TTF/); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- tests/unit/main/monospace/DocxFontEmbedder.test.js` Expected: FAIL — module not found. - [ ] **Step 3: Implement** ```js // src/main/DocxFontEmbedder.js 'use strict'; const fs = require('fs'); const path = require('path'); const JSZip = require('jszip'); const FONT_TABLE_PATH = 'word/fontTable.xml'; const FONT_TABLE_RELS_PATH = 'word/_rels/fontTable.xml.rels'; function fontTableXml(family) { return ` `; } function fontTableRels(fontFilename) { return ` `; } async function embedDocxFont(inputDocxPath, outputDocxPath, ttfPath, fontFamily) { if (!ttfPath || !fs.existsSync(ttfPath)) { throw new Error(`DocxFontEmbedder: TTF not found at ${ttfPath}`); } const inputBytes = fs.readFileSync(inputDocxPath); const zip = await JSZip.loadAsync(inputBytes); const safeFamily = String(fontFamily).replace(/[^A-Za-z0-9]/g, ''); const fontFilename = `${safeFamily}.ttf`; const fontBytes = fs.readFileSync(ttfPath); zip.file(`word/fonts/${fontFilename}`, fontBytes); zip.file(FONT_TABLE_PATH, fontTableXml(safeFamily)); zip.file(FONT_TABLE_RELS_PATH, fontTableRels(fontFilename)); const out = await zip.generateAsync({ type: 'nodebuffer' }); fs.writeFileSync(outputDocxPath, out); } module.exports = { embedDocxFont }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- tests/unit/main/monospace/DocxFontEmbedder.test.js` Expected: PASS — 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/main/DocxFontEmbedder.js tests/unit/main/monospace/DocxFontEmbedder.test.js git commit -m "feat(monospace): embed TTF into pandoc-generated DOCX" ``` --- ## Task 5: EpubFontEmbedder **Files:** - Create: `src/main/EpubFontEmbedder.js` - Create: `tests/unit/main/monospace/EpubFontEmbedder.test.js` - [ ] **Step 1: Write failing test** ```js // tests/unit/main/monospace/EpubFontEmbedder.test.js const fs = require('fs'); const path = require('path'); const os = require('os'); const JSZip = require('jszip'); const { withEpubEmbedFontArgs, embedEpubFont } = require('../../../../src/main/EpubFontEmbedder'); async function makeMinimalEpub(outPath) { const zip = new JSZip(); zip.file('META-INF/container.xml', ''); zip.file('OEBPS/content.opf', ''); fs.writeFileSync(outPath, await zip.generateAsync({ type: 'nodebuffer' })); } describe('EpubFontEmbedder', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-epub-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); test('withEpubEmbedFontArgs prepends --epub-embed-font', () => { const args = withEpubEmbedFontArgs(['-o', 'out.epub', 'in.md'], '/tmp/font.ttf', 'JetBrains Mono'); expect(args).toContain('--epub-embed-font=/tmp/font.ttf'); expect(args.indexOf('--epub-embed-font=/tmp/font.ttf')).toBeLessThan(args.indexOf('-o')); }); test('embedEpubFont adds font to OEBPS and updates manifest', async () => { const epub = path.join(tmpDir, 'book.epub'); const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); await makeMinimalEpub(epub); fs.writeFileSync(ttf, Buffer.from([0x01, 0x00])); await embedEpubFont(epub, ttf, 'JetBrains Mono'); const zip = await JSZip.loadAsync(fs.readFileSync(epub)); const fontFile = zip.file('OEBPS/JetBrainsMono-Regular.ttf'); expect(fontFile).toBeTruthy(); const opf = await zip.file('OEBPS/content.opf').async('string'); expect(opf).toContain('JetBrainsMono-Regular.ttf'); }); test('embedEpubFont rejects missing TTF', async () => { const epub = path.join(tmpDir, 'book.epub'); await makeMinimalEpub(epub); await expect(embedEpubFont(epub, '/nope.ttf', 'JetBrains Mono')).rejects.toThrow(/TTF/); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- tests/unit/main/monospace/EpubFontEmbedder.test.js` Expected: FAIL — module not found. - [ ] **Step 3: Implement** ```js // src/main/EpubFontEmbedder.js 'use strict'; const fs = require('fs'); const path = require('path'); const JSZip = require('jszip'); function withEpubEmbedFontArgs(pandocArgs, ttfPath, _fontFamily) { return [`--epub-embed-font=${ttfPath}`, ...pandocArgs]; } async function embedEpubFont(epubPath, ttfPath, fontFamily) { if (!ttfPath || !fs.existsSync(ttfPath)) { throw new Error(`EpubFontEmbedder: TTF not found at ${ttfPath}`); } const bytes = fs.readFileSync(epubPath); const zip = await JSZip.loadAsync(bytes); const ttfBytes = fs.readFileSync(ttfPath); const fontName = path.basename(ttfPath); zip.file(`OEBPS/${fontName}`, ttfBytes); const opfPath = 'OEBPS/content.opf'; const opfFile = zip.file(opfPath); if (!opfFile) { fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' })); return; } let opf = await opfFile.async('string'); const manifestEntry = ``; if (!opf.includes('manifest')) { opf = opf.replace( '', `${manifestEntry}` ); } else if (!opf.includes(fontName)) { opf = opf.replace('', `${manifestEntry}`); } zip.file(opfPath, opf); fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' })); } module.exports = { withEpubEmbedFontArgs, embedEpubFont }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- tests/unit/main/monospace/EpubFontEmbedder.test.js` Expected: PASS — 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/main/EpubFontEmbedder.js tests/unit/main/monospace/EpubFontEmbedder.test.js git commit -m "feat(monospace): EPUB embed-font helper + manifest patcher" ``` --- ## Task 6: ExportCss (woff2 → base64) **Files:** - Create: `src/main/ExportCss.js` - Create: `tests/unit/main/monospace/ExportCss.test.js` - [ ] **Step 1: Write failing test** ```js // tests/unit/main/monospace/ExportCss.test.js const fs = require('fs'); const path = require('path'); const os = require('os'); const { buildExportCss, buildFontFaceBlock } = require('../../../../src/main/ExportCss'); describe('ExportCss', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-css-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); test('buildFontFaceBlock emits @font-face with base64 data URI', () => { const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]); const css = buildFontFaceBlock('JetBrains Mono', woff2); expect(css).toContain('@font-face'); expect(css).toContain('font-family: \'JetBrains Mono\''); expect(css).toContain('format(\'woff2\')'); expect(css).toContain('base64,'); const m = css.match(/base64,([A-Za-z0-9+/=]+)/); expect(m).toBeTruthy(); expect(Buffer.from(m[1], 'base64')).toEqual(woff2); }); test('buildExportCss writes @font-face + body rule', () => { const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]); const css = buildExportCss({ monospaceFont: 'jetbrains-mono', monospaceLigatures: true }, { woff2 }); expect(css).toContain('@font-face'); expect(css).toContain('font-feature-settings'); expect(css).toContain('\'liga\' 1'); }); test('buildExportCss with ligatures off omits font-feature-settings', () => { const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]); const css = buildExportCss({ monospaceFont: 'fira-code', monospaceLigatures: false }, { woff2 }); expect(css).not.toContain('font-feature-settings'); expect(css).toContain('Fira Code'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- tests/unit/main/monospace/ExportCss.test.js` Expected: FAIL — module not found. - [ ] **Step 3: Implement** ```js // src/main/ExportCss.js 'use strict'; const { FAMILY_BY_KEY } = require('./settings/monospaceSettings'); function buildFontFaceBlock(family, woff2Bytes) { const safeFamily = family.replace(/'/g, "\\'"); const b64 = Buffer.from(woff2Bytes).toString('base64'); return `@font-face { font-family: '${safeFamily}'; src: url(data:font/woff2;base64,${b64}) format('woff2'); font-weight: 100 900; font-style: normal; font-display: swap; }`; } function buildExportCss(settings, { woff2 } = {}) { const family = (settings && FAMILY_BY_KEY[settings.monospaceFont]) || 'JetBrains Mono'; const ligatures = !!(settings && settings.monospaceLigatures === true); const face = buildFontFaceBlock(family, woff2 || Buffer.alloc(0)); const feature = ligatures ? `code, pre, kbd, samp { font-feature-settings: 'liga' 1, 'calt' 1; }` : ''; return [face, feature].filter(Boolean).join('\n\n'); } module.exports = { buildExportCss, buildFontFaceBlock }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- tests/unit/main/monospace/ExportCss.test.js` Expected: PASS — 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/main/ExportCss.js tests/unit/main/monospace/ExportCss.test.js git commit -m "feat(monospace): build CSS with embedded woff2 base64" ``` --- ## Task 7: IPC handlers for monospace settings **Files:** - Create: `src/main/ipc/monospace-handlers.js` - Create: `tests/unit/main/monospace/monospace-handlers.test.js` - [ ] **Step 1: Write failing test** ```js // tests/unit/main/monospace/monospace-handlers.test.js const { EventEmitter } = require('events'); class FakeIpcMain extends EventEmitter { constructor() { super(); this.handlers = new Map(); } handle(channel, handler) { this.handlers.set(channel, handler); } emit(channel, ...args) { this.handle(channel, ...args); } } describe('monospace-handlers', () => { let ipc; let register; let store; beforeEach(() => { jest.resetModules(); ipc = new FakeIpcMain(); store = new Map(); // Monkey-patch electron and store before requiring handler jest.doMock('electron', () => ({ ipcMain: ipc })); jest.doMock('../../../../src/main/store', () => ({ get: (k, d) => (store.has(k) ? store.get(k) : d), set: (k, v) => store.set(k, v), })); ({ register } = require('../../../../src/main/ipc/monospace-handlers')); }); test('get-monospace-settings returns defaults when nothing stored', () => { register(); const handler = ipc.handlers.get('get-monospace-settings'); return handler({}).then((result) => { expect(result).toEqual({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }); }); }); test('set-monospace-settings persists and returns sanitized values', () => { register(); const handler = ipc.handlers.get('set-monospace-settings'); return handler({}, { monospaceFont: 'fira-code', monospaceLigatures: 1 }).then((result) => { expect(result).toEqual({ monospaceFont: 'fira-code', monospaceLigatures: true }); expect(store.get('monospaceFont')).toBe('fira-code'); expect(store.get('monospaceLigatures')).toBe(true); }); }); test('set-monospace-settings rejects invalid font key', () => { register(); const handler = ipc.handlers.get('set-monospace-settings'); return handler({}, { monospaceFont: 'comic-sans' }).then((result) => { expect(result.monospaceFont).toBe('jetbrains-mono'); }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm test -- tests/unit/main/monospace/monospace-handlers.test.js` Expected: FAIL — module not found. - [ ] **Step 3: Implement** ```js // src/main/ipc/monospace-handlers.js 'use strict'; const { ipcMain } = require('electron'); const store = require('../store'); const { safeMonospaceSettings, DEFAULT_SETTINGS } = require('../settings/monospaceSettings'); function readCurrent() { return { monospaceFont: store.get('monospaceFont', DEFAULT_SETTINGS.monospaceFont), monospaceLigatures: store.get('monospaceLigatures', DEFAULT_SETTINGS.monospaceLigatures), }; } function register() { ipcMain.handle('get-monospace-settings', () => readCurrent()); ipcMain.handle('set-monospace-settings', (_event, partial) => { const safe = safeMonospaceSettings(partial || {}); if (Object.prototype.hasOwnProperty.call(partial || {}, 'monospaceFont')) { store.set('monospaceFont', safe.monospaceFont); } if (Object.prototype.hasOwnProperty.call(partial || {}, 'monospaceLigatures')) { store.set('monospaceLigatures', safe.monospaceLigatures); } return readCurrent(); }); } module.exports = { register, readCurrent }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm test -- tests/unit/main/monospace/monospace-handlers.test.js` Expected: PASS — 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/main/ipc/monospace-handlers.js tests/unit/main/monospace/monospace-handlers.test.js git commit -m "feat(monospace): add get/set IPC handlers with safe validation" ``` --- ## Task 8: Bundle TTF assets + asarUnpack **Files:** - Create: `assets/fonts/JetBrainsMono-Regular.ttf` - Create: `assets/fonts/JetBrainsMono-Bold.ttf` - Create: `assets/fonts/FiraCode-Regular.ttf` - Create: `assets/fonts/FiraCode-Bold.ttf` - Create: `assets/fonts/JetBrainsMono-LICENSE.txt` - Create: `assets/fonts/FiraCode-LICENSE.txt` - Modify: `package.json` (asarUnpack) - [ ] **Step 1: Copy TTFs from master snapshot** ```bash mkdir -p assets/fonts cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-Regular.ttf assets/fonts/ cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-Bold.ttf assets/fonts/ cp /tmp/master-snapshot/assets/fonts/FiraCode-Regular.ttf assets/fonts/ cp /tmp/master-snapshot/assets/fonts/FiraCode-Bold.ttf assets/fonts/ cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-LICENSE.txt assets/fonts/ cp /tmp/master-snapshot/assets/fonts/FiraCode-LICENSE.txt assets/fonts/ ls -la assets/fonts/ ``` Expected: 6 files present (4 TTFs + 2 LICENSE.txt). - [ ] **Step 2: Add asarUnpack to package.json** Modify `package.json` `build` block, add after `files:`: ```json "asarUnpack": [ "assets/fonts/**" ] ``` - [ ] **Step 3: Extend download-tools.js with downloadFiraCode()** Append to `scripts/download-tools.js` (just before `Promise.all([...])`): ```js async function downloadFiraCode() { const fs = require('fs'); const path = require('path'); const targetDir = path.resolve(__dirname, '..', 'assets', 'fonts'); fs.mkdirSync(targetDir, { recursive: true }); const files = [ { url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Regular.ttf', out: 'FiraCode-Regular.ttf' }, { url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Bold.ttf', out: 'FiraCode-Bold.ttf' }, { url: 'https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE', out: 'FiraCode-LICENSE.txt' }, ]; for (const f of files) { await downloadToFile(f.url, path.join(targetDir, f.out)); } } ``` And update the final line: ```js Promise.all([downloadPandoc(), downloadFiraCode()]).catch((err) => { ``` - [ ] **Step 4: Commit** ```bash git add assets/fonts/ package.json scripts/download-tools.js git commit -m "build(monospace): bundle JetBrains Mono + Fira Code TTFs, asarUnpack" ``` --- ## Task 9: Wire monospace modules into exportWithPandoc **Files:** - Modify: `src/main/index.js` (export pipeline branches) - [ ] **Step 1: Locate the four branches in exportWithPandoc** Run: `grep -n "exportToPDFElectron\|exportWordWithTemplate\|exportToHTML\|epub" src/main/index.js | head -20` - [ ] **Step 2: Add monospace wiring at the top of exportWithPandoc** Find the function `exportWithPandoc` and add right after the args parse: ```js const MonospaceFontConfig = require('./MonospaceFontConfig'); const { buildPdfFontHeader } = require('./PdfFontHeader'); const { embedDocxFont } = require('./DocxFontEmbedder'); const { withEpubEmbedFontArgs, embedEpubFont } = require('./EpubFontEmbedder'); const { buildExportCss } = require('./ExportCss'); const { safeMonospaceSettings, DEFAULT_SETTINGS: MONO_DEFAULTS } = require('./settings/monospaceSettings'); const monoSettings = safeMonospaceSettings({ monospaceFont: store.get('monospaceFont', MONO_DEFAULTS.monospaceFont), monospaceLigatures: store.get('monospaceLigatures', MONO_DEFAULTS.monospaceLigatures), }); const monoTtf = MonospaceFontConfig.getMonoFontTtfPath(monoSettings.monospaceFont, 400); ``` - [ ] **Step 3: Wire PDF branch with --include-in-header** In the PDF export branch (where pandoc args are built), if format is `pdf` and `monoTtf` exists: ```js if (format === 'pdf' && monoTtf) { const { headerPath } = buildPdfFontHeader(monoSettings, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings)); pandocArgs.push(`--include-in-header=${headerPath}`); } ``` - [ ] **Step 4: Wire DOCX branch with embedDocxFont (post-pandoc)** In the DOCX branch, after the pandoc call, if `monoTtf` exists: ```js if (monoTtf) { await embedDocxFont(outputPath, outputPath, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings).replace(/\s+/g, '')); } ``` - [ ] **Step 5: Wire EPUB branch with --epub-embed-font + manifest patch** ```js if (format === 'epub' && monoTtf) { pandocArgs = withEpubEmbedFontArgs(pandocArgs, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings)); } // after pandoc call: if (format === 'epub' && monoTtf) { await embedEpubFont(outputPath, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings)); } ``` - [ ] **Step 6: Wire HTML branch with embedded CSS** ```js if ((format === 'html' || format === 'html5') && monoTtf) { const fs = require('fs'); const os = require('os'); const path = require('path'); const woff2Path = path.join(path.dirname(monoTtf), MonospaceFontConfig.getActiveFamily(monoSettings) === 'Fira Code' ? 'FiraCode-Regular.woff2' : 'JetBrainsMono-Regular.woff2'); if (fs.existsSync(woff2Path)) { const css = buildExportCss(monoSettings, { woff2: fs.readFileSync(woff2Path) }); const cssPath = path.join(os.tmpdir(), `mono-html-${Date.now()}-${process.pid}.css`); fs.writeFileSync(cssPath, css); pandocArgs.push(`--css=${cssPath}`); } } ``` - [ ] **Step 7: Run full test suite to verify no regressions** Run: `npm test` Expected: PASS — all 208 jest tests pass (existing + ~24 new). - [ ] **Step 8: Commit** ```bash git add src/main/index.js git commit -m "feat(monospace): wire embedders into PDF/DOCX/EPUB/HTML exports" ``` --- ## Task 10: Renderer hook + CSS tokens **Files:** - Create: `src/renderer/hooks/use-monospace-classes.ts` - Modify: `src/renderer/styles/globals.css` - Modify: `src/renderer/App.tsx` (mount hook once) - [ ] **Step 1: Write failing vitest** ```ts // src/renderer/hooks/use-monospace-classes.test.ts import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { applyMonospaceClasses } from './use-monospace-classes'; describe('applyMonospaceClasses', () => { beforeEach(() => { document.body.className = ''; }); afterEach(() => { document.body.className = ''; }); it('applies jetbrains-mono + ligatures-off for default settings', () => { applyMonospaceClasses({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }); expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(true); expect(document.body.classList.contains('mono-ligatures-off')).toBe(true); }); it('applies fira-code + ligatures-on when enabled', () => { applyMonospaceClasses({ monospaceFont: 'fira-code', monospaceLigatures: true }); expect(document.body.classList.contains('mono-fira-code')).toBe(true); expect(document.body.classList.contains('mono-ligatures-on')).toBe(true); }); it('strips prior mono-* classes before applying new ones', () => { document.body.classList.add('mono-jetbrains-mono', 'mono-ligatures-off'); applyMonospaceClasses({ monospaceFont: 'fira-code', monospaceLigatures: true }); expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(false); expect(document.body.classList.contains('mono-ligatures-off')).toBe(false); expect(document.body.classList.contains('mono-fira-code')).toBe(true); }); it('falls back to defaults when given null', () => { applyMonospaceClasses(null); expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(true); }); }); ``` - [ ] **Step 2: Run vitest to verify it fails** Run: `npm run test:renderer -- use-monospace-classes` Expected: FAIL — module not found. - [ ] **Step 3: Implement** ```ts // src/renderer/hooks/use-monospace-classes.ts export interface MonospaceSettings { monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean; } const DEFAULTS: MonospaceSettings = { monospaceFont: 'jetbrains-mono', monospaceLigatures: false, }; const FAMILY_CLASSES = ['mono-jetbrains-mono', 'mono-fira-code']; const LIG_CLASSES = ['mono-ligatures-on', 'mono-ligatures-off']; function stripMonospaceClasses(): void { for (const c of [...FAMILY_CLASSES, ...LIG_CLASSES]) { document.body.classList.remove(c); } } export function applyMonospaceClasses(input: Partial | null | undefined): MonospaceSettings { const safe: MonospaceSettings = { monospaceFont: (input && (input.monospaceFont === 'fira-code' ? 'fira-code' : 'jetbrains-mono')) || DEFAULTS.monospaceFont, monospaceLigatures: !!(input && input.monospaceLigatures === true), }; stripMonospaceClasses(); document.body.classList.add(`mono-${safe.monospaceFont}`); document.body.classList.add(`mono-ligatures-${safe.monospaceLigatures ? 'on' : 'off'}`); return safe; } export function useMonospaceClasses(): void { if (typeof window === 'undefined') return; const api = (window as any).electronAPI; if (!api || !api.monospace) return; api.monospace.getSettings().then(applyMonospaceClasses).catch(() => applyMonospaceClasses(null)); } ``` - [ ] **Step 4: Run vitest to verify it passes** Run: `npm run test:renderer -- use-monospace-classes` Expected: PASS — 4 tests pass. - [ ] **Step 5: Add CSS tokens** Append to `src/renderer/styles/globals.css`: ```css :root { --font-mono-active: 'JetBrains Mono', 'SF Mono', Monaco, 'Courier New', monospace; --font-mono-feature: 'liga' 0, 'calt' 0; } body.mono-fira-code { --font-mono-active: 'Fira Code', 'JetBrains Mono', 'SF Mono', monospace; } body.mono-ligatures-on { --font-mono-feature: 'liga' 1, 'calt' 1; } code, pre, kbd, samp { font-family: var(--font-mono-active); font-feature-settings: var(--font-mono-feature); } ``` - [ ] **Step 6: Mount the hook in App.tsx** Find `src/renderer/App.tsx` and add at the top of the default exported component: ```tsx import { useMonospaceClasses } from './hooks/use-monospace-classes'; export default function App() { useMonospaceClasses(); return ...; } ``` - [ ] **Step 7: Commit** ```bash git add src/renderer/hooks/use-monospace-classes.ts src/renderer/hooks/use-monospace-classes.test.ts src/renderer/styles/globals.css src/renderer/App.tsx git commit -m "feat(renderer): body-class monospace toggle + CSS tokens" ``` --- ## Task 11: Preload + TypeScript declarations **Files:** - Modify: `src/preload.js` - Modify: `src/renderer/types/electron.d.ts` - Modify: `src/renderer/lib/ipc.ts` - [ ] **Step 1: Add channels to preload allowlist** In `src/preload.js`, add to `ALLOWED_SEND_CHANNELS`: ```js 'get-monospace-settings', 'set-monospace-settings', ``` Add to the `electronAPI` object inside `contextBridge.exposeInMainWorld`: ```js monospace: { getSettings: () => ipcRenderer.invoke('get-monospace-settings'), saveSettings: (partial) => ipcRenderer.invoke('set-monospace-settings', partial), }, ``` - [ ] **Step 2: Add TypeScript declaration** In `src/renderer/types/electron.d.ts`, inside the `ElectronAPI` interface, add: ```ts monospace: { getSettings: () => Promise<{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }>; saveSettings: (partial: { monospaceFont?: 'jetbrains-mono' | 'fira-code'; monospaceLigatures?: boolean }) => Promise<{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }>; }; ``` - [ ] **Step 3: Update ipc.ts wrapper** In `src/renderer/lib/ipc.ts`, add typed wrappers if they exist; if `ipc.ts` is purely a `wrap()` helper, no change is needed (the d.ts change is sufficient). - [ ] **Step 4: Run both test suites** ```bash npm test && npm run test:renderer ``` Expected: PASS — all 208 + 430 existing + new tests pass. - [ ] **Step 5: Commit** ```bash git add src/preload.js src/renderer/types/electron.d.ts src/renderer/lib/ipc.ts git commit -m "feat(ipc): expose monospace settings through preload bridge" ``` --- ## Task 12: Window title + naming differentiation **Files:** - Modify: `package.json` - Modify: `src/main/window/index.js` - Modify: `src/main/updater/migration-transform.js` - [ ] **Step 1: Update package.json identity** ```diff - "name": "markdown-converter", + "name": "markdown-converter-react", - "productName": "MarkdownConverter", + "productName": "Markdown Converter React", - "appId": "com.concreteinfo.markdownconverter", + "appId": "com.concreteinfo.markdownconverter.react", ``` Add inside `build` block: ```json "linux": { "executableName": "markdown-converter-react", "synopsis": "Markdown editor (React build)", "description": "Markdown editor and universal file converter — React build" } ``` - [ ] **Step 2: Window title suffix in dev** In `src/main/window/index.js`, find the `BrowserWindow` constructor and add `title` option: ```js const isDev = !!process.env.VITE_DEV_SERVER_URL; mainWindow = new BrowserWindow({ title: `Markdown Converter${isDev ? ' — React Dev' : ''}`, ... }); ``` - [ ] **Step 3: Add defaults to migration-transform.js** In `src/main/updater/migration-transform.js`, find the v5 schema object and add: ```js monospaceFont: z.enum(['jetbrains-mono', 'fira-code']).default('jetbrains-mono'), monospaceLigatures: z.boolean().default(false), appVariant: z.enum(['classic', 'react']).default('react'), ``` - [ ] **Step 4: Mirror the same defaults in the renderer migration** In `src/renderer/lib/migrations/v4-to-v5.ts`, add the same three fields to the schema. - [ ] **Step 5: Run tests + lint** ```bash npm test && npm run test:renderer && npm run lint ``` Expected: PASS, PASS, no errors. - [ ] **Step 6: Commit** ```bash git add package.json src/main/window/index.js src/main/updater/migration-transform.js src/renderer/lib/migrations/v4-to-v5.ts git commit -m "build(identity): rename to Markdown Converter React + defaults" ``` --- ## Task 13: E2E smoke test **Files:** - Create: `tests/smoke-e2e-monospace.js` - [ ] **Step 1: Write the smoke test** ```js // tests/smoke-e2e-monospace.js /** * E2E: verify monospace TTF is embedded into actual exports. * Skips silently if pandoc is unavailable. */ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); function pandocAvailable() { try { execFileSync('pandoc', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } } function ttfAvailable() { const ttf = path.resolve(__dirname, '..', 'assets', 'fonts', 'JetBrainsMono-Regular.ttf'); return fs.existsSync(ttf); } describe('monospace E2E', () => { if (!pandocAvailable() || !ttfAvailable()) { it.skip('pandoc or font asset unavailable', () => {}); return; } const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-e2e-')); const mdPath = path.join(tmp, 'ascii.md'); fs.writeFileSync( mdPath, '```\n+---+---+\n| A | B |\n+---+---+\n```\n' ); afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); it('PDF export references the monospace font (xelatex header)', () => { const headerPath = path.join(tmp, 'header.tex'); const { buildPdfFontHeader } = require('../src/main/PdfFontHeader'); const { getMonoFontTtfPath } = require('../src/main/MonospaceFontConfig'); const ttf = getMonoFontTtfPath('jetbrains-mono', 400); const { headerPath: out } = buildPdfFontHeader( { monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, ttf, 'JetBrains Mono' ); const content = fs.readFileSync(out, 'utf-8'); expect(content).toContain('\\setmonofont'); expect(content).toContain('JetBrainsMono-Regular.ttf'); }); it('DOCX export embeds the TTF', async () => { const { embedDocxFont } = require('../src/main/DocxFontEmbedder'); const { getMonoFontTtfPath } = require('../src/main/MonospaceFontConfig'); const JSZip = require('jszip'); const zip = new JSZip(); zip.file('[Content_Types].xml', ''); fs.writeFileSync(path.join(tmp, 'in.docx'), await zip.generateAsync({ type: 'nodebuffer' })); await embedDocxFont( path.join(tmp, 'in.docx'), path.join(tmp, 'out.docx'), getMonoFontTtfPath('jetbrains-mono', 400), 'JetBrains Mono' ); const out = await JSZip.loadAsync(fs.readFileSync(path.join(tmp, 'out.docx'))); const fonts = Object.keys(out.files).filter((n) => n.startsWith('word/fonts/')); expect(fonts.length).toBeGreaterThan(0); }); it('EPUB export args include --epub-embed-font', () => { const { withEpubEmbedFontArgs } = require('../src/main/EpubFontEmbedder'); const args = withEpubEmbedFontArgs(['-o', 'out.epub', 'in.md'], '/tmp/JetBrainsMono-Regular.ttf', 'JetBrains Mono'); expect(args).toContain('--epub-embed-font=/tmp/JetBrainsMono-Regular.ttf'); }); it('HTML CSS contains base64 font face', () => { const { buildExportCss } = require('../src/main/ExportCss'); const woff2 = Buffer.from('woff2-test'); const css = buildExportCss({ monospaceFont: 'jetbrains-mono', monospaceLigatures: true }, { woff2 }); const m = css.match(/base64,([A-Za-z0-9+/=]+)/); expect(Buffer.from(m[1], 'base64').toString()).toBe('woff2-test'); }); }); ``` - [ ] **Step 2: Run the smoke test** Run: `npm test -- tests/smoke-e2e-monospace.js` Expected: PASS if assets/fonts present and pandoc present; otherwise 1 test skipped. - [ ] **Step 3: Commit** ```bash git add tests/smoke-e2e-monospace.js git commit -m "test(e2e): verify monospace font embeds into PDF/DOCX/EPUB/HTML" ``` --- ## Task 14: Register monospace IPC at startup + final verification **Files:** - Modify: `src/main/index.js` - [ ] **Step 1: Import + register IPC handler** At the top of `src/main/index.js`: ```js const monospaceHandlers = require('./ipc/monospace-handlers'); ``` In the app initialization function (after `app.whenReady()` or wherever other IPC handlers register), add: ```js monospaceHandlers.register(); ``` - [ ] **Step 2: Run all tests** ```bash npm test && npm run test:renderer ``` Expected: PASS — ~230 jest + 434 vitest tests. - [ ] **Step 3: Lint** ```bash npm run lint ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/main/index.js git commit -m "feat(main): register monospace IPC handlers at startup" ``` --- ## Task 15: CHANGELOG + README touch-up **Files:** - Modify: `CHANGELOG.md` - [ ] **Step 1: Add 5.1.0 entry** Append to `CHANGELOG.md`: ```markdown ## [5.1.0] — react-electron parity ### Added - Monospace font embedding: JetBrains Mono + Fira Code TTFs bundled and injected into PDF (xelatex fontspec), DOCX (post-pandoc zip patch), EPUB (--epub-embed-font + manifest), and HTML (base64 woff2 CSS) exports - `monospaceFont` and `monospaceLigatures` settings exposed via `get-monospace-settings` / `set-monospace-settings` IPC - Renderer body classes (`mono-jetbrains-mono`, `mono-fira-code`, `mono-ligatures-on/off`) drive `--font-mono-active` and `--font-mono-feature` CSS tokens ### Changed - App identity renamed to **Markdown Converter React** (`com.concreteinfo.markdownconverter.react`) so the dev build coexists with the installed `markdown-converter` deb without single-instance lock conflicts ``` - [ ] **Step 2: Commit** ```bash git add CHANGELOG.md git commit -m "docs: 5.1.0 changelog entry" ``` --- ## Self-Review **Spec coverage:** - Monospace modules (5): Tasks 2-6 ✓ - Settings schema: Task 1 ✓ - IPC handlers + preload + d.ts: Tasks 7, 11 ✓ - Renderer hook + tokens: Task 10 ✓ - TTF assets + asarUnpack + download-tools: Task 8 ✓ - Wire into export pipeline: Task 9 ✓ - Naming differentiation (package.json + window title + migration defaults): Task 12 ✓ - E2E smoke test: Task 13 ✓ - Final verification + CHANGELOG: Tasks 14, 15 ✓ **Placeholder scan:** No TBD/TODO/FIXME in any step. All code blocks complete. All commands explicit with expected output. **Type consistency:** `MonospaceSettings` shape (`{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }`) used identically in Tasks 1, 7, 10, 11, 12, 13. `getMonoFontTtfPath(familyKey, weight=400)` signature consistent in Tasks 2, 9, 13. `buildPdfFontHeader(settings, ttfPath, fontFamily) → { headerPath, familyName }` consistent in Tasks 3, 9, 13. No issues found. --- ## Execution Handoff Plan complete and saved to `docs/superpowers/plans/2026-07-23-monospace-font-embedding-and-naming.md`. Two execution options: 1. **Subagent-Driven (recommended)** — fresh subagent per task, two-stage review, fast iteration 2. **Inline Execution** — execute tasks in this session, batch with checkpoints I'll go with **Inline Execution** since you've already said "go ahead" and the work is sequential (each task's tests depend on prior commits being intact).