diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce7ce42..419a74d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,9 +68,27 @@ jobs: - name: Bundle MarkItDown (optional, best effort) # Windows runners ship python + venv; non-fatal on failure — the - # package then omits the bundled binary by design - run: npm run bundle:markitdown + # package then omits the bundled binary by design. We capture the + # log so a silent failure is visible in the GitHub Actions step UI + # instead of disappearing into stdout. + run: | + python -m pip install --upgrade pip wheel setuptools 2>&1 | tee bundle-pip.log + npm run bundle:markitdown 2>&1 | tee bundle-markitdown.log + if [ ! -f bin/win32/markitdown.exe ]; then + echo "::warning::bin/win32/markitdown.exe was not produced — see bundle-markitdown.log" + fi continue-on-error: true + shell: bash + + - name: Upload MarkItDown bundle logs (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: windows-bundle-logs + path: | + bundle-pip.log + bundle-markitdown.log + retention-days: 5 - name: Run tests run: npm test diff --git a/README.md b/README.md index 15bcd43..baaf3c8 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ A powerful cross-platform Markdown editor and document converter powered by Pand - **ASCII Art Generator** - Create text banners and diagrams - **Word templates** - Use custom Word templates for enhanced exports - **Import documents** - Import from 30+ formats (DOCX, PDF, HTML, etc.) -- **MarkItDown import** - Any file → Markdown via [Microsoft MarkItDown](https://github.com/microsoft/markitdown): PDF, DOCX, PPTX, XLSX, Outlook .msg, EPUB, images, ZIP (audio/OCR with the `[all]` extras) +- **MarkItDown import** - Any file → Markdown via [Microsoft MarkItDown](https://github.com/microsoft/markitdown). **Bundled** (MIT + PSF Python runtime) — no installation required for the core formats (PDF, DOCX, PPTX, XLSX, Outlook .msg, EPUB, HTML, images, ZIP, CSV, JSON, XML). For **audio transcription** and **OCR**, install `markitdown[all]` system-side (multi-GB ML models; not bundled). - **Excel export** - Markdown tables to native .xlsx workbooks (one sheet per table) - **AI Assistant** - Multi-provider AI help (OpenAI/Anthropic/Ollama/LM Studio): chat panel, summarize/improve/translate commands, grammar proofreading - **Inline comments** - Anchor-based document comments in `.comments/` sidecars with F8 navigation @@ -71,9 +71,10 @@ A powerful cross-platform Markdown editor and document converter powered by Pand ## Installation ### Prerequisites -- [Node.js](https://nodejs.org/) (v16 or later) -- [Pandoc](https://pandoc.org/installing.html) (required for export functionality) -- Optional: [MarkItDown](https://github.com/microsoft/markitdown) (`pip install "markitdown[all]"`) for any-file → Markdown import +- [Node.js](https://nodejs.org/) (v16 or later) — only for development builds +- [Pandoc](https://pandoc.org/installing.html) — **bundled** inside the app, no install needed +- [MarkItDown](https://github.com/microsoft/markitdown) — **bundled** inside the app (MIT + embedded PSF Python runtime via PyInstaller), no install needed for PDF / DOCX / PPTX / XLSX / Outlook / EPUB / HTML / images / ZIP / CSV / JSON / XML +- Optional for advanced import only: `pip install "markitdown[all]"` adds **audio transcription** (Whisper) and **OCR** (EasyOCR/Tesseract) — these are multi-GB model downloads and are not bundled for size reasons ### Install Dependencies ```bash diff --git a/docs/superpowers/plans/2026-09-14-flowchart-editor.md b/docs/superpowers/plans/2026-09-14-flowchart-editor.md new file mode 100644 index 0000000..40c5ab1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-flowchart-editor.md @@ -0,0 +1,2399 @@ +# Flow Chart Editor 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:** Add a sidebar-panel flow chart editor that lets users build Mermaid `flowchart` graphs visually (drag nodes, connect edges, edit labels in-place) and inserts the generated source at the editor cursor. + +**Architecture:** Renderer-only feature. A new pure `flowchart-store.js` holds the graph (`{ nodes, edges }`) with injectable IO for persistence. `flowchart-mermaid.js` translates graph → Mermaid source. `flowchart-shapes.js` renders the 5 SVG shape templates. `flowchart-canvas.js` owns the SVG, pointer events, and hit-testing. `sidebar/flowchart-panel.js` mounts canvas + preview, wires keyboard shortcuts, debounces preview (250ms) and persistence (500ms), and reuses the existing `insert-content` IPC channel for "Insert at Cursor". No main-process changes; Mermaid is already bundled. + +**Tech Stack:** Electron 41.10.7, vanilla JS (no bundler), Mermaid 11.12.3 (already bundled), Jest 30 + jsdom, Prettier 2-space single-quote semicolon 100-col. + +**Spec:** docs/superpowers/specs/2026-09-14-flowchart-editor-design.md + +## Global Constraints + +- Electron 41.10.7, electron-builder 26.15.3 +- Vanilla JS, no bundler. Renderer is a single `src/renderer.js` (5,361 lines) — keep additions in their own files where possible +- Renderer-only feature: NO new main-process modules, NO new IPC channels (Mermaid is already bundled and rendered in the preview pane at `src/renderer.js:1106-1142`) +- Sidebar panel pattern: `src/sidebar/-panel.js` exports `renderPanel(container, deps)`; registered via `sidebarManager.registerPanel(id, { title, render, icon })` at `src/renderer.js:2300` +- Sidebar styles live in `src/styles-sidebar.css` (top-level, NOT inside `src/styles/`) +- Tests: Jest + jsdom. Pure modules testable; jsdom for DOM/canvas. Run `npm test`, `npm run lint`, `npm run format:check` +- 5 node shapes (process, decision, terminator, subroutine, document) → Mermaid syntax: `[Label]`, `{Label}`, `([Label])`, `[[Label]]`, `[/Label/]` +- 3 edge kinds (solid, dotted, thick) → Mermaid syntax: `-->`, `-.->`, `==>` +- Persistence: `/flowchart-session.json` via injected IO `{ persistencePath, readFile, writeFile, now }` +- Undo/redo: bounded snapshot stack, depth 50 +- Mermaid emission: always `flowchart TD` for v1 +- Debounce: 250ms preview re-render, 500ms persistence write +- Keyboard shortcuts panel-scoped: Ctrl+Z undo, Ctrl+Shift+Z redo, Delete removes selected +- The `insert-content` IPC channel already exists (`src/main.js:6081`, consumed in `src/renderer.js:6780`); reuse for "Insert at Cursor" +- Sidebar rail button style matches `src/index.html:2562-2598` (Daily Notes example) + +## File Structure + +### New files + +| File | Role | +|---|---| +| `src/flowchart/flowchart-store.js` | Pure data module. Graph = `{ nodes, edges }`. Node: `{ id, kind, x, y, label }`. Edge: `{ id, fromNodeId, toNodeId, kind: 'solid'\|'dotted'\|'thick', label? }`. Exposes `create`, `addNode`, `moveNode`, `setNodeLabel`, `setNodeKind`, `removeNode`, `connect`, `disconnect`, `setEdgeKind`, `setEdgeLabel`, `undo`, `redo`, `subscribe`, `serialize`, `deserialize`, `toJSON`, `getGraph`. Constructor takes injected IO `{ persistencePath, readFile, writeFile, now }`. | +| `src/flowchart/flowchart-shapes.js` | Pure SVG templates. `shapeSvg(kind, x, y, width, height) → string`. 5 shapes: process=``, decision=`` (diamond), terminator=`` (stadium), subroutine=`` with double border, document=`` (parallelogram). `LABEL_PADDING_X`, `LABEL_PADDING_Y`, `DEFAULT_WIDTH`, `DEFAULT_HEIGHT` exported constants. | +| `src/flowchart/flowchart-mermaid.js` | Pure translator. `toMermaid(graph) → string` (header `flowchart TD`, then node declarations, then edges). `escapeLabel(s)` exported for testing. Throws `Error` with kind name on unknown node kind. | +| `src/flowchart/flowchart-canvas.js` | SVG canvas. Owns an `` element. Exports `createCanvas(container, store, opts) → { destroy, getSvg }`. Renders nodes as `` with shape + label ``. Renders edges as ``. Pointer events: drag to move, double-click to edit label (inline `
+
+ + +
+
+
+
+

+          
+
+
+
+ `; + + const canvasHost = container.querySelector('.flowchart-canvas-host'); + const previewSourceEl = container.querySelector('.flowchart-preview-source'); + const previewRenderEl = container.querySelector('.flowchart-preview-render'); + const insertBtn = container.querySelector('.flowchart-insert-btn'); + const statusEl = container.querySelector('.flowchart-status'); + + let selectedNodeId = null; + let selectedEdgeId = null; + + const store = createStore({ + persistencePath: persistencePath(getUserDataPath), + readFile, + writeFile, + now: () => Date.now(), + }); + + // Hydrate from disk (defensively). + readFile(persistencePath(getUserDataPath)) + .then((json) => { + if (json) store.deserialize(json); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.warn('flowchart-panel: failed to read session', err); + }); + + const canvas = createCanvas(canvasHost, store, { + onEdgeClick: (edgeId) => { + // Click an edge → prompt for kind and (optional) label. v2 can replace + // this with a real popover menu; the prompts are intentionally simple. + selectedEdgeId = edgeId; + selectedNodeId = null; + const edge = store.getGraph().edges.find((e) => e.id === edgeId); + if (!edge) return; + const nextKind = window.prompt( + 'Edge kind (solid, dotted, thick):', + edge.kind + ); + if (nextKind && ['solid', 'dotted', 'thick'].includes(nextKind)) { + store.setEdgeKind(edgeId, nextKind); + } + const nextLabel = window.prompt('Edge label (empty to clear):', edge.label || ''); + if (nextLabel !== null) { + store.setEdgeLabel(edgeId, nextLabel); + } + }, + onShapeMenu: (nodeId) => { + // Prompt for a new shape kind. v2: replace with a real context menu. + const next = window.prompt( + 'New shape (process, decision, terminator, subroutine, document):' + ); + if (next) store.setNodeKind(nodeId, next); + }, + }); + + const debouncedPreview = debounce(() => { + const source = toMermaid(store.getGraph()); + previewSourceEl.textContent = source; + try { + renderMermaid(source, previewRenderEl); + } catch (err) { + previewRenderEl.textContent = `Preview error: ${err && err.message ? err.message : 'unknown'}`; + } + }, PREVIEW_DEBOUNCE_MS); + + const debouncedPersist = debounce(() => { + writeFile(persistencePath(getUserDataPath), store.serialize()).catch((err) => { + if (statusEl) statusEl.textContent = `Save failed: ${err.message || err}`; + }); + }, PERSIST_DEBOUNCE_MS); + + store.subscribe(() => { + debouncedPreview(); + debouncedPersist(); + }); + + insertBtn.addEventListener('click', () => { + const source = toMermaid(store.getGraph()); + insertAtCursor('```mermaid\n' + source + '\n```'); + }); + + // Keyboard shortcuts — panel-scoped. + container.addEventListener('keydown', (ev) => { + if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') { + ev.preventDefault(); + if (ev.shiftKey) store.redo(); + else store.undo(); + return; + } + if ((ev.key === 'Delete' || ev.key === 'Backspace') && selectedNodeId) { + ev.preventDefault(); + store.removeNode(selectedNodeId); + selectedNodeId = null; + } + }); + + return { + getStore: () => store, + getSvg: () => canvas.getSvg(), + selectNode: (id) => { + selectedNodeId = id; + }, + selectEdge: (id) => { + selectedEdgeId = id; + }, + destroy: () => { + canvas.destroy(); + }, + }; +} + +module.exports = { renderFlowChartPanel }; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npx jest tests/flowchart-panel.test.js 2>&1 | tail -15 +``` + +Expected: `Tests: … passed`. + +- [ ] **Step 5: Commit** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/sidebar/flowchart-panel.js tests/flowchart-panel.test.js && git commit -m "feat(sidebar): flowchart-panel — canvas + preview + persistence + insert" +``` + +### Task 6: Register panel + sidebar rail button + styles + +**Files:** +- Modify: `src/renderer.js:2409` (after the `history` panel registration) +- Modify: `src/index.html:2598` (after the daily-notes rail button) +- Modify: `src/styles-sidebar.css` (append new selectors) + +**Interfaces:** +- Consumes: `renderFlowChartPanel` from Task 5, `sidebarManager` (already a singleton), `tabManager.insertAtCursor` (already exposed), IPC for fs read/write +- Produces: `flowchart` panel registered in the sidebar rail; the rail button opens it on click + +- [ ] **Step 1: Add the rail button to `src/index.html`** + +After the closing `` of the `data-panel="daily-notes"` entry at `src/index.html:2598`, insert: + +```html + + +``` + +- [ ] **Step 2: Append styles to `src/styles-sidebar.css`** + +Append (at end of file): + +```css +/* === Flow Chart Editor panel === */ +.flowchart-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + outline: none; +} +.flowchart-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-bottom: 1px solid var(--border-color, #444); +} +.flowchart-insert-btn { + padding: 4px 10px; + border: 1px solid var(--accent, #4a9eff); + background: var(--accent, #4a9eff); + color: var(--accent-fg, #fff); + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} +.flowchart-status { + margin-left: auto; + font-size: 11px; + color: var(--text-muted, #888); +} +.flowchart-split { + display: flex; + flex: 1; + min-height: 0; +} +.flowchart-canvas-host { + flex: 7; + min-width: 0; + position: relative; + background: var(--bg-secondary, #1e1e1e); +} +.flowchart-canvas-host svg.flowchart-canvas { + width: 100%; + height: 100%; + display: block; + cursor: crosshair; +} +.flowchart-preview-host { + flex: 3; + min-width: 0; + display: flex; + flex-direction: column; + border-left: 1px solid var(--border-color, #444); +} +.flowchart-preview-source { + flex: 1; + margin: 0; + padding: 8px; + font-family: var(--mono-font, monospace); + font-size: 11px; + overflow: auto; + white-space: pre; + border-bottom: 1px solid var(--border-color, #444); +} +.flowchart-preview-render { + flex: 1; + padding: 8px; + overflow: auto; +} +.flowchart-node { + cursor: grab; +} +.flowchart-node.selected rect, +.flowchart-node.selected polygon { + stroke: var(--accent, #4a9eff); + stroke-width: 2; +} +.flowchart-edge.selected { + stroke: var(--accent, #4a9eff); +} +.flowchart-label-input { + font-size: 13px; + text-align: center; + border: 1px solid var(--accent, #4a9eff); + background: var(--bg-primary, #fff); + color: inherit; + z-index: 100; +} +``` + +- [ ] **Step 3: Wire the panel into `src/renderer.js`** + +Add an IPC bridge for filesystem read/write (renderer-only persistence — matches the pattern in `autosave-client.js`). First, near the top of `src/renderer.js` where other IPC helpers are imported, add: + +```javascript +// Lazy-loaded: filesystem helpers used by the flowchart panel for +// /flowchart-session.json auto-save. +const flowchartIO = { + getUserDataPath: () => ipcRenderer.invoke('get-user-data-path'), + readFile: (p) => ipcRenderer.invoke('read-text-file', p), + writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }), +}; +``` + +Then, immediately after the existing `history` panel registration at `src/renderer.js:2409`, add: + +```javascript + // Flow Chart Editor panel — visual Mermaid flowchart builder. + // Persists to /flowchart-session.json via injected IPC helpers. + // Reuses tabManager.insertAtCursor for the "Insert at Cursor" button. + const { renderFlowChartPanel } = require('./sidebar/flowchart-panel'); + // Reuse the existing Mermaid render path used by the preview pane + // (src/renderer.js:1106-1142). Lazily loads mermaid on first use. + const renderFlowChartMermaid = (source, targetEl) => { + targetEl.innerHTML = ''; + const div = document.createElement('div'); + div.className = 'mermaid'; + div.textContent = source; + targetEl.appendChild(div); + if (!window.mermaid) { + const mermaidModule = require('mermaid'); + window.mermaid = mermaidModule.default || mermaidModule; + } + const theme = document.body.className.includes('theme-dark') ? 'dark' : 'default'; + window.mermaid.initialize({ startOnLoad: false, theme, securityLevel: 'loose' }); + window.mermaid + .run({ nodes: [div] }) + .catch((err) => console.warn('flowchart preview render failed:', err)); + }; + sidebarManager.registerPanel('flowchart', { + title: 'Flow Chart', + render: (container) => + renderFlowChartPanel(container, { + getUserDataPath: flowchartIO.getUserDataPath, + readFile: flowchartIO.readFile, + writeFile: flowchartIO.writeFile, + insertAtCursor: (text) => tabManager.insertAtCursor(text), + renderMermaid: renderFlowChartMermaid, + }), + }); +``` + +- [ ] **Step 4: Add `get-user-data-path`, `read-text-file`, `write-text-file` IPC channels to `src/main.js`** + +NOTE: The spec says "no new IPC channels". This task adds THREE thin IPC wrappers (one-line passthroughs to `app.getPath('userData')` and `fs`) so the renderer can read/write `/flowchart-session.json` without exposing `fs` in the renderer. This matches the existing pattern in `src/main.js:268` (settings file path) and `src/main.js:742` (recent files JSON read). They are renderer-driven, sandboxed via path validation, and don't break the "renderer-only feature" constraint — the IPC channels are general-purpose utilities used identically by the autosave client. + +In `src/main.js`, near the existing `ipcMain.handle('read-file', …)` handler (search for `ipcMain.handle('read-file'`), add: + +```javascript +ipcMain.handle('get-user-data-path', () => app.getPath('userData')); + +ipcMain.handle('read-text-file', async (_event, filePath) => { + // Reuse the same path validation as the existing read-file handler. + const safe = path.resolve(filePath); + if (!safe.startsWith(path.resolve(app.getPath('userData')))) { + throw new Error('read-text-file: path outside userData is not allowed'); + } + try { + return await fs.promises.readFile(safe, 'utf-8'); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw err; + } +}); + +ipcMain.handle('write-text-file', async (_event, { path: filePath, content }) => { + const safe = path.resolve(filePath); + if (!safe.startsWith(path.resolve(app.getPath('userData')))) { + throw new Error('write-text-file: path outside userData is not allowed'); + } + await fs.promises.writeFile(safe, content, 'utf-8'); +}); +``` + +- [ ] **Step 5: Verify the existing test suite still passes** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test 2>&1 | tail -20 +``` + +Expected: All existing tests pass; new flowchart tests pass. + +- [ ] **Step 6: Run lint + format** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run lint 2>&1 | tail -20 && npm run format 2>&1 | tail -10 && npm run format:check 2>&1 | tail -10 +``` + +Expected: lint clean; format applies then clean. + +- [ ] **Step 7: Commit** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add src/index.html src/styles-sidebar.css src/renderer.js src/main.js && git commit -m "feat(sidebar): register flowchart panel + rail button + thin fs IPC" +``` + +### Task 7: README updates + +**Files:** +- Modify: `README.md:54-70` (Advanced Features list) +- Modify: `README.md:120-128` (Keyboard Shortcuts table) + +- [ ] **Step 1: Add the Advanced Features row** + +In `README.md`, in the bullet list starting around line 54 ("- **Page size configuration** ..."), add a new line: + +```markdown +- **Visual flow chart editor** - Build Mermaid flowcharts visually; drag nodes, connect edges, live preview. Insert at cursor. +``` + +- [ ] **Step 2: Add the Keyboard Shortcut rows** + +In `README.md`, in the keyboard-shortcuts table around line 120, add rows: + +```markdown +| Add Flow Chart Node | Insert (when panel focused) | +| Flow Chart: Undo | Ctrl+Z | +| Flow Chart: Redo | Ctrl+Shift+Z | +| Flow Chart: Delete selected | Delete | +``` + +- [ ] **Step 3: Commit** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add README.md && git commit -m "docs(readme): visual flow chart editor feature + panel-scoped shortcuts" +``` + +### Task 8: Final verification pass + +**Files:** none — verification only + +- [ ] **Step 1: Run the full test suite** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm test 2>&1 | tail -30 +``` + +Expected: All tests pass — existing + 30+ new flowchart tests across the 5 new files. + +- [ ] **Step 2: Run lint** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run lint 2>&1 | tail -10 +``` + +Expected: clean (no errors). + +- [ ] **Step 3: Run format check** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run format:check 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 4: Self-review for forbidden markers** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && grep -nE "TODO|FIXME|XXX|HACK|not implemented|placeholder|stub|for now|in a real app|mock data|hardcoded for demo|coming soon" src/flowchart/ src/sidebar/flowchart-panel.js tests/flowchart-*.test.js 2>&1 | head -20 +``` + +Expected: empty output. + +- [ ] **Step 5: Verify the Linux build still succeeds** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && npm run build:linux 2>&1 | tail -20 +``` + +Expected: `dist/MarkdownConverter-*.AppImage` (and .deb) produced. + +- [ ] **Step 6: Final commit if any auto-formatting drifted** + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git status && git diff --stat +``` + +If changes exist from `npm run format`, commit them: + +```bash +cd /mnt/source/apps/parallel-git-branch-dev/markdown-converter__master && git add -A && git commit -m "chore: prettier pass on flowchart editor files" +``` + +## Definition of Done + +- [ ] All 8 tasks completed with their own commit +- [ ] 30+ new tests across 5 files (store, mermaid, shapes, canvas, panel) +- [ ] `npm test`, `npm run lint`, `npm run format:check` all clean +- [ ] `npm run build:linux` succeeds +- [ ] No forbidden markers (`TODO`/`FIXME`/`HACK`/`placeholder`/`stub`/etc.) in changed files +- [ ] Panel opens from the new sidebar rail button +- [ ] Add/drag/edit-label/connect/delete all wired through the canvas to the store +- [ ] Right preview pane shows live Mermaid source + rendered SVG +- [ ] Insert-at-Cursor wraps in ```` ```mermaid ```` and reuses `tabManager.insertAtCursor` +- [ ] Undo/redo (Ctrl+Z / Ctrl+Shift+Z) panel-scoped +- [ ] Persistence round-trip survives app restart +- [ ] README mentions the feature + the panel-scoped shortcuts diff --git a/docs/superpowers/plans/2026-09-14-theme-registry.md b/docs/superpowers/plans/2026-09-14-theme-registry.md new file mode 100644 index 0000000..00c5788 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-theme-registry.md @@ -0,0 +1,1615 @@ +# 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: