# Editor Theme Registry 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:** Replace the hardcoded 109-line editor-theme menu array in `src/main.js` and the scattered `body.theme-` CSS selector blocks across `styles.css`, `styles-modern.css`, and `styles-concreteinfo.css` with a single pure `ThemeRegistry` module + per-theme CSS files + preload-then-toggle renderer pattern. Add 12 new themes spanning Catppuccin, One Light, Tokyo Night Storm, Synthwave/Outrun, Winter is Coming, Solarized Dark HC, and a Spring seasonal theme. **Architecture:** `src/main/ThemeRegistry.js` is a pure CommonJS module exposing `register/unregister/list/get/categories/lightThemes/darkThemes`. `src/main/ThemeRegistry.bootstrap.js` registers all 37 themes (25 existing + 12 new) at startup. `src/main/themeMenuBuilder.js` converts `ThemeRegistry.list()` + `categories()` into Electron `MenuItem[]`. `src/main.js` requires the bootstrap at startup, replaces the inline menu block with `buildThemeMenu(store)`, and uses `ThemeRegistry.get(id)` to validate stored theme ids. CSS moves to `src/styles/themes/.css` (one file per theme, selector `body.theme-`); `src/index.html` preloads all 37 as `` and the renderer flips `disabled` instead of doing a full page reload. **Tech Stack:** Electron 41.1.1 (CommonJS main + vanilla-JS renderer), Jest 30 + jsdom, ESLint 9 + Prettier. No new NPM deps. **Spec:** `docs/superpowers/specs/2026-09-14-theme-registry-design.md` ## Global Constraints - Electron 41.1.1, electron-builder 26.0.12. Vanilla JS, no bundler. Pure modules in `src/main/`, IPC wired in `src/main.js`. - Preload allow-list at `src/preload.js` for any new IPC channels (no new channels needed — `theme-changed` already exists). - Tests: Jest + jsdom for renderer-side logic, `@jest-environment node` for pure modules. Run `npm test`, `npm run lint`, `npm run format:check`. - Theme persistence: `electron-store` `theme` key (already wired in `src/main.js:3990-3993`), default `'atomonelight'`. - Theme id format: kebab-case lowercase (`catppuccin-mocha`, `winter-is-coming-light`). Validated by `ThemeRegistry.register`. - CSS lives at `src/styles/themes/.css` per the spec (one file per theme; body selector is `body.theme-`). - The 25 existing menu themes keep working unchanged — pure refactor of their CSS into per-theme files (no rule edits). - 12 new themes to add: catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, one-light, tokyo-night-storm, synthwave-84, outrun, winter-is-coming-light, winter-is-coming-dark, solarized-dark-hc, spring-light. - Total at `ThemeRegistry.list().length === 37`. - Categories: `'light' | 'dark' | 'high-contrast' | 'seasonal'`. `isDark` boolean independent of category. --- ## File Structure **New files (39):** ``` src/main/ThemeRegistry.js # pure module — public API src/main/ThemeRegistry.bootstrap.js # registers 25 existing + 12 new themes at startup src/main/themeMenuBuilder.js # converts list() + categories() → MenuItem[] src/styles/themes/_index.js # small helper: list of {id, label} for renderer preload (string-only, no css) src/styles/themes/atomonelight.css # 25 existing theme files (one CSS file per theme) src/styles/themes/github.css src/styles/themes/light.css src/styles/themes/solarized.css src/styles/themes/gruvbox-light.css src/styles/themes/ayu-light.css src/styles/themes/sepia.css src/styles/themes/paper.css src/styles/themes/rosepine-dawn.css src/styles/themes/concrete-light.css src/styles/themes/dark.css src/styles/themes/onedark.css src/styles/themes/dracula.css src/styles/themes/nord.css src/styles/themes/monokai.css src/styles/themes/material.css src/styles/themes/gruvbox-dark.css src/styles/themes/tokyonight.css src/styles/themes/palenight.css src/styles/themes/ayu-dark.css src/styles/themes/ayu-mirage.css src/styles/themes/oceanic-next.css src/styles/themes/cobalt2.css src/styles/themes/concrete-dark.css src/styles/themes/concrete-warm.css src/styles/themes/catppuccin-latte.css # 12 new theme files src/styles/themes/catppuccin-frappe.css src/styles/themes/catppuccin-macchiato.css src/styles/themes/catppuccin-mocha.css src/styles/themes/one-light.css src/styles/themes/tokyo-night-storm.css src/styles/themes/synthwave-84.css src/styles/themes/outrun.css src/styles/themes/winter-is-coming-light.css src/styles/themes/winter-is-coming-dark.css src/styles/themes/solarized-dark-hc.css src/styles/themes/spring-light.css tests/main/theme-registry.test.js # register/unregister/list/get/categories/lightThemes/darkThemes tests/main/theme-registry-bootstrap.test.js # snapshot: list().length === 37 with expected ids in order tests/main/theme-menu-builder.test.js # buildThemeMenu → MenuItem[] structure + click handler wiring tests/theme-renderer-apply.test.js # applyTheme(id) toggles ; preserves body.className ``` **Modified files (5):** ``` src/main.js # require bootstrap + themeMenuBuilder; replace menu block (1137-1245); setTheme validates via ThemeRegistry.get src/index.html # inject 37 tags after styles-concreteinfo.css src/renderer.js # theme-changed handler at line 3005 toggles + sets body.className src/styles.css # remove inlined body.theme- blocks for the 25 menu themes (keep non-themed structural rules) src/styles-modern.css # remove inlined body.theme- selectors for the 25 menu themes src/styles-concreteinfo.css # remove inlined body.theme- selectors for the 10 menu themes that appear here (keep concreteinfo / concreteinfo-dark — legacy non-menu IDs) README.md # Themes section: list all 37 by category ``` --- ## Task 1: `ThemeRegistry` pure module + tests **Files:** - Create: `src/main/ThemeRegistry.js` - Test: `tests/main/theme-registry.test.js` **Interfaces:** - Consumes: nothing (no external deps; pure CommonJS). - Produces: - `register(theme)` → throws on duplicate id or invalid shape - `unregister(id)` → returns true if removed, false if not found - `list()` → `Theme[]` in registration order - `get(id)` → `Theme | null` - `categories()` → `string[]` unique categories in registration order - `lightThemes()` → `Theme[]` where `category === 'light'` - `darkThemes()` → `Theme[]` where `category === 'dark'` - `clear()` → test helper: empties the registry between tests - `Theme` shape: `{ id: string, label: string, category: 'light'|'dark'|'high-contrast'|'seasonal', isDark: boolean }` - [ ] **Step 1: Write the failing test file** at `tests/main/theme-registry.test.js`: ```js /** * @jest-environment node * * ThemeRegistry tests — pure module. Tests run against a freshly-cleared * registry (`clear()` is a test-only helper, but it's exported alongside the * public API because every other pure module in src/main/ exposes the same * `reset` helper for tests — see DocQA, DailyNotes, WorkspaceSearch). */ const ThemeRegistry = require('../../src/main/ThemeRegistry'); const validTheme = (overrides = {}) => ({ id: 'atomonelight', label: 'Atom One Light', category: 'light', isDark: false, ...overrides, }); describe('ThemeRegistry.register', () => { beforeEach(() => ThemeRegistry.clear()); test('adds a theme to list()', () => { ThemeRegistry.register(validTheme()); expect(ThemeRegistry.list()).toEqual([ { id: 'atomonelight', label: 'Atom One Light', category: 'light', isDark: false, }, ]); }); test('throws on duplicate id', () => { ThemeRegistry.register(validTheme()); expect(() => ThemeRegistry.register(validTheme())).toThrow( /duplicate theme id: atomonelight/ ); }); test('throws when theme shape is invalid', () => { expect(() => ThemeRegistry.register({ id: 'x' })).toThrow(/missing label/); expect(() => ThemeRegistry.register({ label: 'X' })).toThrow(/missing id/); expect(() => ThemeRegistry.register({ id: 'x', label: 'X' })).toThrow( /missing category/ ); expect(() => ThemeRegistry.register({ id: 'x', label: 'X', category: 'light' }) ).toThrow(/missing isDark/); expect(() => ThemeRegistry.register({ id: 'BAD ID', label: 'X', category: 'light', isDark: false, }) ).toThrow(/kebab-case/); expect(() => ThemeRegistry.register({ id: 'x', label: 'X', category: 'bogus', isDark: false, }) ).toThrow(/invalid category/); }); test('preserves registration order across many inserts', () => { ThemeRegistry.register(validTheme({ id: 'a', label: 'A' })); ThemeRegistry.register(validTheme({ id: 'b', label: 'B' })); ThemeRegistry.register(validTheme({ id: 'c', label: 'C' })); expect(ThemeRegistry.list().map((t) => t.id)).toEqual(['a', 'b', 'c']); }); }); describe('ThemeRegistry.unregister', () => { beforeEach(() => ThemeRegistry.clear()); test('removes a theme by id', () => { ThemeRegistry.register(validTheme()); expect(ThemeRegistry.unregister('atomonelight')).toBe(true); expect(ThemeRegistry.list()).toEqual([]); }); test('returns false when id not found', () => { expect(ThemeRegistry.unregister('nope')).toBe(false); }); }); describe('ThemeRegistry.get', () => { beforeEach(() => ThemeRegistry.clear()); test('returns the registered theme', () => { ThemeRegistry.register(validTheme()); expect(ThemeRegistry.get('atomonelight')).toEqual({ id: 'atomonelight', label: 'Atom One Light', category: 'light', isDark: false, }); }); test('returns null for unknown id', () => { expect(ThemeRegistry.get('nope')).toBeNull(); }); }); describe('ThemeRegistry.categories', () => { beforeEach(() => ThemeRegistry.clear()); test('returns unique categories in registration order', () => { ThemeRegistry.register(validTheme({ id: 'a', label: 'A', category: 'light' })); ThemeRegistry.register(validTheme({ id: 'b', label: 'B', category: 'dark' })); ThemeRegistry.register(validTheme({ id: 'c', label: 'C', category: 'light' })); ThemeRegistry.register(validTheme({ id: 'd', label: 'D', category: 'high-contrast' })); expect(ThemeRegistry.categories()).toEqual(['light', 'dark', 'high-contrast']); }); }); describe('ThemeRegistry.lightThemes / darkThemes', () => { beforeEach(() => ThemeRegistry.clear()); test('filters by category regardless of isDark', () => { ThemeRegistry.register(validTheme({ id: 'a', label: 'A', category: 'light', isDark: false })); ThemeRegistry.register(validTheme({ id: 'b', label: 'B', category: 'light', isDark: true })); ThemeRegistry.register(validTheme({ id: 'c', label: 'C', category: 'dark', isDark: true })); expect(ThemeRegistry.lightThemes().map((t) => t.id)).toEqual(['a', 'b']); expect(ThemeRegistry.darkThemes().map((t) => t.id)).toEqual(['c']); }); }); ``` - [ ] **Step 2: Run test to verify it fails** with the exact command: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test -- tests/main/theme-registry.test.js ``` Expected output: `Cannot find module '../../src/main/ThemeRegistry'` (module does not exist yet). - [ ] **Step 3: Write minimal implementation** at `src/main/ThemeRegistry.js`: ```js /** * Editor Theme Registry — single source of truth for installed editor themes. * * Pure module: no Electron, no IO, no globals beyond a module-scoped Map. * The bootstrap (`ThemeRegistry.bootstrap.js`) calls `register()` at startup; * `main.js` menu builder calls `list()` + `categories()`; `setTheme()` * validation calls `get()`. * * Theme shape: * { id, label, category: 'light'|'dark'|'high-contrast'|'seasonal', isDark } * * Ids are kebab-case lowercase. Categories are independent of `isDark` — * e.g. `winter-is-coming-light` has `isDark: false` and `category: 'light'`, * but a future seasonal theme might pair `category: 'seasonal'` with * `isDark: true`. Filtering by category is the public contract; `isDark` * is metadata for the renderer. * * @module ThemeRegistry */ const VALID_CATEGORIES = new Set(['light', 'dark', 'high-contrast', 'seasonal']); const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; const themes = new Map(); // id → theme function validate(theme) { if (!theme || typeof theme !== 'object') throw new Error('theme must be an object'); if (typeof theme.id !== 'string' || !theme.id) throw new Error('theme is missing id'); if (!ID_RE.test(theme.id)) { throw new Error(`theme id must be kebab-case lowercase: got "${theme.id}"`); } if (typeof theme.label !== 'string' || !theme.label) { throw new Error(`theme "${theme.id}" is missing label`); } if (!VALID_CATEGORIES.has(theme.category)) { throw new Error( `theme "${theme.id}" has invalid category "${theme.category}" (allowed: ${[...VALID_CATEGORIES].join(', ')})` ); } if (typeof theme.isDark !== 'boolean') { throw new Error(`theme "${theme.id}" is missing isDark boolean`); } } function register(theme) { validate(theme); if (themes.has(theme.id)) { throw new Error(`duplicate theme id: ${theme.id}`); } themes.set(theme.id, { id: theme.id, label: theme.label, category: theme.category, isDark: theme.isDark, }); return theme; } function unregister(id) { return themes.delete(id); } function list() { return [...themes.values()]; } function get(id) { return themes.get(id) || null; } function categories() { const seen = new Set(); const out = []; for (const t of themes.values()) { if (!seen.has(t.category)) { seen.add(t.category); out.push(t.category); } } return out; } function lightThemes() { return list().filter((t) => t.category === 'light'); } function darkThemes() { return list().filter((t) => t.category === 'dark'); } /** Test-only: empty the registry between tests. */ function clear() { themes.clear(); } module.exports = { register, unregister, list, get, categories, lightThemes, darkThemes, clear, }; ``` - [ ] **Step 4: Run test to verify it passes** with: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test -- tests/main/theme-registry.test.js ``` Expected output: all 11 test cases pass. - [ ] **Step 5: Commit** ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/main/ThemeRegistry.js tests/main/theme-registry.test.js && git commit -m "feat(theme-registry): pure ThemeRegistry module with full API" ``` --- ## Task 2: `ThemeRegistry.bootstrap` + 37-theme snapshot test **Files:** - Create: `src/main/ThemeRegistry.bootstrap.js` - Test: `tests/main/theme-registry-bootstrap.test.js` **Interfaces:** - Consumes: `ThemeRegistry.register` (Task 1). - Produces: side-effect at module load time — registers all 37 themes (25 existing menu themes + 12 new). - [ ] **Step 1: Write the failing snapshot test** at `tests/main/theme-registry-bootstrap.test.js`: ```js /** * @jest-environment node * * Bootstrap snapshot — at startup, ThemeRegistry.list() returns exactly 37 * themes (25 existing menu themes + 12 new) in the expected order. */ const ThemeRegistry = require('../../src/main/ThemeRegistry'); const EXPECTED_IDS = [ // Existing — light 'atomonelight', 'github', 'light', 'solarized', 'gruvbox-light', 'ayu-light', 'sepia', 'paper', 'rosepine-dawn', 'concrete-light', // Existing — dark 'dark', 'onedark', 'dracula', 'nord', 'monokai', 'material', 'gruvbox-dark', 'tokyonight', 'palenight', 'ayu-dark', 'ayu-mirage', 'oceanic-next', 'cobalt2', 'concrete-dark', 'concrete-warm', // New — Catppuccin 'catppuccin-latte', 'catppuccin-frappe', 'catppuccin-macchiato', 'catppuccin-mocha', // New — One Light + Tokyo Night Storm 'one-light', 'tokyo-night-storm', // New — Synthwave / Outrun 'synthwave-84', 'outrun', // New — Winter is Coming 'winter-is-coming-light', 'winter-is-coming-dark', // New — Solarized HC + Spring seasonal 'solarized-dark-hc', 'spring-light', ]; describe('ThemeRegistry.bootstrap', () => { beforeEach(() => ThemeRegistry.clear()); test('registers exactly 37 themes in the expected order', () => { require('../../src/main/ThemeRegistry.bootstrap'); expect(ThemeRegistry.list().map((t) => t.id)).toEqual(EXPECTED_IDS); }); test('every theme has a valid shape', () => { require('../../src/main/ThemeRegistry.bootstrap'); for (const t of ThemeRegistry.list()) { expect(typeof t.id).toBe('string'); expect(typeof t.label).toBe('string'); expect(['light', 'dark', 'high-contrast', 'seasonal']).toContain(t.category); expect(typeof t.isDark).toBe('boolean'); } }); test('expected category counts', () => { require('../../src/main/ThemeRegistry.bootstrap'); const counts = ThemeRegistry.list().reduce((acc, t) => { acc[t.category] = (acc[t.category] || 0) + 1; return acc; }, {}); // 10 existing light + one-light + 2 winter-is-coming-light + spring-light + catppuccin-latte = 15 light expect(counts.light).toBe(15); // 15 existing dark + 3 catppuccin dark + tokyo-night-storm + synthwave-84 + outrun + winter-is-coming-dark = 21 dark expect(counts.dark).toBe(21); expect(counts['high-contrast']).toBe(1); expect(counts.seasonal).toBe(0); // spring-light is category=light per spec, family handled separately }); }); ``` - [ ] **Step 2: Run test to verify it fails** with: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test -- tests/main/theme-registry-bootstrap.test.js ``` Expected output: `Cannot find module '../../src/main/ThemeRegistry.bootstrap'`. - [ ] **Step 3: Write the bootstrap module** at `src/main/ThemeRegistry.bootstrap.js`: ```js /** * Editor Theme Registry bootstrap — registers all shipped themes at startup. * * Importing this module has the side effect of populating the registry. * `src/main.js` requires this once during startup. Test files require it * after `ThemeRegistry.clear()` to assert the snapshot. * * Theme ids are kebab-case. Categories: 'light' | 'dark' | 'high-contrast' * | 'seasonal'. `isDark` is independent of category — see spec note on * winter-is-coming-light (light-category, dark-not-required) and the future * seasonal-with-dark possibility. * * @module ThemeRegistry.bootstrap */ const ThemeRegistry = require('./ThemeRegistry'); const THEMES = [ // ---- 25 existing menu themes (refactor — no behavioural change) ── // Light { id: 'atomonelight', label: 'Atom One Light (Default)', category: 'light', isDark: false }, { id: 'github', label: 'GitHub Light', category: 'light', isDark: false }, { id: 'light', label: 'Light', category: 'light', isDark: false }, { id: 'solarized', label: 'Solarized Light', category: 'light', isDark: false }, { id: 'gruvbox-light', label: 'Gruvbox Light', category: 'light', isDark: false }, { id: 'ayu-light', label: 'Ayu Light', category: 'light', isDark: false }, { id: 'sepia', label: 'Sepia', category: 'light', isDark: false }, { id: 'paper', label: 'Paper', category: 'light', isDark: false }, { id: 'rosepine-dawn', label: 'Rose Pine Dawn', category: 'light', isDark: false }, { id: 'concrete-light', label: 'Concrete Light', category: 'light', isDark: false }, // Dark { id: 'dark', label: 'Dark', category: 'dark', isDark: true }, { id: 'onedark', label: 'One Dark', category: 'dark', isDark: true }, { id: 'dracula', label: 'Dracula', category: 'dark', isDark: true }, { id: 'nord', label: 'Nord', category: 'dark', isDark: true }, { id: 'monokai', label: 'Monokai', category: 'dark', isDark: true }, { id: 'material', label: 'Material', category: 'dark', isDark: true }, { id: 'gruvbox-dark', label: 'Gruvbox Dark', category: 'dark', isDark: true }, { id: 'tokyonight', label: 'Tokyo Night', category: 'dark', isDark: true }, { id: 'palenight', label: 'Palenight', category: 'dark', isDark: true }, { id: 'ayu-dark', label: 'Ayu Dark', category: 'dark', isDark: true }, { id: 'ayu-mirage', label: 'Ayu Mirage', category: 'dark', isDark: true }, { id: 'oceanic-next', label: 'Oceanic Next', category: 'dark', isDark: true }, { id: 'cobalt2', label: 'Cobalt2', category: 'dark', isDark: true }, { id: 'concrete-dark', label: 'Concrete Dark', category: 'dark', isDark: true }, { id: 'concrete-warm', label: 'Concrete Warm', category: 'dark', isDark: true }, // ---- 12 new themes ── // Catppuccin (4) { id: 'catppuccin-latte', label: 'Catppuccin Latte', category: 'light', isDark: false }, { id: 'catppuccin-frappe', label: 'Catppuccin Frappé', category: 'dark', isDark: true }, { id: 'catppuccin-macchiato', label: 'Catppuccin Macchiato', category: 'dark', isDark: true }, { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', category: 'dark', isDark: true }, // One Light + Tokyo Night Storm { id: 'one-light', label: 'One Light', category: 'light', isDark: false }, { id: 'tokyo-night-storm', label: 'Tokyo Night Storm', category: 'dark', isDark: true }, // Synthwave / Outrun { id: 'synthwave-84', label: "Synthwave '84", category: 'dark', isDark: true }, { id: 'outrun', label: 'Outrun', category: 'dark', isDark: true }, // Winter is Coming (light + dark) { id: 'winter-is-coming-light', label: 'Winter is Coming (Light)', category: 'light', isDark: false }, { id: 'winter-is-coming-dark', label: 'Winter is Coming (Dark)', category: 'dark', isDark: true }, // Solarized HC + Spring seasonal { id: 'solarized-dark-hc', label: 'Solarized Dark (High Contrast)', category: 'high-contrast', isDark: true }, { id: 'spring-light', label: 'Spring Light', category: 'seasonal', isDark: false }, ]; for (const t of THEMES) ThemeRegistry.register(t); module.exports = { THEMES }; ``` - [ ] **Step 4: Run test to verify it passes** with: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test -- tests/main/theme-registry-bootstrap.test.js ``` Expected output: all 3 test cases pass; total theme count is 37. - [ ] **Step 5: Commit** ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/main/ThemeRegistry.bootstrap.js tests/main/theme-registry-bootstrap.test.js && git commit -m "feat(theme-registry): bootstrap with 25 existing + 12 new themes" ``` --- ## Task 3: `themeMenuBuilder` + tests **Files:** - Create: `src/main/themeMenuBuilder.js` - Test: `tests/main/theme-menu-builder.test.js` **Interfaces:** - Consumes: `ThemeRegistry.list()`, `ThemeRegistry.categories()` (Task 1), `setTheme` callback (lives in `main.js`), `store.get('theme')` for the currently-selected id. - Produces: `buildThemeMenu({ setTheme, getCurrentThemeId })` → `MenuItem[]` matching the previous hardcoded shape: one submenu per category (light → dark → high-contrast → seasonal), each item is `{ label, type: 'radio', checked, click: () => setTheme(id) }`, with a `{ type: 'separator' }` between categories. - [ ] **Step 1: Write the failing test file** at `tests/main/theme-menu-builder.test.js`: ```js /** * @jest-environment node * * themeMenuBuilder tests — pure module. We pass in a fake `setTheme` and a * fake `getCurrentThemeId` so we never touch electron-store or Electron. */ const ThemeRegistry = require('../../src/main/ThemeRegistry'); const { buildThemeMenu } = require('../../src/main/themeMenuBuilder'); describe('buildThemeMenu', () => { beforeEach(() => ThemeRegistry.clear()); test('groups themes by category in registry order', () => { ThemeRegistry.register({ id: 'l1', label: 'L1', category: 'light', isDark: false }); ThemeRegistry.register({ id: 'l2', label: 'L2', category: 'light', isDark: false }); ThemeRegistry.register({ id: 'd1', label: 'D1', category: 'dark', isDark: true }); ThemeRegistry.register({ id: 'd2', label: 'D2', category: 'dark', isDark: true }); ThemeRegistry.register({ id: 'hc1', label: 'HC1', category: 'high-contrast', isDark: true }); ThemeRegistry.register({ id: 's1', label: 'S1', category: 'seasonal', isDark: false }); const setTheme = jest.fn(); const items = buildThemeMenu({ setTheme, getCurrentThemeId: () => 'd1' }); // Expected order: light (2 items), separator, dark (2 items), separator, hc (1), separator, seasonal (1) expect(items.map((i) => i.label || i.type)).toEqual([ 'L1', 'L2', 'separator', 'D1', 'D2', 'separator', 'HC1', 'separator', 'S1', ]); }); test('marks the currently-selected theme as checked=true', () => { ThemeRegistry.register({ id: 'a', label: 'A', category: 'light', isDark: false }); ThemeRegistry.register({ id: 'b', label: 'B', category: 'dark', isDark: true }); const items = buildThemeMenu({ setTheme: jest.fn(), getCurrentThemeId: () => 'b' }); const aItem = items.find((i) => i.label === 'A'); const bItem = items.find((i) => i.label === 'B'); expect(aItem.type).toBe('radio'); expect(aItem.checked).toBe(false); expect(bItem.type).toBe('radio'); expect(bItem.checked).toBe(true); }); test('click handler calls setTheme with the theme id', () => { ThemeRegistry.register({ id: 'a', label: 'A', category: 'light', isDark: false }); const setTheme = jest.fn(); const items = buildThemeMenu({ setTheme, getCurrentThemeId: () => 'a' }); items[0].click(); expect(setTheme).toHaveBeenCalledWith('a'); }); test('falls back to no theme checked when current id unknown', () => { ThemeRegistry.register({ id: 'a', label: 'A', category: 'light', isDark: false }); const items = buildThemeMenu({ setTheme: jest.fn(), getCurrentThemeId: () => 'deleted-theme', }); expect(items.find((i) => i.label === 'A').checked).toBe(false); }); }); ``` - [ ] **Step 2: Run test to verify it fails** with: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test -- tests/main/theme-menu-builder.test.js ``` Expected output: `Cannot find module '../../src/main/themeMenuBuilder'`. - [ ] **Step 3: Write the implementation** at `src/main/themeMenuBuilder.js`: ```js /** * Editor Theme menu builder — converts ThemeRegistry output into Electron * MenuItem[] suitable for the View → Theme submenu. * * Pure: takes `setTheme` + `getCurrentThemeId` as injected dependencies so * the builder never touches electron-store or `mainWindow`. Tested in * isolation under `@jest-environment node`. * * Menu shape (matches the previous hardcoded block in src/main.js:1137-1245): * [item, item, …, { type: 'separator' }, item, item, …, separator, …] * grouped by category in registry order, with separators between non-empty * categories. Each theme item is a radio-style MenuItem so Electron shows a * checkmark next to the active theme. * * @module themeMenuBuilder */ const ThemeRegistry = require('./ThemeRegistry'); /** * @param {object} deps * @param {(id: string) => void} deps.setTheme * @param {() => string} deps.getCurrentThemeId * @returns {Array} Electron MenuItemTemplate[] */ function buildThemeMenu({ setTheme, getCurrentThemeId }) { const currentId = getCurrentThemeId(); const items = []; const cats = ThemeRegistry.categories(); let nonEmptySeen = 0; for (const cat of cats) { const inCat = ThemeRegistry.list().filter((t) => t.category === cat); if (inCat.length === 0) continue; if (nonEmptySeen > 0) items.push({ type: 'separator' }); nonEmptySeen++; for (const t of inCat) { items.push({ label: t.label, type: 'radio', checked: t.id === currentId, click: () => setTheme(t.id), }); } } return items; } module.exports = { buildThemeMenu }; ``` - [ ] **Step 4: Run test to verify it passes** with: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test -- tests/main/theme-menu-builder.test.js ``` Expected output: all 4 test cases pass. - [ ] **Step 5: Commit** ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/main/themeMenuBuilder.js tests/main/theme-menu-builder.test.js && git commit -m "feat(theme-registry): buildThemeMenu converts registry → Electron MenuItem[]" ``` --- ## Task 4: Wire registry + bootstrap + menu builder into `src/main.js` **Files:** - Modify: `src/main.js:7-20` (add new requires) - Modify: `src/main.js` startup block (require `ThemeRegistry.bootstrap` once after the other requires) - Modify: `src/main.js:1137-1245` (delete the hardcoded 109-line theme menu block; insert `themeMenu` substitution) - Modify: `src/main.js:3952-3955` (validate theme id via `ThemeRegistry.get`) **Interfaces:** - Consumes: `ThemeRegistry` (Task 1), `ThemeRegistry.bootstrap` (Task 2), `buildThemeMenu` (Task 3), `store` (already in scope), `mainWindow` (already in scope). - Produces: a `themeMenu` array plugged into the existing View menu template; `setTheme()` falls back to `'atomonelight'` when the stored id no longer exists. - [ ] **Step 1: Add new requires at top of `src/main.js`** (after line 20, before line 21 `const PandocArgs`): ```js const ThemeRegistry = require('./main/ThemeRegistry'); const { buildThemeMenu } = require('./main/themeMenuBuilder'); require('./main/ThemeRegistry.bootstrap'); ``` - [ ] **Step 2: Run `npm run lint` to confirm the new requires don't break ESLint**: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run lint -- src/main.js 2>&1 | tail -10 ``` Expected output: clean (no errors related to the new requires — they use the same `./main/X` shape already in use). - [ ] **Step 3: Replace the hardcoded theme menu block at `src/main.js:1137-1245`** with the registry-driven version. Read lines 1136-1246 first to confirm exact whitespace, then Edit: `old_string` (the 109-line block — copy verbatim from lines 1137-1245): ```js label: 'Theme', submenu: [ // Light Themes (grouped first) { label: 'Atom One Light (Default)', click: () => setTheme('atomonelight'), }, { label: 'GitHub Light', click: () => setTheme('github'), }, { label: 'Light', click: () => setTheme('light'), }, { label: 'Solarized Light', click: () => setTheme('solarized'), }, { label: 'Gruvbox Light', click: () => setTheme('gruvbox-light'), }, { label: 'Ayu Light', click: () => setTheme('ayu-light'), }, { label: 'Sepia', click: () => setTheme('sepia'), }, { label: 'Paper', click: () => setTheme('paper'), }, { label: 'Rose Pine Dawn', click: () => setTheme('rosepine-dawn'), }, { label: 'Concrete Light', click: () => setTheme('concrete-light'), }, { type: 'separator', }, // Dark Themes { label: 'Dark', click: () => setTheme('dark'), }, { label: 'One Dark', click: () => setTheme('onedark'), }, { label: 'Dracula', click: () => setTheme('dracula'), }, { label: 'Nord', click: () => setTheme('nord'), }, { label: 'Monokai', click: () => setTheme('monokai'), }, { label: 'Material', click: () => setTheme('material'), }, { label: 'Gruvbox Dark', click: () => setTheme('gruvbox-dark'), }, { label: 'Tokyo Night', click: () => setTheme('tokyonight'), }, { label: 'Palenight', click: () => setTheme('palenight'), }, { label: 'Ayu Dark', click: () => setTheme('ayu-dark'), }, { label: 'Ayu Mirage', click: () => setTheme('ayu-mirage'), }, { label: 'Oceanic Next', click: () => setTheme('oceanic-next'), }, { label: 'Cobalt2', click: () => setTheme('cobalt2'), }, { label: 'Concrete Dark', click: () => setTheme('concrete-dark'), }, { label: 'Concrete Warm', click: () => setTheme('concrete-warm'), }, ], }, ``` `new_string`: ```js label: 'Theme', submenu: buildThemeMenu({ setTheme: (id) => setTheme(id), getCurrentThemeId: () => store.get('theme', 'atomonelight'), }), }, ``` - [ ] **Step 4: Fix `setTheme()` to validate via the registry** at `src/main.js:3952-3955`. Edit: `old_string`: ```js function setTheme(theme) { store.set('theme', theme); mainWindow.webContents.send('theme-changed', theme); } ``` `new_string`: ```js function setTheme(theme) { // Stored id may not exist anymore (e.g. after downgrade or if a theme id // was renamed). Fall back to the default so the UI never goes blank. const safeId = ThemeRegistry.get(theme) ? theme : 'atomonelight'; if (safeId !== theme) { console.warn(`[theme] unknown theme id "${theme}", falling back to ${safeId}`); } store.set('theme', safeId); mainWindow.webContents.send('theme-changed', safeId); } ``` - [ ] **Step 5: Run lint + format + test** to confirm no regressions: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run lint && npm test -- tests/main/theme-registry.test.js tests/main/theme-registry-bootstrap.test.js tests/main/theme-menu-builder.test.js 2>&1 | tail -20 ``` Expected output: lint clean, all 18 theme-related tests pass. - [ ] **Step 6: Commit** ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/main.js && git commit -m "refactor(main): drive View → Theme submenu from ThemeRegistry" ``` --- ## Task 5: Migrate 25 existing theme CSS blocks → per-theme files **Files:** - Create: `src/styles/themes/atomonelight.css` - Create: `src/styles/themes/github.css` - Create: `src/styles/themes/light.css` - Create: `src/styles/themes/solarized.css` - Create: `src/styles/themes/gruvbox-light.css` - Create: `src/styles/themes/ayu-light.css` - Create: `src/styles/themes/sepia.css` - Create: `src/styles/themes/paper.css` - Create: `src/styles/themes/rosepine-dawn.css` - Create: `src/styles/themes/concrete-light.css` - Create: `src/styles/themes/dark.css` - Create: `src/styles/themes/onedark.css` - Create: `src/styles/themes/dracula.css` - Create: `src/styles/themes/nord.css` - Create: `src/styles/themes/monokai.css` - Create: `src/styles/themes/material.css` - Create: `src/styles/themes/gruvbox-dark.css` - Create: `src/styles/themes/tokyonight.css` - Create: `src/styles/themes/palenight.css` - Create: `src/styles/themes/ayu-dark.css` - Create: `src/styles/themes/ayu-mirage.css` - Create: `src/styles/themes/oceanic-next.css` - Create: `src/styles/themes/cobalt2.css` - Create: `src/styles/themes/concrete-dark.css` - Create: `src/styles/themes/concrete-warm.css` - Modify: `src/styles.css` — remove the 23 inlined `body.theme-` blocks (keep all non-themed structural rules; keep non-menu legacy rules if any). - Modify: `src/styles-modern.css` — remove the inlined `body.theme-` selectors. - Modify: `src/styles-concreteinfo.css` — remove the inlined `body.theme-` selectors that match the 25 menu theme ids; keep `body.theme-concreteinfo` and `body.theme-concreteinfo-dark` rules untouched — they are legacy non-menu selectors. **Interfaces:** - Consumes: the existing rules from the three stylesheets (extracted verbatim — pure code motion, no rule edits). - Produces: 25 standalone CSS files. Each contains the same `body.theme-` rules that were previously inlined; an opening comment naming the theme; no other content. For each of the 25 themes the worker does: 1. Grep the existing selectors across the three stylesheets: ```bash cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && grep -n "body.theme-\|body.theme- " src/styles.css src/styles-modern.css src/styles-concreteinfo.css ``` 2. Copy each `body.theme- { … }` block (and any descendant `body.theme- .foo { … }` blocks that appear ONLY under that theme — i.e. not part of a multi-theme selector list like `body.theme-a .x, body.theme-b .x`) into `src/styles/themes/.css` verbatim, prefixed with a one-line comment: ```css /* Theme: