The v5.0.0 React UI shipped with a working surface but had a number of
features left as 'wired in a later phase' or broken by missing IPC
plumbing. This commit completes the wiring end-to-end and proves the
behaviour with the run-desktop driver.
Toolbar format buttons (Toolbar.tsx)
- Bold, Italic, Unordered/Ordered list, Code, Link now dispatch real
commands instead of being permanently disabled.
New module: src/renderer/lib/editor-commands.ts
- Module-level singleton holding the active CodeMirror EditorView.
- toggleBold/Italic/Code/CodeBlock, toggleUnorderedList/OrderedList,
insertLink, setHeadingLevel, scrollToLine, undo, redo, insertSnippet.
- CodeMirrorEditor mounts the view via setActiveView on mount and
nulls it on unmount, so commands land on the focused buffer.
register-menu-commands.ts
- file.confirmClose: prompts before closing a dirty tab via the
confirm modal; otherwise closes silently.
- app.quit: prompts before quitting if any buffer is dirty; uses
the new ipc.app.quit channel.
- short-cuts.show: shows the keyboard shortcut list in a confirm dialog.
- editor.bold/italic/code/list.*/link/undo/redo/heading.*: drive the
active editor.
- find.toggle: dispatches mc:find-toggle for any listening component.
- view.sidebarPanel/bottomPanel: navigate between sidebar sections
via the new data-testid buttons; bottom panel toggles the REPL.
- font.size: increase/decrease/reset on editorFontSize (10..28).
- theme.loadCustomCss/clearCustomCss: pick and clear the custom CSS
path on the settings store.
- template.load: inserts a bundled markdown skeleton at the cursor.
- print.preview/previewStyled: emit mc:print and mc:print-preview.
- file.clearRecent: clears open tabs.
- file.new: creates an untitled buffer and tab.
- editor.gotoHeading: scrolls to a given line (used by Outline and
Breadcrumb).
- view.toggleSidebar: already wired.
- file.opened: already wired (prior fix).
Sidebar Outline + Breadcrumb
- Both now click through to 'editor.gotoHeading' with the line
number extracted from the active buffer.
- Sidebar exposes data-testid for the 'view.sidebarPanel' menu action.
editor-commands sets the active view; scroll metrics flow through to
Minimap which now reads scrollRatio/visibleRatio from the editor.
Export dialogs (PDF/DOCX/HTML)
- Replaced the broken ipc.export.{pdf,docx,html,batch} calls (those
channels were not implemented in main and only CHANNEL_MISSING
was returned) with renderer-side pipelines.
- PDF: generateHtml() with @page CSS for size + margins, then
ipc.print to the main process for native print-to-PDF.
- DOCX: generateDocx() from the existing lib, then ipc.file.writeBuffer.
- HTML: generateHtml() then ipc.file.writeBuffer.
- All three dialogs use ipc.app.showSaveDialog for output path
(new IPC handler) and ipc.file.writeBuffer for the binary write
(new preload channel).
- PrintPreview now uses MarkdownRenderer instead of a raw <pre>.
New settings fields
- editorFontSize: 10..28 px (drives CodeMirror theme font-size)
- customCssPath: string|null (Theme menu wires 'load-custom-css' and
'clear-custom-css' to it).
New IPC channels
- 'app:quit', 'app:open-external', 'app:show-save-dialog',
'write-buffer' (already existed; now exposed in preload).
Tests
- Updated Toolbar test: format buttons are no longer disabled and
dispatch their command ids.
- Updated Export*D*Dialog tests to mock the new ipc surface
(ipc.print, ipc.app.showSaveDialog, ipc.file.writeBuffer) and
assert the new flow.
- Updated PrintPreview test to use ipc.print.doPrint.
- Updated phase8-toasts integration: PDF flow now goes through
ipc.print and asserts 'Sent <title> to printer'.
- register-menu-commands test: re-register 'template.load' AFTER
Harness renders so the captured-args handler is the live one.
Verification
- npm run build:renderer: success (1.6s)
- npx vitest run: 308 passed (up from 305; 3 new + unchanged)
- npx jest: 189 passed (unchanged)
- .claude/skills/run-desktop/verify-features.mjs: drove the live app
via Playwright, exercised every wired feature end-to-end, captured
11 screenshots. All verified working (editor + preview render the
file content, toolbar buttons dispatch, dialogs open, theme
toggles, outline shows headings, italic button works in editor).
Two related defects were breaking the 'open file' flow:
1. The BrowserWindow webPreferences had no preload path AND
contextIsolation: false, so contextBridge.exposeInMainWorld
threw on load and window.electronAPI was never set. Result:
every renderer-side ipc.* call returned CHANNEL_MISSING and
silently no-op'd. Toolbar Open/Save, the file menu, every
dialog — all broken.
2. The 'file.opened' IPC bridge in register-menu-commands.ts
dispatched to a 'file.opened' command that was never registered
as a handler. Even if the preload had worked, the main menu's
File -> Open would have been a no-op once it reached the
command store.
Fixes:
- window/index.js: add preload path; set contextIsolation: true
(required for contextBridge) and nodeIntegration: false
(renderer no longer needs direct Node access; everything goes
through the whitelisted preload bridge).
- file-store.ts: add openFileFromMain(path, content) that opens
a tab + editor buffer from content the main process already read
(skips the renderer's ipc.file.read since main has the bytes).
Preserves existing buffer content if the file is already open.
- register-menu-commands.ts: register 'file.opened' to call
openFileFromMain. Drop the dead 'command.palette' bridge
(no consumer, no UI, no handler).
- tests: two new cases for openFileFromMain — verifies it does
not call ipc.file.read and does not clobber user edits on
re-open.
Verified end-to-end via the run-desktop driver: triggering
file-opened from the main process with a test .md now shows the
content in BOTH the CodeMirror editor and the rendered preview
pane (plus the Outline panel picks up the H1). 497 tests pass.
User prefers light mode. next-themes previously defaulted to dark
which made first-load users (no localStorage) see a dark UI. With
'enableSystem' on, 'system' is still available as a choice — only
the initial value changed.
Existing users with 'dark' in localStorage keep dark until they toggle.
New users get light.
Two related bugs in src/main/window/index.js:
1. Renderer path was ../../dist/renderer/index.html which resolves to
src/dist/renderer/index.html (one level too deep). Production builds
load from <project>/dist/renderer/index.html. Fix: ../../..
This was never caught because dev mode uses VITE_DEV_SERVER_URL and
never touches the file path; only 'electron .' (no Vite) hits it.
2. win.on('closed', state.save) calls win.getBounds() on a destroyed
BrowserWindow. The 'closed' event fires AFTER destruction, so
getBounds() throws 'Object has been destroyed'. Use 'close' (fires
before destruction) and guard with isDestroyed() as a belt-and-suspenders.
Both bugs surfaced when the user clicked File → Open. The renderer
loaded a missing HTML (blank body); the openFile handler then triggered
the closed-event state save which threw.
The menu items file was using require('../main') which resolved to a
non-existent src/main/main.js. After the 054ce23 refactor that renamed
main.js → index.js, every menu click tried to require a missing module.
All 65 occurrences fixed; fileItems() now loads cleanly at app startup
and the menu doesn't throw on construction.
Caught by user clicking File → Open: showOpenDialogSync emitted through
to the lazy require at items.js:52, which failed with module-not-found
before main process handlers ran.
Closes the gap from the previous handoff: `npm run dist:all` now
produces Linux .deb/AppImage/snap, Windows NSIS/portable/zip, and
macOS dmg/zip artifacts. `.github/workflows/release.yml` builds all
three on tag push (v*) and attaches the union to a GitHub Release.
Bug fix in the same commit (would have broken any packaged build):
* `build.files` was `["src/**/*", ...]` — shipped `src/renderer/`
.tsx dev source into the asar. Replaced with a whitelist
(`src/main/**/*`, `src/preload.js`, `src/plugins/**/*`,
`package.json`) and moved the built renderer to
`build.extraResources` (`dist/renderer` -> `renderer/`).
* `src/main/window/index.js` now uses `process.resourcesPath` when
`app.isPackaged` is true, plus a friendly error if the renderer is
missing. Verified by extracting the .AppImage and launching the
binary — main process logs
`Production mode — loading .../resources/renderer/index.html`.
New in `package.json` build config:
* `mac.target`: dmg + zip for x64 and arm64; `darkModeSupport: true`,
`hardenedRuntime: false`, `gatekeeperAssess: false`,
`entitlements: null`. `mac.identity: null` retained (unsigned).
* `publish`: GitHub Releases provider. CI overrides with
`--publish=never`.
Known limitations (intentional follow-ups, not blockers):
* No `assets/icon.icns` — macOS app uses the default Electron icon.
This box lacks `iconutil`/`png2icns`; generating a proper .icns is
a one-line follow-up.
* No code signing — Windows SmartScreen will warn, macOS Gatekeeper
will quarantine. `CSC_LINK_BASE64`/`CSC_KEY_PASSWORD` secrets are
already wired in the Windows job for when the cert is added.
* No auto-update — user decision. The `publish` block writes the
metadata that a future `electron-updater` integration will consume.
Verified on this Linux box:
* `npm run build:renderer` then `npm run build:linux` — produced
AppImage (291M), deb (220M), snap (245M).
* `resources/app.asar` has 0 `src/renderer/*` entries (was the
pre-fix bug). 44 .tsx files remain in asar — all from
`node_modules/@hookform/resolvers` and `node_modules/immer` test
fixtures, not our code.
* Extracted `squashfs-root/resources/renderer/index.html` exists and
is what `window/index.js` loads.
* Binary runs for >12s without crashing — `loadFile` resolves
`process.resourcesPath/renderer/index.html` correctly.
Tests: 306/306 pass. No regressions.
Plan doc: docs/plans/2026-06-06-packaging-all-platforms.md (the
task-by-task execution guide I followed; included for traceability).
Every DialogContent had aria-describedby="X-desc" and every
DialogDescription had a matching id="X-desc". That pattern is the
default shadcn/ui recipe, but it breaks Radix 1.1.x.
Root cause: Radix auto-generates a descriptionId via useId() (e.g. :r0:)
and stores it in the Dialog context. The DescriptionWarning effect does
`document.getElementById(descriptionId)` and warns if the element is
missing. When the description element's id is overridden to
"welcome-desc", the DOM has id="welcome-desc" but the context still has
:r0:, so the lookup misses and the warning fires on every dialog open.
Fix: drop both overrides. Let Radix manage the id and aria-describedby
itself. Screen-reader semantics are unchanged (Radix still wires
Content -> Description via the auto-generated id).
Touched: 12 modal files (11 DialogContent/DialogDescription pairs +
SettingsSheet which uses Sheet). +1 regression test in
WelcomeDialog.test.tsx that spies on console.warn and asserts the
"Missing Description" message is never emitted.
Tests: 306/306 pass (was 305).
Long-term memory: radix-aria-describedby-anti-pattern.md captures the
do-not-reintroduce rule for future agents.
The v5.0.0 React UI redesign shipped without a working dev workflow —
`npm start` only ran `electron .` which loaded the source index.html
that references raw .tsx files. The browser couldn't execute them, so
the app rendered an empty #root and the user saw 'lot of errors'.
This commit restores a real dev workflow:
1. **Vite dev server wired into npm scripts** — added `concurrently`
+ `wait-on` as devDeps. `npm run dev` now runs Vite (port 5173)
and Electron together with `wait-on tcp:5173` so Electron only
starts after the dev server is ready.
2. **Main process loads Vite URL in dev, dist/ in prod** — window
module now checks `process.env.VITE_DEV_SERVER_URL` and
`app.isPackaged` to decide what to load. Production still
loads the built `dist/renderer/index.html`.
3. **Fixed ReferenceError: app is not defined** — window module
referenced `app.isPackaged` but only imported `BrowserWindow`.
Added `app` to the destructured import.
4. **Fixed wrong icon path** — window creation used
`../../assets/icon.png` from src/main/window/ which resolved
to `src/assets/icon.png` (doesn't exist). Icon lives at
project root, so path is now `../../../assets/icon.png`.
5. **Allowed Google Fonts in CSP** — the strict CSP
(`font-src 'self' data:`, `connect-src 'self'`) blocked
the preconnect/css link to fonts.googleapis.com / .gstatic.com.
Added both domains so Plus Jakarta Sans loads.
6. **Renamed onLayout → onLayoutChange** in AppShell — react-resizable-panels
v4 renamed the prop. The v1 name was being spread to a DOM
div and React logged 'Unknown event handler property' warnings.
Test mock updated for parity.
7. **show: false → show: true** — ready-to-show was hanging on
this Wayland/container combo. Now the window is shown
immediately on create.
Verified end-to-end:
- 305/305 vitest tests pass
- Vite dev server starts on :5173
- Electron loads localhost:5173 and React mounts
- Header 'MarkdownConverter' + sidebar with 'No files open' visible
- No onLayout warnings after HMR
- All blocked errors resolved
Adds the actual right-alignment heuristic to toAsciiTable (was missing
from initial implementation). When any column header starts with a digit,
all columns are right-padded instead of left-padded. Matches the test
added in 53870d5 which exercises the right-alignment path with header
['Item', '7'].
All 6 ascii-table tests pass.