diff --git a/docs/superpowers/specs/2026-06-05-phase-7-modals-design.md b/docs/superpowers/specs/2026-06-05-phase-7-modals-design.md new file mode 100644 index 0000000..0c369de --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-phase-7-modals-design.md @@ -0,0 +1,399 @@ +# Phase 7 — Modals Design + +> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phases 7+8+9+10 are sketched there at high level; this spec locks Phase 7's architecture, file map, and contracts so it can be planned task-by-task.) + +**Date:** 2026-06-05 +**Phase:** 7 of 10 (React + shadcn/ui UI redesign) +**Tag (on completion):** `phase-7-modals` + +--- + +## 1. Goal & Non-Goals + +**Goal:** Add a layered modal system to the React renderer. Wire 7 modal types (SettingsSheet + 4 Export dialogs + About + Welcome + Confirm) to a single ``. Add a persisted `useSettingsStore` for user preferences. The modals are opened via the command store (Phase 6 pattern), and the modals themselves read/write settings via the new store. + +**Non-goals (Phase 7):** +- Implement the actual export pipelines (PDF/DOCX/HTML/PNG generation) — those are main-process concerns, already implemented. Phase 7 only adds the renderer-side dialog UI. +- Real plugin system — the Plugins tab is a placeholder ("Coming soon"). +- Toast notifications — Phase 8. +- Advanced tools (Zen mode, REPL, ASCII/Table generators, Print preview) — Phase 9. + +--- + +## 2. Architecture + +### 2.1 Modal state lives in `useAppStore` (extended, not new store) + +`useAppStore` is already the "global UI" store (sidebar, preview, zen, paneSizes). It's the right home for modal state because: +- It's already mounted. +- It's already persisted (via `zustand persist` with `partialize` for pane sizes). +- A separate `useUIStore` would be YAGNI. + +**Add to `AppState`:** +- `modal: ModalState` (discriminated union — see §2.2) +- `openModal: (kind: K, props?: ModalPropsFor) => void` +- `closeModal: () => void` + +**Persistence:** `modal` is **runtime-only**, like `userBindings` in `useCommandStore`. We add it to the `partialize` function so only the persisted fields (`sidebarVisible`, `previewVisible`, `zenMode`, `paneSizes`) are saved. The modal kind never needs to survive a reload. + +### 2.2 Discriminated-union modal shape + +```ts +export type ModalState = + | { kind: null } + | { kind: 'export-pdf'; props: { sourcePath: string } } + | { kind: 'export-docx'; props: { sourcePath: string } } + | { kind: 'export-html'; props: { sourcePath: string } } + | { kind: 'export-batch'; props: { sourcePaths: string[] } } + | { kind: 'settings' } + | { kind: 'about' } + | { kind: 'welcome' } + | { kind: 'confirm'; props: ConfirmProps }; + +export interface ConfirmProps { + title: string; + body: string; + confirmLabel?: string; // default "Confirm" + cancelLabel?: string; // default "Cancel" + destructive?: boolean; // switches confirm button to red variant + onConfirm: () => void | Promise; + onCancel?: () => void; +} +``` + +**Why a discriminated union:** every component that opens a modal must pass the right `props` shape for the `kind` — TypeScript catches mismatches at compile time. A generic `{ open: boolean, type: string }` shape would defer the error to runtime. + +### 2.3 Single `` + +Mounted at the bottom of `App.tsx`. Reads `modal.kind` from `useAppStore`, renders the matching component (or `null`). Each child modal calls `closeModal()` on dismiss. + +```tsx +// src/renderer/components/modals/ModalLayer.tsx (sketch) +export function ModalLayer() { + const modal = useAppStore((s) => s.modal); + switch (modal.kind) { + case null: return null; + case 'export-pdf': return ; + // ... etc + } +} +``` + +`` ensures only one modal is visible at a time (the store only holds one). This is correct for v1 — no need for stacking/replacement transitions in Phase 7. + +### 2.4 Settings store (new, separate) + +A new `useSettingsStore` for user preferences. **Why separate from `useAppStore`:** settings is a different lifecycle. `useAppStore` is "current view configuration"; `useSettingsStore` is "user preferences that survive across sessions and are read by many features". Same precedent as `useFileStore` (file tree state) being separate from `useAppStore` (UI chrome state). + +**Persistence:** `zustand persist` with `partialize` to serialize only the leaf settings (matching the pattern in `useFileStore` and `useCommandStore`). + +```ts +interface SettingsState { + // Editor + fontSize: number; // 12-20, default 14 + tabSize: number; // 2 | 4 | 8, default 4 + lineNumbers: boolean; // default true + wordWrap: boolean; // default true + minimap: boolean; // default true + // Theme + theme: 'light' | 'dark' | 'auto'; // default 'auto' + accentColor: 'brand' | 'blue' | 'green' | 'purple' | 'orange'; // default 'brand' + fontFamily: 'system' | 'jetbrains' | 'fira'; // default 'system' + // Export + pdfFormat: 'letter' | 'a4' | 'legal'; // default 'a4' + pdfMargins: 'normal' | 'narrow' | 'wide'; // default 'normal' + pdfEmbedFonts: boolean; // default true + docxTemplate: 'standard' | 'minimal' | 'modern'; // default 'standard' + htmlHighlightStyle: 'github' | 'monokai' | 'nord' | 'none'; // default 'github' + // ASCII table formatting — applies to all 3 single-file export formats + renderTablesAsAscii: boolean; // default false + // First-launch / Welcome + welcomeDismissed: boolean; // default false + // Actions + setSetting: >(...); + resetToDefaults: () => void; +} +``` + +**Template-based exports** (per user request): `docxTemplate` is one of `'standard' | 'minimal' | 'modern'`. The export dialog shows a Select with the available templates. The IPC layer (`ipc.export.docx`) already accepts a `template` field; Phase 7 just exposes it. The main process maps these template names to actual `.docx` template files bundled with the app. + +**ASCII table formatting** (per user request): `renderTablesAsAscii` is a toggle in the Settings sheet (Export tab) and in each of the 3 single-file export dialogs as an inline checkbox override. When true, the markdown AST's table nodes are converted to fixed-width monospace text (using a small `lib/ascii-table.ts` helper) *before* the export pipeline sees them. The preview pane is unaffected — this is export-time only. + +### 2.5 WelcomeDialog trigger logic + +A small `useEffect` in `App.tsx`: +```ts +useEffect(() => { + if (!useSettingsStore.getState().welcomeDismissed) { + useAppStore.getState().openModal('welcome'); + } +}, []); // run once on mount +``` + +The Help menu registers a `help.welcome` command that simply calls `openModal('welcome')` — does NOT reset the `welcomeDismissed` flag. (Decision in §2.4 of the brainstorming.) + +### 2.6 Commands trigger modals + +The command store (Phase 6) gets new commands. Registered in `src/renderer/lib/commands/register-menu-commands.ts`: + +| Command ID | Handler | +|------------------|------------------------------------------------------| +| `file.exportPdf` | `openModal('export-pdf', { sourcePath: activePath })` | +| `file.exportDocx` | `openModal('export-docx', { sourcePath: activePath })` | +| `file.exportHtml` | `openModal('export-html', { sourcePath: activePath })` | +| `file.exportBatch` | `openModal('export-batch', { sourcePaths: openFiles })` | +| `settings.open` | `openModal('settings')` | +| `help.welcome` | `openModal('welcome')` | +| `help.about` | `openModal('about')` | +| `file.confirmClose` | opens confirm dialog before closing a dirty tab | +| `app.quit` | opens confirm if dirty tabs exist, else quits | + +`settings.open` and `help.about` also get buttons in `AppHeader` (already partly done in Phase 6 — we just add new icons and wire to the new commands). + +--- + +## 3. File Map + +### 3.1 shadcn primitives (manually created, per the shadcn-CLI-blocked memory) + +Created in `src/renderer/components/ui/`: +- `dialog.tsx` — Radix Dialog wrapper with motion preset +- `sheet.tsx` — Radix Dialog (side variant) for SettingsSheet +- `tabs.tsx` — Radix Tabs for the 5-tab SettingsSheet +- `input.tsx` — text input +- `textarea.tsx` — multi-line input (for confirm body, welcome copy) +- `select.tsx` — Radix Select for theme/font/template pickers +- `switch.tsx` — Radix Switch for boolean settings +- `checkbox.tsx` — Radix Checkbox for "don't show again" toggles +- `slider.tsx` — Radix Slider for fontSize +- `label.tsx` — Radix Label (always pair with form fields) +- `form.tsx` — react-hook-form glue components (FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage) +- `radio-group.tsx` — Radix RadioGroup for accent color / template + +### 3.2 Modals (`src/renderer/components/modals/`) + +- `ModalLayer.tsx` — root, mounted by `App.tsx` +- `ExportPdfDialog.tsx` — PDF options (format, margins, embed fonts, ascii tables) +- `ExportDocxDialog.tsx` — DOCX options (template picker: standard/minimal/modern, ascii tables) +- `ExportHtmlDialog.tsx` — HTML options (standalone, highlight style, ascii tables) +- `ExportBatchDialog.tsx` — batch queue (format, concurrency, file list) +- `SettingsSheet.tsx` — 5-tab sheet (side="right", 480px wide) + - `EditorSettings.tsx` — font size, tab size, line numbers, word wrap, minimap + - `ThemeSettings.tsx` — light/dark/auto, accent color, font family + - `ExportSettings.tsx` — pdf format, margins, embed fonts, docx template, html highlight, ascii tables + - `PluginsSettings.tsx` — "Coming soon" placeholder + - `AboutSettings.tsx` — app version, links, acknowledgements +- `AboutDialog.tsx` — simple read-only dialog with version + GitHub link +- `WelcomeDialog.tsx` — first-launch dialog with quick-start cards +- `ConfirmDialog.tsx` — generic confirmation (title, body, destructive, onConfirm) +- `ExportDialogFooter.tsx` — shared Cancel / Export button row used by the 4 export dialogs +- `useExportSource.ts` — shared hook: reads active buffer, validates, returns source string + path + +### 3.3 Stores + +- **Modify** `src/renderer/stores/app-store.ts` — add `modal` + `openModal` + `closeModal`; update `partialize` to exclude `modal` +- **Create** `src/renderer/stores/settings-store.ts` — new + +### 3.4 Lib + +- **Create** `src/renderer/lib/validators.ts` — zod schemas: + - `settingsSchema` (whole settings object) + - `exportPdfSchema` (format, margins, embedFonts, renderTablesAsAscii) + - `exportDocxSchema` (template, renderTablesAsAscii) + - `exportHtmlSchema` (standalone, highlightStyle, renderTablesAsAscii) + - `exportBatchSchema` (format, concurrency, file list) + - `confirmPropsSchema` (for the confirm dialog) +- **Create** `src/renderer/lib/ascii-table.ts` — `toAsciiTable(rows: string[][]): string` (the small helper that converts a 2D string array to a fixed-width ASCII table) +- **Create** `src/renderer/lib/modal-triggers.ts` — small helpers: `useWelcomeTrigger()`, `useQuitGuard()` + +### 3.5 Modified files + +- **Modify** `src/renderer/App.tsx` — mount `` at the bottom; add the `useWelcomeTrigger` `useEffect` for first-launch +- **Modify** `src/renderer/lib/commands/register-menu-commands.ts` — add the 9 new commands (4 export + settings + welcome + about + confirmClose + quit) +- **Modify** `src/renderer/components/layout/AppHeader.tsx` — add Settings (gear) and About (info) icon buttons that dispatch the new commands +- **Modify** `src/main.js` (verify) — no changes expected; menu items already wire to `menu:action` channels that flow through `useBridgeNativeMenu`. If any new menu items need IPC channels, add them in the main process mirror. + +### 3.6 Tests + +**Unit (`tests/unit/`):** +- `stores/settings-store.test.ts` (5-6 tests: defaults, setSetting, resetToDefaults, persistence/partialize) +- `stores/app-store.test.ts` extended (3 tests: openModal sets state, closeModal clears, only one modal at a time) +- `lib/validators.test.ts` (3 tests: each schema rejects bad input) +- `lib/ascii-table.test.ts` (3 tests: simple table, alignment, empty input) + +**Component (`tests/component/modals/`):** +- `ExportPdfDialog.test.tsx` (4 tests: renders with default settings, submit calls ipc.export.pdf with merged opts, error renders inline, ascii-table toggle flows through) +- `ExportDocxDialog.test.tsx` (3 tests: renders, submit includes template, ascii-table toggle) +- `ExportHtmlDialog.test.tsx` (3 tests: renders, highlight style select, ascii-table toggle) +- `ExportBatchDialog.test.tsx` (3 tests: renders file list, format selector, concurrency) +- `SettingsSheet.test.tsx` (6 tests: renders 5 tabs, each tab shows correct fields, settings change persists) +- `AboutDialog.test.tsx` (2 tests: renders version, links open external) +- `WelcomeDialog.test.tsx` (3 tests: renders, dismiss sets welcomeDismissed, "don't show again" checked) +- `ConfirmDialog.test.tsx` (3 tests: confirm calls onConfirm and closes, cancel calls closeModal, destructive variant) +- `ModalLayer.test.tsx` (3 integration tests: null kind renders nothing, switching kinds replaces modal, modal unmounts on close) + +**Integration (`tests/integration/`):** +- `phase7-modals-smoke.test.tsx` (4 tests: dispatch command opens modal, command store + settings store + IPC all wired, app.tsx mount triggers welcome on first launch, modal layer end-to-end) + +--- + +## 4. Data Flow + +### 4.1 Open a modal +```ts +// From any command handler in register-menu-commands.ts: +useAppStore.getState().openModal('export-pdf', { sourcePath: activePath }); +``` + +### 4.2 The dialog reads source +```ts +// ExportPdfDialog.tsx +const { source, path } = useExportSource(); +if (!source) return ; +``` + +`useExportSource` is a small hook that: +1. Reads `useFileStore.activeTabId` + `useEditorStore.buffers` +2. If no active buffer, prompts the user to open a file (uses confirm dialog) +3. Returns `{ source: string, path: string } | null` + +### 4.3 Settings change +```ts +// EditorSettings.tsx — switches/inputs call setSetting +const [fontSize, setFontSize] = useSettingsStore(s => [s.fontSize, s.setSetting]); +// or +setSetting('fontSize', 16); +``` + +Editor and preview subscribe to specific slices. The `useTheme` hook from `next-themes` is augmented to read `theme: 'light' | 'dark' | 'auto'` from `useSettingsStore` (replacing the standalone next-themes default). + +### 4.4 Export flow +```ts +// ExportPdfDialog on submit: +const settings = useSettingsStore.getState(); +const result = await ipc.export.pdf({ + inputPath: path, + outputPath: chosenOutputPath, + format: dialogFormat ?? settings.pdfFormat, + margins: MARGIN_PRESETS[dialogMargins ?? settings.pdfMargins], + embedFonts: dialogEmbed ?? settings.pdfEmbedFonts, + renderTablesAsAscii: dialogAscii ?? settings.renderTablesAsAscii, +}); +if (!result.ok) setError(result.error.message); +else { closeModal(); /* toast in Phase 8 */ } +``` + +The dialog-level overrides fall through to settings defaults when not explicitly chosen. + +### 4.5 ASCII table transformation +The transformation happens in the **renderer** (pre-IPC), so the main process doesn't need to know about ASCII mode: +```ts +// In ExportPdfDialog before submitting: +const finalSource = renderTablesAsAscii + ? applyAsciiTransform(source) // walks AST, replaces blocks + : source; +``` +`applyAsciiTransform` is a small function (10-20 lines) that: +1. Parses markdown source for `|...|` table syntax via a small regex +2. Replaces each table block with a fenced code block containing the ASCII table +3. Returns the modified source + +(No AST walker needed — markdown tables are line-based and a regex per line + simple width calc is sufficient.) + +### 4.6 DOCX template selection +The dialog shows a Select with 3 options (standard, minimal, modern). The main process maps these names to bundled `.docx` template files. The IPC contract is unchanged — `DocxOptions.template: string` was already defined in `types/ipc.ts` during Phase 1. + +### 4.7 Confirm flow +```ts +// In a command handler: +const activeTab = ...; +if (activeTab?.dirty) { + useAppStore.getState().openModal('confirm', { + title: 'Discard unsaved changes?', + body: `"${activeTab.title}" has unsaved changes. Close without saving?`, + confirmLabel: 'Discard', + destructive: true, + onConfirm: () => doCloseTab(), + }); +} else { + doCloseTab(); +} +``` + +### 4.8 Welcome first-launch +`useEffect` in `App.tsx` (run once on mount) checks `welcomeDismissed` from `useSettingsStore`. If false, calls `openModal('welcome')`. The Welcome dialog has a "Don't show again" checkbox that sets `welcomeDismissed: true` and closes. + +--- + +## 5. Error Handling + +- **IPC errors in export dialogs:** inline error banner below the submit button. `IpcResult` discriminated union makes this easy. Banner shows `result.error.message` and a "Try again" button that re-submits. +- **Settings validation:** zod schemas in `validators.ts`. Each form field shows `aria-invalid` + red border on error. Form-level errors via react-hook-form's `formState.errors`. +- **Confirm dialog cancel:** just calls `closeModal()`. No state mutation. Optional `onCancel` callback for "remember my choice" patterns (not used in Phase 7). +- **Welcome "don't show again":** persists `welcomeDismissed: true`. Help menu can re-open Welcome (without resetting the flag). +- **Settings corruption on load (bad localStorage data):** `useSettingsStore` is built with a `partialize` that also acts as a whitelist — only known fields are deserialized. Unknown fields are dropped. If a persisted value fails zod validation, fall back to defaults (logged as a warning). + +--- + +## 6. Testing Strategy + +TDD per the established pattern (Phases 1-6). Every component test: +- Renders with empty/default state +- One happy-path interaction (form submit, button click) +- One error/edge case (validation fail, IPC error, cancel) + +Store tests focus on pure logic (state transitions, persistence, partialize). Settings store test specifically verifies: +- Defaults match schema +- `setSetting` works for leaf keys +- `resetToDefaults` clears to initial state +- Persisted payload (from `partialize`) contains exactly the leaf fields +- Hydration from a partial/corrupt payload doesn't throw + +Component tests use `render` + `userEvent`, mock `window.electronAPI` for IPC. + +ModalLayer integration test verifies: +1. Mounting with `kind: null` renders nothing (query container, expect empty) +2. Mounting with `kind: 'about'` renders `` (aria-label match) +3. Switching from `kind: 'about'` to `kind: 'settings'` unmounts About, mounts Settings (verified by role/aria-label transitions) +4. Confirm dialog calls `onConfirm` and `closeModal` on success + +--- + +## 7. Risks & Open Questions + +**Risks:** +- **Form library complexity.** react-hook-form + zod is powerful but adds learning curve. Mitigation: a single shared `` wrapper reduces cognitive load; export dialogs use simple `useState` (no need for the full form infra). +- **shadcn Dialog animation jank with our motion presets.** Radix Dialog has its own `data-state` attributes for open/closed. We compose with our `modalPop` preset via `forceMount` + Motion. Need to verify no double-animation. +- **Settings store hydration race.** If a component reads a setting on first render before hydration completes, it gets the default. For Phase 7 this is fine — defaults are sensible. + +**Open questions (deferrable):** +- Should the "ascii table" output include alignment row separators (`+---+---+`) or just be a `| a | b |`-style table? → Decision: use the `|---|` separator form (more compact, common in plain-text email). +- Should ExportBatchDialog be a Sheet (queue progress) or a Dialog (form)? → Decision: Dialog with a form; progress is shown inline (not a streaming queue). Phase 9 could revisit. +- Should `welcomeDismissed` be per-user-account or per-install? → Per-install (localStorage). No multi-user concept in v1. + +--- + +## 8. Out of Scope (deferred to later phases) + +- Phase 8: Toast notifications on export success/failure (the dialog's inline error is v1; toasts are a follow-up). +- Phase 9: ASCII art generator (figlet) is separate from ASCII table rendering. This spec is about table formatting. +- Phase 9: Word export uses a `.docx` template *generation* step (WordExportDialog), not the IPC `ipc.export.docx` path. Distinct. +- Phase 10: Delete legacy `src/print-preview.js`, `src/wordTemplateExporter.js`, etc. + +--- + +## 9. Success Criteria + +Phase 7 is complete when: +- All listed shadcn primitives exist in `src/renderer/components/ui/` with tests +- `useSettingsStore` is implemented, tested, and persisted +- `useAppStore` extended with `modal` discriminated union and tested +- All 7 modal components implemented, tested, and accessible (aria-labels, keyboard nav) +- 4 export dialogs (PDF/DOCX/HTML/Batch) all submit through the command store and call IPC correctly +- `ModalLayer` mounted in `App.tsx` and integrated with command triggers +- Welcome dialog shows on first launch, dismissible, re-openable from Help menu +- Confirm dialog used by quit-with-dirty and close-with-dirty flows +- ASCII table rendering works (toggle in settings, override in export dialogs) +- DOCX template picker in ExportDocxDialog submits the correct `template` field +- `npx vite build` succeeds, `npx vitest run` shows **all tests green** (target: +50 new tests, total ~220) +- Branch tagged `phase-7-modals` and pushed to origin