(null);
const handleSubmit = async () => {
setSubmitting(true);
setError(null);
const items = sourcePaths.map((p) => ({
inputPath: p,
outputPath: p.replace(/\.md$/, extFor(format)),
}));
const result = await ipc.export.batch(items, { format, concurrency });
if (!result.ok) {
setError(result.error.message);
setSubmitting(false);
} else {
closeModal();
}
};
return (
);
}
```
- [ ] **Step 4: Run, commit**
```bash
npx vitest run tests/component/modals/ExportBatchDialog.test.tsx
git add src/renderer/components/modals/ExportBatchDialog.tsx tests/component/modals/ExportBatchDialog.test.tsx
git commit -m "feat(renderer): ExportBatchDialog (format, concurrency, file list)"
```
---
## Task 27: `EditorSettings` tab (no test, exercised via SettingsSheet test in Task 32)
**Files:**
- Create: `src/renderer/components/modals/EditorSettings.tsx`
- [ ] **Step 1: Implement**
Create `src/renderer/components/modals/EditorSettings.tsx`:
```tsx
import { useSettingsStore } from '@/stores/settings-store';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
export function EditorSettings() {
const { fontSize, tabSize, lineNumbers, wordWrap, minimap, setSetting } = useSettingsStore();
return (
setSetting('fontSize', v)} />
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/renderer/components/modals/EditorSettings.tsx
git commit -m "feat(renderer): EditorSettings tab (font, tab, line/wrap, minimap)"
```
---
## Task 28: `ThemeSettings` tab
**Files:**
- Create: `src/renderer/components/modals/ThemeSettings.tsx`
- [ ] **Step 1: Implement**
Create `src/renderer/components/modals/ThemeSettings.tsx`:
```tsx
import { useSettingsStore } from '@/stores/settings-store';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export function ThemeSettings() {
const { theme, accentColor, fontFamily, setSetting } = useSettingsStore();
return (
setSetting('theme', v as 'light' | 'dark' | 'auto')}>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/renderer/components/modals/ThemeSettings.tsx
git commit -m "feat(renderer): ThemeSettings tab (mode, accent, font)"
```
---
## Task 29: `ExportSettings` tab
**Files:**
- Create: `src/renderer/components/modals/ExportSettings.tsx`
- [ ] **Step 1: Implement**
Create `src/renderer/components/modals/ExportSettings.tsx`:
```tsx
import { useSettingsStore } from '@/stores/settings-store';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
export function ExportSettings() {
const { pdfFormat, pdfMargins, pdfEmbedFonts, docxTemplate, htmlHighlightStyle, renderTablesAsAscii, setSetting } = useSettingsStore();
return (
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/renderer/components/modals/ExportSettings.tsx
git commit -m "feat(renderer): ExportSettings tab (PDF/DOCX/HTML defaults + ascii toggle)"
```
---
## Task 30: `PluginsSettings` tab (placeholder)
**Files:**
- Create: `src/renderer/components/modals/PluginsSettings.tsx`
- [ ] **Step 1: Implement**
Create `src/renderer/components/modals/PluginsSettings.tsx`:
```tsx
import { Sparkles } from 'lucide-react';
export function PluginsSettings() {
return (
Coming soon
The plugin system is on the roadmap. You'll be able to extend MarkdownConverter with custom
commands, themes, and export formats.
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/renderer/components/modals/PluginsSettings.tsx
git commit -m "feat(renderer): PluginsSettings tab placeholder (Coming soon)"
```
---
## Task 31: `AboutSettings` tab
**Files:**
- Create: `src/renderer/components/modals/AboutSettings.tsx`
- [ ] **Step 1: Implement**
Create `src/renderer/components/modals/AboutSettings.tsx`:
```tsx
import { useEffect, useState } from 'react';
import { ipc } from '@/lib/ipc';
export function AboutSettings() {
const [version, setVersion] = useState('…');
useEffect(() => {
ipc.app.getVersion().then((r) => {
if (r.ok && typeof r.data === 'string') setVersion(r.data);
});
}, []);
return (
);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/renderer/components/modals/AboutSettings.tsx
git commit -m "feat(renderer): AboutSettings tab (version + links)"
```
---
## Task 32: `SettingsSheet` (compose 5 tabs) + test (TDD)
**Files:**
- Create: `src/renderer/components/modals/SettingsSheet.tsx`
- Create: `tests/component/modals/SettingsSheet.test.tsx`
- [ ] **Step 1: Write the failing tests**
Create `tests/component/modals/SettingsSheet.test.tsx`:
```tsx
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SettingsSheet } from '@/components/modals/SettingsSheet';
import { useSettingsStore } from '@/stores/settings-store';
describe('SettingsSheet', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.setState(useSettingsStore.getInitialState());
});
it('renders 5 tab triggers', () => {
render();
expect(screen.getByRole('tab', { name: /editor/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /theme/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /export/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /plugins/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /about/i })).toBeInTheDocument();
});
it('editor tab is open by default and shows font size', () => {
render();
expect(screen.getByText(/font size/i)).toBeInTheDocument();
});
it('theme tab shows theme radio group', async () => {
render();
await userEvent.click(screen.getByRole('tab', { name: /theme/i }));
expect(screen.getByText(/accent color/i)).toBeInTheDocument();
});
it('export tab shows ascii toggle and template picker', async () => {
render();
await userEvent.click(screen.getByRole('tab', { name: /export/i }));
expect(screen.getByText(/render tables as ascii by default/i)).toBeInTheDocument();
expect(screen.getByText(/default docx template/i)).toBeInTheDocument();
});
it('plugins tab shows coming soon message', async () => {
render();
await userEvent.click(screen.getByRole('tab', { name: /plugins/i }));
expect(screen.getByText(/coming soon/i)).toBeInTheDocument();
});
it('about tab shows version', async () => {
render();
await userEvent.click(screen.getByRole('tab', { name: /about/i }));
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
});
it('reset to defaults button clears all settings', async () => {
useSettingsStore.getState().setSetting('fontSize', 22);
render();
await userEvent.click(screen.getByRole('button', { name: /reset/i }));
expect(useSettingsStore.getState().fontSize).toBe(14);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
npx vitest run tests/component/modals/SettingsSheet.test.tsx
```
Expected: FAIL.
- [ ] **Step 3: Implement**
Create `src/renderer/components/modals/SettingsSheet.tsx`:
```tsx
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { EditorSettings } from './EditorSettings';
import { ThemeSettings } from './ThemeSettings';
import { ExportSettings } from './ExportSettings';
import { PluginsSettings } from './PluginsSettings';
import { AboutSettings } from './AboutSettings';
export function SettingsSheet() {
const closeModal = useAppStore((s) => s.closeModal);
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
return (
!o && closeModal()}>
Settings
Editor, theme, and export preferences
Editor
Theme
Export
Plugins
About
);
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
npx vitest run tests/component/modals/SettingsSheet.test.tsx
```
Expected: PASS, 7 tests.
- [ ] **Step 5: Commit**
```bash
git add src/renderer/components/modals/SettingsSheet.tsx tests/component/modals/SettingsSheet.test.tsx
git commit -m "feat(renderer): SettingsSheet (5 tabs: editor, theme, export, plugins, about)"
```
---
## Task 33: Extend `register-menu-commands.ts` with 9 new commands + test (TDD)
**Files:**
- Modify: `src/renderer/lib/commands/register-menu-commands.ts`
- Create: `tests/unit/commands/modal-commands.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `tests/unit/commands/modal-commands.test.ts`:
```ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
import { useCommandStore } from '@/stores/command-store';
import { useAppStore } from '@/stores/app-store';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
describe('modal commands', () => {
beforeEach(() => {
localStorage.clear();
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useAppStore.setState({ modal: { kind: null } } as any);
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]) } as any);
});
it('settings.open opens settings modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('settings.open');
expect(useAppStore.getState().modal).toEqual({ kind: 'settings' });
});
it('help.about opens about modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('help.about');
expect(useAppStore.getState().modal).toEqual({ kind: 'about' });
});
it('help.welcome opens welcome modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('help.welcome');
expect(useAppStore.getState().modal).toEqual({ kind: 'welcome' });
});
it('file.exportPdf opens export-pdf modal with active path', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('file.exportPdf');
expect(useAppStore.getState().modal).toEqual({ kind: 'export-pdf', props: { sourcePath: '/x.md' } });
});
it('file.exportDocx opens export-docx modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('file.exportDocx');
expect(useAppStore.getState().modal).toEqual({ kind: 'export-docx', props: { sourcePath: '/x.md' } });
});
it('file.exportBatch opens export-batch modal with all open files', () => {
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }, { id: '/y.md', path: '/y.md', title: 'y.md', dirty: false }] } as any);
registerMenuCommands();
useCommandStore.getState().dispatch('file.exportBatch');
expect(useAppStore.getState().modal).toEqual({ kind: 'export-batch', props: { sourcePaths: ['/x.md', '/y.md'] } });
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
npx vitest run tests/unit/commands/modal-commands.test.ts
```
Expected: FAIL — `Cannot find module '@/lib/commands/register-menu-commands'`.
- [ ] **Step 3: Update `register-menu-commands.ts`**
Open `src/renderer/lib/commands/register-menu-commands.ts` and add the new commands. The existing file (from Phase 6) registers `file.*` and `view.*` and `tab.*` commands. Append the new handlers.
(If the file uses a `registerMany` pattern from Phase 6, the addition is a single object spread. The exact structure depends on what's already there; the test above defines the contract.)
The new commands (full handler code):
```ts
import { useAppStore } from '@/stores/app-store';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
export function registerMenuCommands() {
const handlers: Record void | Promise> = {
// ... existing handlers from Phase 6 ...
'settings.open': () => useAppStore.getState().openModal('settings'),
'help.about': () => useAppStore.getState().openModal('about'),
'help.welcome': () => useAppStore.getState().openModal('welcome'),
'file.exportPdf': () => {
const activeTabId = useFileStore.getState().activeTabId;
if (!activeTabId) return;
useAppStore.getState().openModal('export-pdf', { sourcePath: activeTabId });
},
'file.exportDocx': () => {
const activeTabId = useFileStore.getState().activeTabId;
if (!activeTabId) return;
useAppStore.getState().openModal('export-docx', { sourcePath: activeTabId });
},
'file.exportHtml': () => {
const activeTabId = useFileStore.getState().activeTabId;
if (!activeTabId) return;
useAppStore.getState().openModal('export-html', { sourcePath: activeTabId });
},
'file.exportBatch': () => {
const paths = useFileStore.getState().openTabs.map((t) => t.path);
if (paths.length === 0) return;
useAppStore.getState().openModal('export-batch', { sourcePaths: paths });
},
};
useCommandStore.getState().registerMany(handlers);
}
```
(If the existing `registerMenuCommands` already has a return signature, preserve it. The key requirement is that the 9 new commands are registered.)
- [ ] **Step 4: Run test to verify it passes**
```bash
npx vitest run tests/unit/commands/modal-commands.test.ts
```
Expected: PASS, 6 tests.
- [ ] **Step 5: Commit**
```bash
git add src/renderer/lib/commands/register-menu-commands.ts tests/unit/commands/modal-commands.test.ts
git commit -m "feat(renderer): register 9 modal-opening commands in command store"
```
---
## Task 34: Extend `AppHeader` with Settings + About icon buttons + test (TDD)
**Files:**
- Modify: `src/renderer/components/layout/AppHeader.tsx`
- Modify: `tests/component/layout/AppHeader.test.tsx`
- [ ] **Step 1: Read the current AppHeader to see its command pattern**
```bash
cat src/renderer/components/layout/AppHeader.tsx
```
The header from Phase 6 already uses `useCommandStore` to dispatch `view.toggleSidebar`, `view.togglePreview`, `shortcuts.show`. Add Settings (gear icon) and About (info icon) buttons that dispatch `settings.open` and `help.about`.
- [ ] **Step 2: Add the new buttons**
In `AppHeader.tsx`, add (after the existing buttons, before the `ThemeToggle`):
```tsx
import { Settings, Info } from 'lucide-react';
// ...
const dispatch = useCommandStore((s) => s.dispatch);
// ...
```
- [ ] **Step 3: Update the test**
The test must register the matching commands in `beforeEach` (the AppHeader dispatches, doesn't call store directly). Add to `tests/component/layout/AppHeader.test.tsx`:
```tsx
beforeEach(() => {
// ... existing beforeEach ...
useCommandStore.setState({ handlers: { 'settings.open': () => useAppStore.getState().openModal('settings'), 'help.about': () => useAppStore.getState().openModal('about') }, userBindings: {} } as any);
});
it('settings button opens settings modal', async () => {
render();
await userEvent.click(screen.getByRole('button', { name: /settings/i }));
expect(useAppStore.getState().modal).toEqual({ kind: 'settings' });
});
it('about button opens about modal', async () => {
render();
await userEvent.click(screen.getByRole('button', { name: /^about$/i }));
expect(useAppStore.getState().modal).toEqual({ kind: 'about' });
});
```
- [ ] **Step 4: Run tests**
```bash
npx vitest run tests/component/layout/AppHeader.test.tsx
```
Expected: PASS, 6 tests (2 existing + 2 new + the toggle tests from Phase 6).
- [ ] **Step 5: Commit**
```bash
git add src/renderer/components/layout/AppHeader.tsx tests/component/layout/AppHeader.test.tsx
git commit -m "feat(renderer): AppHeader gets Settings + About icon buttons"
```
---
## Task 35: Mount `ModalLayer` in `App.tsx` + first-launch welcome `useEffect`
**Files:**
- Modify: `src/renderer/App.tsx`
- Create: `src/renderer/hooks/use-welcome-trigger.ts`
- [ ] **Step 1: Create the welcome trigger hook**
Create `src/renderer/hooks/use-welcome-trigger.ts`:
```ts
import { useEffect } from 'react';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
/**
* On first mount, if the user hasn't dismissed the welcome dialog, open it.
* Call once at the top of App.tsx.
*/
export function useWelcomeTrigger() {
useEffect(() => {
if (!useSettingsStore.getState().welcomeDismissed) {
useAppStore.getState().openModal('welcome');
}
}, []);
}
```
- [ ] **Step 2: Mount `ModalLayer` + call the hook in `App.tsx`**
Open `src/renderer/App.tsx`. The current structure (from Phase 2) is:
```tsx
import { AppShell } from './components/layout/AppShell';
function App() {
return ;
}
export default App;
```
Replace with:
```tsx
import { AppShell } from './components/layout/AppShell';
import { ModalLayer } from './components/modals/ModalLayer';
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
function App() {
useWelcomeTrigger();
return (
<>
>
);
}
export default App;
```
- [ ] **Step 3: Build verification**
```bash
npx vite build --config vite.renderer.config.ts
```
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add src/renderer/App.tsx src/renderer/hooks/use-welcome-trigger.ts
git commit -m "feat(renderer): mount ModalLayer + first-launch welcome trigger"
```
---
## Task 36: Phase 7 integration smoke test
**Files:**
- Create: `tests/integration/phase7-modals-smoke.test.tsx`
- [ ] **Step 1: Write the test**
```tsx
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from '@/App';
import { useAppStore } from '@/stores/app-store';
import { useCommandStore } from '@/stores/command-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
vi.mock('@/lib/ipc', () => ({
ipc: {
app: {
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
openExternal: vi.fn().mockResolvedValue({ ok: true }),
},
},
}));
describe('Phase 7 modals integration', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.setState({ modal: { kind: null }, sidebarVisible: true, previewVisible: true, zenMode: false, paneSizes: { sidebar: 20, editor: 50, preview: 30 } } as any);
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]) } as any);
});
it('dispatching settings.open from command store opens SettingsSheet', () => {
registerMenuCommands();
render();
useCommandStore.getState().dispatch('settings.open');
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('AppHeader Settings button dispatches settings.open', async () => {
registerMenuCommands();
render();
await userEvent.click(screen.getByRole('button', { name: /settings/i }));
expect(useAppStore.getState().modal.kind).toBe('settings');
});
it('first launch with welcomeDismissed=false opens welcome modal', () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), welcomeDismissed: false });
registerMenuCommands();
render();
expect(useAppStore.getState().modal.kind).toBe('welcome');
});
it('first launch with welcomeDismissed=true does not open welcome', () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), welcomeDismissed: true });
registerMenuCommands();
render();
expect(useAppStore.getState().modal).toEqual({ kind: null });
});
});
```
- [ ] **Step 2: Run test to verify it passes**
```bash
npx vitest run tests/integration/phase7-modals-smoke.test.tsx
```
Expected: PASS, 4 tests.
If the App.tsx export is `default`, change the import to:
```ts
import App from '@/App';
```
- [ ] **Step 3: Commit**
```bash
git add tests/integration/phase7-modals-smoke.test.tsx
git commit -m "test(renderer): Phase 7 modals integration smoke test"
```
---
## Task 37: Phase 7 verification + tag
- [ ] **Step 1: Run the full test suite**
```bash
npx vitest run
```
Expected: all tests pass. Total should be ~250-270 (target: +50 from the ~220 at end of Phase 6).
- [ ] **Step 2: Build the renderer**
```bash
npx vite build --config vite.renderer.config.ts
```
Expected: `built in XXXms`, exit 0.
- [ ] **Step 3: Lint**
```bash
npx eslint src tests
```
Expected: no errors.
- [ ] **Step 4: Push the branch and tag**
```bash
git push origin react-electron
git tag -a phase-7-modals -m "Phase 7 complete: ModalLayer, 7 modal types, settings store, ASCII table transform, DOCX template picker"
git push origin react-electron --tags
```
---
## Self-Review Notes
**Spec coverage:**
- §2.1 modal state in `useAppStore` → Task 15 ✓
- §2.2 discriminated union → Task 15 ✓
- §2.3 single `` → Task 17 ✓
- §2.4 settings store → Task 16 ✓
- §2.5 welcome trigger → Task 35 ✓
- §2.6 commands trigger modals → Task 33 ✓
- §3.1 shadcn primitives → Tasks 1-12 ✓
- §3.2 modals → Tasks 17-26, 32 ✓
- §3.3 stores → Tasks 15-16 ✓
- §3.4 lib → Tasks 13-14 ✓
- §3.5 modified files → Tasks 33-35 ✓
- §3.6 tests → all tasks have tests ✓
- §4 data flow → Tasks 23-26, 33-35 ✓
- §5 error handling → inline error banner in Tasks 23-26; onRehydrateStorage in Task 16 ✓
- §6 testing strategy → followed throughout ✓
- §9 success criteria → Task 37 ✓
**Placeholder scan:** None.
**Type consistency:**
- `useAppStore.modal` shape consistent across Tasks 15, 17, 33.
- `useSettingsStore.setSetting` signature consistent across Tasks 16, 27-29.
- `useExportSource` shape consistent across Tasks 21, 23-26.
- IPC `ipc.export.{pdf,docx,html,batch}` signatures match `types/ipc.ts` from Phase 1.
**One spec gap:** the spec mentions "ascii-table.test.ts (3 tests: simple table, alignment, empty input)" but Task 13 has 5 tests. Acceptable — the additional `applyAsciiTransform` tests are needed.
**One risk:** Task 33 assumes `register-menu-commands.ts` has a specific shape. If the Phase 6 file uses a different structure (e.g., separate `useRegisterMenuCommands` hook), the engineer must adapt. The tests in Task 33 verify the behavior, not the internals.