diff --git a/docs/superpowers/plans/2026-06-05-react-ui-redesign.md b/docs/superpowers/plans/2026-06-05-react-ui-redesign.md new file mode 100644 index 0000000..3a24105 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-react-ui-redesign.md @@ -0,0 +1,3296 @@ +# MarkdownConverter React + shadcn/ui UI Redesign 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 legacy 213 KB vanilla JS renderer with a React 19 + Vite + TypeScript + shadcn/ui renderer that achieves full feature parity and a Polished + Glassy (Raycast/Arc-style) aesthetic, without touching the Electron main process or preload bridge. + +**Architecture:** Layered renderer. App shell composes feature modules (`editor`, `preview`, `tabs`, `sidebar`, `modals`, `tools`, `export`). Each feature owns its Zustand store slice and its components. Cross-feature access goes through typed hooks. All IPC routed through a single `lib/ipc.ts` wrapper with discriminated-union error returns. Work proceeds via 10 vertical slices — each PR keeps the app runnable. + +**Tech Stack:** +- React 19, Vite, TypeScript (strict) +- shadcn/ui (Radix UI + Tailwind, copy-paste ownership) +- Motion (formerly Framer Motion) for layout/drawer/modal transitions +- Zustand + Immer for state +- CodeMirror 6 (markdown, lang-python/css/html/json, one-dark, search, autocomplete) +- marked + DOMPurify + KaTeX + Mermaid (lazy) + highlight.js for preview +- react-resizable-panels for pane resize, @dnd-kit/core for sortable lists +- next-themes for theme switching, sonner for toasts +- react-hook-form + zod for forms +- Vitest + RTL for unit/component/integration, Playwright for E2E + visual regression + +**Spec:** `docs/superpowers/specs/2026-06-05-react-ui-redesign-design.md` +**Tag:** `design/react-ui-redesign/v1.0` + +--- + +## File Map (Files Created or Modified Across All Phases) + +### Phase 1 — Foundation +**Create:** +- `components.json` — shadcn config (renderer-relative paths) +- `src/renderer/lib/utils.ts` — `cn()` helper +- `src/renderer/lib/ipc.ts` — typed IPC wrapper with `IpcResult` +- `src/renderer/lib/motion.ts` — Motion preset transitions +- `src/renderer/types/ipc.ts` — `IpcResult` discriminated union +- `src/renderer/types/index.ts` — shared types +- `src/renderer/styles/globals.css` — extend with shadow/glass tokens +- `src/renderer/components/theme-provider.tsx` — next-themes wrapper +- `src/renderer/components/theme-toggle.tsx` — sun/moon toggle +- `src/renderer/components/ui/button.tsx` — shadcn button (CLI) +- `vitest.config.ts` — Vitest config +- `src/renderer/test/setup.ts` — test setup +- `tests/unit/lib/cn.test.ts` +- `tests/unit/lib/ipc.test.ts` +- `tests/unit/lib/motion.test.ts` + +**Modify:** +- `package.json` — install new deps +- `tailwind.config.js` — add shadcn config block +- `postcss.config.js` — verify Tailwind directives +- `src/renderer/App.tsx` — wrap with ThemeProvider, render shell +- `src/renderer/main.tsx` — wire StrictMode +- `tsconfig.json` (create if missing) — strict mode + +### Phase 2 — App Shell + Layout +**Create:** +- `src/renderer/stores/app-store.ts` — Zustand global UI store +- `src/renderer/components/layout/AppHeader.tsx` +- `src/renderer/components/layout/TabBar.tsx` +- `src/renderer/components/layout/Toolbar.tsx` +- `src/renderer/components/layout/Breadcrumb.tsx` +- `src/renderer/components/layout/StatusBar.tsx` +- `src/renderer/components/layout/AppShell.tsx` +- `src/renderer/components/ui/resizable.tsx` — shadcn resizable (CLI) +- `src/renderer/hooks/use-pane-sizes.ts` +- `src/renderer/hooks/use-sidebar.ts` +- `tests/unit/stores/app-store.test.ts` +- `tests/component/layout/AppShell.test.tsx` + +**Modify:** +- `src/renderer/App.tsx` — render AppShell +- `src/renderer/styles/globals.css` — scrollbar polish + +### Phase 3 — Editor Pane +**Create:** +- `src/renderer/stores/editor-store.ts` +- `src/renderer/components/editor/EditorPane.tsx` +- `src/renderer/components/editor/CodeMirrorEditor.tsx` +- `src/renderer/components/editor/themes/light.ts` +- `src/renderer/hooks/use-codemirror.ts` +- `tests/unit/stores/editor-store.test.ts` +- `tests/component/editor/EditorPane.test.tsx` + +### Phase 4 — Preview Pane +**Create:** +- `src/renderer/stores/preview-store.ts` +- `src/renderer/components/preview/PreviewPane.tsx` +- `src/renderer/components/preview/MarkdownRenderer.tsx` +- `src/renderer/components/preview/MermaidLazy.tsx` +- `src/renderer/lib/markdown.ts` — marked config + post-processors +- `src/renderer/hooks/use-scroll-sync.ts` +- `tests/unit/lib/markdown.test.ts` +- `tests/component/preview/PreviewPane.test.tsx` + +### Phase 5 — File Tree + Tabs +**Create:** +- `src/renderer/stores/file-store.ts` +- `src/renderer/components/sidebar/Sidebar.tsx` +- `src/renderer/components/sidebar/FileTree.tsx` +- `src/renderer/components/sidebar/Outline.tsx` +- `src/renderer/hooks/use-file-tree.ts` +- `src/renderer/hooks/use-open-file.ts` +- `tests/unit/stores/file-store.test.ts` +- `tests/component/sidebar/FileTree.test.tsx` + +**Modify:** +- `src/renderer/components/layout/TabBar.tsx` — wire to file store + +### Phase 6 — Native Menus + Toolbar +**Create:** +- `src/renderer/stores/command-store.ts` — menu action registry +- `src/renderer/hooks/use-shortcut.ts` +- `src/renderer/hooks/use-menu-action.ts` +- `tests/unit/hooks/use-shortcut.test.ts` + +**Modify:** +- `src/renderer/components/layout/Toolbar.tsx` — wire to actions +- `src/renderer/components/layout/AppHeader.tsx` — wire menu triggers +- `src/main.js` (verify menu integration unchanged) + +### Phase 7 — Modals +**Create:** +- `src/renderer/components/ui/dialog.tsx` — shadcn (CLI) +- `src/renderer/components/ui/sheet.tsx` — shadcn (CLI) +- `src/renderer/components/ui/tabs.tsx` — shadcn (CLI) +- `src/renderer/components/ui/form.tsx` — shadcn (CLI) +- `src/renderer/components/ui/input.tsx` — shadcn (CLI) +- `src/renderer/components/ui/textarea.tsx` — shadcn (CLI) +- `src/renderer/components/ui/select.tsx` — shadcn (CLI) +- `src/renderer/components/ui/switch.tsx` — shadcn (CLI) +- `src/renderer/components/ui/checkbox.tsx` — shadcn (CLI) +- `src/renderer/components/ui/slider.tsx` — shadcn (CLI) +- `src/renderer/components/modals/ExportDialog.tsx` +- `src/renderer/components/modals/SettingsSheet.tsx` +- `src/renderer/components/modals/AboutDialog.tsx` +- `src/renderer/components/modals/ConfirmDialog.tsx` +- `src/renderer/components/modals/WelcomeDialog.tsx` +- `src/renderer/stores/settings-store.ts` +- `src/renderer/lib/validators.ts` — zod schemas +- `tests/component/modals/ExportDialog.test.tsx` +- `tests/component/modals/SettingsSheet.test.tsx` + +### Phase 8 — Toasts +**Create:** +- `src/renderer/components/ui/sonner.tsx` — shadcn (CLI) +- `src/renderer/lib/toast.ts` — typed toast helpers +- `tests/unit/lib/toast.test.ts` + +**Modify:** +- `src/renderer/App.tsx` — mount Toaster +- All async operations in stores — call `toast.*` on result + +### Phase 9 — Advanced Tools +**Create:** +- `src/renderer/components/tools/ZenMode.tsx` +- `src/renderer/components/tools/Repl.tsx` +- `src/renderer/components/tools/AsciiGenerator.tsx` +- `src/renderer/components/tools/TableGenerator.tsx` +- `src/renderer/components/tools/WordExportDialog.tsx` +- `src/renderer/components/tools/PrintPreview.tsx` +- `src/renderer/hooks/use-zen-mode.ts` +- `tests/component/tools/ZenMode.test.tsx` + +### Phase 10 — Polish + Delete Legacy +**Create:** +- `playwright.config.ts` +- `tests/e2e/app.spec.ts` +- `tests/e2e/visual.spec.ts` + +**Modify:** +- `src/renderer/index.html` — remove legacy script references +- `src/main.js` — verify renderer entry is the new build +- `package.json` — remove legacy build scripts if any +- README.md — update dev instructions + +**Delete (eventually):** +- `src/renderer.js` (213 KB legacy) +- `src/styles.css`, `src/styles-modern.css`, `src/styles-concreteinfo.css`, `src/styles-sidebar.css`, `src/styles-welcome.css`, `src/styles-zen.css` +- `src/command-palette.js`, `src/print-preview.js`, `src/welcome.js`, `src/zen-mode.js`, `src/wordTemplateExporter.js` +- `src/ascii-generator.html`, `src/table-generator.html` + +--- + +## Phase 1 — Foundation (Tasks 1-12) + +### Task 1: Install new dependencies + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install runtime deps** + +```bash +cd /home/amith/apps/markdown-converter +npm install \ + next-themes \ + motion \ + react-hook-form \ + @hookform/resolvers \ + zod \ + @dnd-kit/core \ + @dnd-kit/sortable \ + @dnd-kit/utilities \ + react-resizable-panels \ + sonner \ + immer +``` + +Expected: deps added to `package.json` `dependencies`, no errors. + +- [ ] **Step 2: Install dev/test deps** + +```bash +npm install -D \ + vitest \ + @vitest/ui \ + @testing-library/react \ + @testing-library/jest-dom \ + @testing-library/user-event \ + @playwright/test \ + jsdom \ + @types/node +``` + +Expected: deps added to `devDependencies`, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): add shadcn/ui ecosystem, motion, zustand+immer, test stack" +``` + +--- + +### Task 2: Initialize shadcn/ui + +**Files:** +- Create: `components.json` +- Modify: `tailwind.config.js` +- Modify: `src/renderer/styles/globals.css` + +- [ ] **Step 1: Create `components.json`** + +Create `/home/amith/apps/markdown-converter/components.json`: + +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/renderer/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} +``` + +- [ ] **Step 2: Update `tailwind.config.js`** + +Replace the file with: + +```js +const animate = require('tailwindcss-animate') + +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ['class'], + content: [ + './src/renderer/**/*.{js,ts,jsx,tsx}', + './src/renderer/index.html', + ], + theme: { + container: { + center: true, + padding: '2rem', + screens: { '2xl': '1400px' }, + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + brand: { + DEFAULT: '#e5461f', + dark: '#c93a18', + light: '#ff6b47', + }, + success: '#1a7a56', + warning: 'hsl(45 93% 47%)', + danger: 'hsl(0 84% 60%)', + info: 'hsl(199 89% 48%)', + warm: { + 50: '#fafbfc', 100: '#f5f5f5', 200: '#e3e3e3', 300: '#d1d1d1', + 400: '#b0b0b0', 500: '#9a9696', 600: '#7a7878', 700: '#5a5858', + 800: '#4e4e4e', 900: '#464646', 950: '#0d0b09', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + fontFamily: { + sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'], + display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'], + mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'], + }, + keyframes: { + 'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } }, + 'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [animate], +} +``` + +- [ ] **Step 3: Add shadow/glass tokens to `globals.css`** + +Append to `/home/amith/apps/markdown-converter/src/renderer/styles/globals.css` before the closing `}` of the `.dark` block: + +```css + --shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06); + --shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04); + --shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06); + --shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25); + --glass-bg-light: rgba(255, 255, 255, 0.72); + --glass-bg-dark: rgba(13, 11, 9, 0.72); + --glass-border-light: rgba(255, 255, 255, 0.4); + --glass-border-dark: rgba(255, 255, 255, 0.08); +``` + +- [ ] **Step 4: Verify shadcn CLI runs** + +```bash +cd /home/amith/apps/markdown-converter +npx shadcn@latest --help +``` + +Expected: command prints help, exits 0. + +- [ ] **Step 5: Commit** + +```bash +git add components.json tailwind.config.js src/renderer/styles/globals.css +git commit -m "feat(renderer): configure shadcn/ui, extend design tokens for glassy aesthetic" +``` + +--- + +### Task 3: Write failing test for `cn()` helper + +**Files:** +- Create: `tests/unit/lib/cn.test.ts` +- Create: `vitest.config.ts` +- Create: `src/renderer/test/setup.ts` +- Modify: `package.json` (add test script) + +- [ ] **Step 1: Create `vitest.config.ts`** + +Create `/home/amith/apps/markdown-converter/vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/renderer/test/setup.ts'], + include: ['tests/**/*.{test,spec}.{ts,tsx}'], + coverage: { + provider: 'v8', + include: ['src/renderer/**/*.{ts,tsx}'], + exclude: ['src/renderer/test/**', 'src/renderer/**/*.test.*'], + }, + }, + resolve: { + alias: { + '@': path.join(__dirname, 'src/renderer'), + }, + }, +}); +``` + +- [ ] **Step 2: Create test setup** + +Create `/home/amith/apps/markdown-converter/src/renderer/test/setup.ts`: + +```ts +import '@testing-library/jest-dom/vitest'; + +// Mock window.electronAPI for tests +declare global { + interface Window { + electronAPI: any; + } +} + +if (typeof window !== 'undefined' && !window.electronAPI) { + window.electronAPI = {}; +} +``` + +- [ ] **Step 3: Add test script to `package.json`** + +In `/home/amith/apps/markdown-converter/package.json` under `"scripts"`, add: + +```json + "test:renderer": "vitest run", + "test:renderer:watch": "vitest", + "test:renderer:coverage": "vitest run --coverage", +``` + +- [ ] **Step 4: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/unit/lib/cn.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { cn } from '@/lib/utils'; + +describe('cn', () => { + it('merges class names', () => { + expect(cn('a', 'b')).toBe('a b'); + }); + + it('deduplicates conflicting tailwind classes', () => { + expect(cn('p-2', 'p-4')).toBe('p-4'); + }); + + it('ignores falsy values', () => { + expect(cn('a', false, null, undefined, '', 'b')).toBe('a b'); + }); + + it('accepts conditional class objects', () => { + expect(cn('base', { active: true, disabled: false })).toBe('base active'); + }); +}); +``` + +- [ ] **Step 5: Run test to verify it fails** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/lib/cn.test.ts +``` + +Expected: FAIL — `Cannot find module '@/lib/utils'` or similar. + +- [ ] **Step 6: Commit (failing test)** + +```bash +git add vitest.config.ts src/renderer/test/setup.ts tests/unit/lib/cn.test.ts package.json +git commit -m "test(renderer): add failing test for cn() helper + vitest config" +``` + +--- + +### Task 4: Implement `cn()` helper + +**Files:** +- Create: `src/renderer/lib/utils.ts` + +- [ ] **Step 1: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/lib/utils.ts`: + +```ts +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/lib/cn.test.ts +``` + +Expected: PASS, 4 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/lib/utils.ts +git commit -m "feat(renderer): implement cn() helper for tailwind-merge + clsx" +``` + +--- + +### Task 5: Write failing test for IPC wrapper + +**Files:** +- Create: `src/renderer/types/ipc.ts` +- Create: `tests/unit/lib/ipc.test.ts` + +- [ ] **Step 1: Define the `IpcResult` type** + +Create `/home/amith/apps/markdown-converter/src/renderer/types/ipc.ts`: + +```ts +export type IpcResult = + | { ok: true; data: T } + | { ok: false; error: { code: string; message: string } }; + +export interface FileEntry { + name: string; + path: string; + isDirectory: boolean; + size?: number; + modifiedAt?: string; +} + +export interface FileResult { + path: string; + content: string; +} + +export interface PdfOptions { + inputPath: string; + outputPath: string; + format?: 'letter' | 'a4' | 'legal'; + margins?: { top: number; right: number; bottom: number; left: number }; + toc?: boolean; + embedFonts?: boolean; +} + +export interface DocxOptions { + inputPath: string; + outputPath: string; + template?: string; + referenceDoc?: string; +} + +export interface HtmlOptions { + inputPath: string; + outputPath: string; + standalone?: boolean; + highlightStyle?: string; +} + +export interface ExportResult { + outputPath: string; + bytes: number; + durationMs: number; +} + +export interface BatchItem { + inputPath: string; + outputPath: string; +} + +export interface BatchOptions { + format: 'pdf' | 'docx' | 'html' | 'png'; + concurrency?: number; +} + +export interface BatchResult { + total: number; + succeeded: number; + failed: number; + results: Array<{ item: BatchItem; ok: boolean; error?: string }>; +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/unit/lib/ipc.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ipc } from '@/lib/ipc'; +import type { FileResult } from '@/types/ipc'; + +describe('ipc wrapper', () => { + beforeEach(() => { + window.electronAPI = { + file: { + read: vi.fn().mockResolvedValue('# hello'), + write: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + }, + }; + }); + + it('file.read returns ok result on success', async () => { + const result = await ipc.file.read('/foo.md'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe('# hello'); + } + }); + + it('file.read returns err result when channel throws', async () => { + window.electronAPI.file.read = vi.fn().mockRejectedValue(new Error('ENOENT')); + const result = await ipc.file.read('/missing.md'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe('ENOENT'); + } + }); + + it('file.read returns err result when channel missing', async () => { + delete (window.electronAPI.file as any).read; + const result = await ipc.file.read('/foo.md'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('CHANNEL_MISSING'); + } + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/lib/ipc.test.ts +``` + +Expected: FAIL — `Cannot find module '@/lib/ipc'`. + +- [ ] **Step 4: Commit (failing test)** + +```bash +git add src/renderer/types/ipc.ts tests/unit/lib/ipc.test.ts +git commit -m "test(renderer): add failing test for ipc wrapper + IpcResult types" +``` + +--- + +### Task 6: Implement IPC wrapper + +**Files:** +- Create: `src/renderer/lib/ipc.ts` + +- [ ] **Step 1: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/lib/ipc.ts`: + +```ts +import type { + IpcResult, + FileResult, + FileEntry, + PdfOptions, + DocxOptions, + HtmlOptions, + ExportResult, + BatchItem, + BatchOptions, + BatchResult, +} from '@/types/ipc'; + +type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string }; + +function wrap(fn: () => Promise): Promise> { + if (typeof window === 'undefined' || !window.electronAPI) { + return Promise.resolve({ + ok: false, + error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' }, + }); + } + return fn().then( + (data) => ({ ok: true as const, data }), + (err: Error) => ({ + ok: false as const, + error: { code: err.name || 'IPC_ERROR', message: err.message || String(err) }, + }), + ); +} + +function safeCall Promise>( + path: string[], + ...args: Parameters +): Promise> | ChannelMissing>> { + if (typeof window === 'undefined' || !window.electronAPI) { + return Promise.resolve({ + ok: false, + error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' }, + }); + } + let target: any = window.electronAPI; + for (const segment of path) { + if (!target || typeof target[segment] === 'undefined') { + return Promise.resolve({ + ok: false, + error: { code: 'CHANNEL_MISSING', message: `Missing channel: ${path.join('.')}` }, + }); + } + target = target[segment]; + } + if (typeof target !== 'function') { + return Promise.resolve({ + ok: false, + error: { code: 'CHANNEL_MISSING', message: `Not a function: ${path.join('.')}` }, + }); + } + return wrap(() => target(...args)); +} + +export const ipc = { + file: { + open: (): Promise> => + safeCall('file', 'open'), + read: (path: string): Promise> => + safeCall('file', 'read', path), + write: (path: string, content: string): Promise> => + safeCall('file', 'write', path, content), + list: (dir: string): Promise> => + safeCall('file', 'list', dir), + onChange: (cb: (path: string) => void): (() => void) => { + if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) { + return () => {}; + } + return window.electronAPI.file.onChange(cb); + }, + }, + export: { + pdf: (opts: PdfOptions): Promise> => + safeCall('export', 'pdf', opts), + docx: (opts: DocxOptions): Promise> => + safeCall('export', 'docx', opts), + html: (opts: HtmlOptions): Promise> => + safeCall('export', 'html', opts), + batch: (items: BatchItem[], opts: BatchOptions): Promise> => + safeCall('export', 'batch', items, opts), + }, + app: { + getVersion: (): Promise> => + safeCall('app', 'getVersion'), + openExternal: (url: string): Promise> => + safeCall('app', 'openExternal', url), + showItemInFolder: (path: string): Promise> => + safeCall('app', 'showItemInFolder', path), + }, +}; +``` + +- [ ] **Step 2: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/lib/ipc.test.ts +``` + +Expected: PASS, 3 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/lib/ipc.ts +git commit -m "feat(renderer): implement typed IPC wrapper with IpcResult discriminated union" +``` + +--- + +### Task 7: Write failing test for motion presets + +**Files:** +- Create: `tests/unit/lib/motion.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/unit/lib/motion.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { + fadeIn, + slideInRight, + modalPop, + toastSpring, + sidebarToggle, +} from '@/lib/motion'; + +describe('motion presets', () => { + it('fadeIn is a valid transition object', () => { + expect(fadeIn.duration).toBeGreaterThan(0); + expect(fadeIn.ease).toBeDefined(); + }); + + it('slideInRight uses translateX transform', () => { + expect(slideInRight.x).toBe('100%'); + }); + + it('modalPop uses scale and opacity', () => { + expect(modalPop.scale).toBeDefined(); + expect(modalPop.opacity).toBeDefined(); + }); + + it('toastSpring has spring physics', () => { + expect(toastSpring.type).toBe('spring'); + }); + + it('sidebarToggle animates width', () => { + expect(sidebarToggle.width.duration).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/lib/motion.test.ts +``` + +Expected: FAIL — `Cannot find module '@/lib/motion'`. + +- [ ] **Step 3: Commit (failing test)** + +```bash +git add tests/unit/lib/motion.test.ts +git commit -m "test(renderer): add failing test for motion presets" +``` + +--- + +### Task 8: Implement motion presets + +**Files:** +- Create: `src/renderer/lib/motion.ts` + +- [ ] **Step 1: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/lib/motion.ts`: + +```ts +import type { Transition, Variants } from 'motion/react'; + +export const fadeIn: Transition = { + duration: 0.2, + ease: 'easeOut', +}; + +export const slideInRight: Variants = { + initial: { x: '100%', opacity: 0 }, + animate: { x: 0, opacity: 1, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] } }, + exit: { x: '100%', opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } }, +}; + +export const modalPop: Variants = { + initial: { scale: 0.96, opacity: 0 }, + animate: { scale: 1, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } }, + exit: { scale: 0.96, opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } }, +}; + +export const toastSpring: Transition = { + type: 'spring', + stiffness: 300, + damping: 30, +}; + +export const sidebarToggle: Transition = { + duration: 0.25, + ease: 'easeInOut', +}; + +export const tabSwitch: Transition = { + duration: 0.2, + ease: 'easeOut', +}; +``` + +- [ ] **Step 2: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/lib/motion.test.ts +``` + +Expected: PASS, 5 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/lib/motion.ts +git commit -m "feat(renderer): add motion preset transitions for modals/sidebar/toasts" +``` + +--- + +### Task 9: Install shadcn button + write failing test + +**Files:** +- Create: `src/renderer/components/ui/button.tsx` (via CLI) +- Create: `tests/component/ui/button.test.tsx` + +- [ ] **Step 1: Install via shadcn CLI** + +```bash +cd /home/amith/apps/markdown-converter +npx shadcn@latest add button +``` + +Expected: file `src/renderer/components/ui/button.tsx` created; no errors. + +- [ ] **Step 2: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/component/ui/button.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Button } from '@/components/ui/button'; + +describe('Button', () => { + it('renders children', () => { + render(); + expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument(); + }); + + it('calls onClick when clicked', async () => { + let count = 0; + render(); + await userEvent.click(screen.getByRole('button')); + expect(count).toBe(1); + }); + + it('renders the brand variant with primary styling', () => { + render(); + const btn = screen.getByRole('button'); + expect(btn.className).toContain('bg-primary'); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/ui/button.test.tsx +``` + +Expected: PASS, 3 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/components/ui/button.tsx tests/component/ui/button.test.tsx +git commit -m "feat(renderer): install shadcn button primitive with tests" +``` + +--- + +### Task 10: Theme provider + theme toggle + +**Files:** +- Create: `src/renderer/components/theme-provider.tsx` +- Create: `src/renderer/components/theme-toggle.tsx` +- Create: `tests/component/theme-toggle.test.tsx` + +- [ ] **Step 1: Write the failing test for theme toggle** + +Create `/home/amith/apps/markdown-converter/tests/component/theme-toggle.test.tsx`: + +```tsx +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from '@/components/theme-provider'; +import { ThemeToggle } from '@/components/theme-toggle'; + +describe('ThemeToggle', () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove('dark', 'light'); + }); + + it('toggles between light and dark when clicked', async () => { + render( + + + + ); + const btn = screen.getByRole('button'); + expect(btn).toHaveAccessibleName(/toggle theme/i); + await userEvent.click(btn); + expect(document.documentElement.classList.contains('dark')).toBe(true); + await userEvent.click(btn); + expect(document.documentElement.classList.contains('light')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Implement `theme-provider.tsx`** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/theme-provider.tsx`: + +```tsx +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import type { ComponentProps } from 'react'; + +export function ThemeProvider({ children, ...props }: ComponentProps) { + return {children}; +} +``` + +- [ ] **Step 3: Implement `theme-toggle.tsx`** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/theme-toggle.tsx`: + +```tsx +import { useTheme } from 'next-themes'; +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useEffect, useState } from 'react'; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted) { + return ( + + ); + } + + const isDark = resolvedTheme === 'dark'; + return ( + + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/theme-toggle.test.tsx +``` + +Expected: PASS, 1 test passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/components/theme-provider.tsx src/renderer/components/theme-toggle.tsx tests/component/theme-toggle.test.tsx +git commit -m "feat(renderer): add next-themes provider + theme toggle (sun/moon)" +``` + +--- + +### Task 11: Wire `App.tsx` shell with theme + button + +**Files:** +- Modify: `src/renderer/App.tsx` +- Modify: `src/renderer/main.tsx` + +- [ ] **Step 1: Replace `App.tsx`** + +Replace `/home/amith/apps/markdown-converter/src/renderer/App.tsx` with: + +```tsx +import { ThemeToggle } from './components/theme-toggle'; +import { Button } from './components/ui/button'; + +function App() { + return ( +
+
+
+
+

MarkdownConverter

+
+
+ + + + +
+
+
+

Shell skeleton — implementation phases in progress.

+
+
+ ); +} + +export default App; +``` + +- [ ] **Step 2: Update `main.tsx` to wrap with ThemeProvider** + +Replace `/home/amith/apps/markdown-converter/src/renderer/main.tsx` with: + +```tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { ThemeProvider } from './components/theme-provider'; +import './styles/globals.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +); +``` + +- [ ] **Step 3: Verify the renderer builds** + +```bash +cd /home/amith/apps/markdown-converter +npx vite build --config vite.renderer.config.ts +``` + +Expected: `built in XXXms`, exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/App.tsx src/renderer/main.tsx +git commit -m "feat(renderer): wire App.tsx shell with theme provider and top-bar" +``` + +--- + +### Task 12: Phase 1 verification + +- [ ] **Step 1: Run all unit + component tests** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run +``` + +Expected: all tests pass; coverage report shows lib/ ≥ 90 %. + +- [ ] **Step 2: Create Phase 1 completion tag** + +```bash +git tag -a phase-1-foundation v1.0 -m "Phase 1 complete: shadcn/ui foundation, cn/ipc/motion libs, theme toggle" +git push origin react-electron --tags +``` + +--- + +## Phase 2 — App Shell + Layout (Tasks 13-22) + +### Task 13: Create app store (Zustand) + +**Files:** +- Create: `src/renderer/stores/app-store.ts` +- Create: `tests/unit/stores/app-store.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/unit/stores/app-store.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAppStore } from '@/stores/app-store'; + +describe('useAppStore', () => { + beforeEach(() => { + localStorage.clear(); + useAppStore.setState({ + sidebarVisible: true, + previewVisible: true, + zenMode: false, + paneSizes: { sidebar: 20, editor: 50, preview: 30 }, + }); + }); + + it('toggles sidebar visibility', () => { + expect(useAppStore.getState().sidebarVisible).toBe(true); + useAppStore.getState().toggleSidebar(); + expect(useAppStore.getState().sidebarVisible).toBe(false); + }); + + it('toggles preview visibility', () => { + useAppStore.getState().togglePreview(); + expect(useAppStore.getState().previewVisible).toBe(false); + }); + + it('updates pane sizes', () => { + useAppStore.getState().setPaneSizes({ sidebar: 25, editor: 50, preview: 25 }); + expect(useAppStore.getState().paneSizes).toEqual({ sidebar: 25, editor: 50, preview: 25 }); + }); + + it('enables and disables zen mode', () => { + useAppStore.getState().setZenMode(true); + expect(useAppStore.getState().zenMode).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/stores/app-store.test.ts +``` + +Expected: FAIL — `Cannot find module '@/stores/app-store'`. + +- [ ] **Step 3: Implement the store** + +Create `/home/amith/apps/markdown-converter/src/renderer/stores/app-store.ts`: + +```ts +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface PaneSizes { + sidebar: number; + editor: number; + preview: number; +} + +interface AppState { + sidebarVisible: boolean; + previewVisible: boolean; + zenMode: boolean; + paneSizes: PaneSizes; + toggleSidebar: () => void; + togglePreview: () => void; + setZenMode: (value: boolean) => void; + setPaneSizes: (sizes: PaneSizes) => void; +} + +export const useAppStore = create()( + persist( + (set) => ({ + sidebarVisible: true, + previewVisible: true, + zenMode: false, + paneSizes: { sidebar: 20, editor: 50, preview: 30 }, + toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })), + togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })), + setZenMode: (value) => set({ zenMode: value }), + setPaneSizes: (sizes) => set({ paneSizes: sizes }), + }), + { name: 'mc-app-store' } + ) +); +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/stores/app-store.test.ts +``` + +Expected: PASS, 4 tests passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/stores/app-store.ts tests/unit/stores/app-store.test.ts +git commit -m "feat(renderer): add useAppStore with sidebar/preview/zen/pane-sizes" +``` + +--- + +### Task 14: Install shadcn resizable + +**Files:** +- Create: `src/renderer/components/ui/resizable.tsx` + +- [ ] **Step 1: Install via CLI** + +```bash +cd /home/amith/apps/markdown-converter +npx shadcn@latest add resizable +``` + +Expected: file `src/renderer/components/ui/resizable.tsx` created. + +- [ ] **Step 2: Verify it imports from react-resizable-panels** + +```bash +cd /home/amith/apps/markdown-converter +head -5 src/renderer/components/ui/resizable.tsx +``` + +Expected: import line references `react-resizable-panels`. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/components/ui/resizable.tsx +git commit -m "feat(renderer): install shadcn resizable primitive" +``` + +--- + +### Task 15: Build AppHeader component + +**Files:** +- Create: `src/renderer/components/layout/AppHeader.tsx` +- Create: `tests/component/layout/AppHeader.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/component/layout/AppHeader.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 { ThemeProvider } from '@/components/theme-provider'; +import { AppHeader } from '@/components/layout/AppHeader'; +import { useAppStore } from '@/stores/app-store'; + +describe('AppHeader', () => { + beforeEach(() => { + localStorage.clear(); + useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false }); + }); + + it('renders the app title', () => { + render( + + + + ); + expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument(); + }); + + it('toggles sidebar when sidebar button clicked', async () => { + render( + + + + ); + const btn = screen.getByRole('button', { name: /toggle sidebar/i }); + await userEvent.click(btn); + expect(useAppStore.getState().sidebarVisible).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/layout/AppHeader.test.tsx +``` + +Expected: FAIL — `Cannot find module '@/components/layout/AppHeader'`. + +- [ ] **Step 3: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/layout/AppHeader.tsx`: + +```tsx +import { PanelLeft, PanelRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ThemeToggle } from '@/components/theme-toggle'; +import { useAppStore } from '@/stores/app-store'; + +export function AppHeader() { + const { sidebarVisible, previewVisible, toggleSidebar, togglePreview } = useAppStore(); + + return ( +
+
+
+

MarkdownConverter

+
+
+ + + +
+
+ ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/layout/AppHeader.test.tsx +``` + +Expected: PASS, 2 tests passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/components/layout/AppHeader.tsx tests/component/layout/AppHeader.test.tsx +git commit -m "feat(renderer): AppHeader with sidebar/preview toggles + theme toggle" +``` + +--- + +### Task 16: Build TabBar component (skeleton) + +**Files:** +- Create: `src/renderer/components/layout/TabBar.tsx` +- Create: `tests/component/layout/TabBar.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/component/layout/TabBar.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TabBar } from '@/components/layout/TabBar'; + +describe('TabBar', () => { + it('renders an empty state when no tabs are open', () => { + render(); + expect(screen.getByText(/no files open/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/layout/TabBar.tsx`: + +```tsx +export function TabBar() { + return ( +
+ No files open +
+ ); +} +``` + +- [ ] **Step 3: Run test, commit** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/layout/TabBar.test.tsx +git add src/renderer/components/layout/TabBar.tsx tests/component/layout/TabBar.test.tsx +git commit -m "feat(renderer): TabBar skeleton (no tabs yet — wired in Phase 5)" +``` + +--- + +### Task 17: Build Toolbar (placeholder) + +**Files:** +- Create: `src/renderer/components/layout/Toolbar.tsx` +- Create: `tests/component/layout/Toolbar.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/component/layout/Toolbar.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Toolbar } from '@/components/layout/Toolbar'; + +describe('Toolbar', () => { + it('renders formatting buttons', () => { + render(); + expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/layout/Toolbar.tsx`: + +```tsx +import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export function Toolbar() { + return ( +
+ + + + + + +
+ ); +} +``` + +- [ ] **Step 3: Run test, commit** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/layout/Toolbar.test.tsx +git add src/renderer/components/layout/Toolbar.tsx tests/component/layout/Toolbar.test.tsx +git commit -m "feat(renderer): Toolbar skeleton with formatting buttons" +``` + +--- + +### Task 18: Build Breadcrumb (placeholder) + +**Files:** +- Create: `src/renderer/components/layout/Breadcrumb.tsx` + +- [ ] **Step 1: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/layout/Breadcrumb.tsx`: + +```tsx +export function Breadcrumb() { + return ( + + ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/renderer/components/layout/Breadcrumb.tsx +git commit -m "feat(renderer): Breadcrumb placeholder (wired in Phase 5)" +``` + +--- + +### Task 19: Build StatusBar (placeholder) + +**Files:** +- Create: `src/renderer/components/layout/StatusBar.tsx` +- Create: `tests/component/layout/StatusBar.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/component/layout/StatusBar.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StatusBar } from '@/components/layout/StatusBar'; + +describe('StatusBar', () => { + it('renders zero word count when no file is open', () => { + render(); + expect(screen.getByText(/0 words/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/layout/StatusBar.tsx`: + +```tsx +export function StatusBar() { + return ( +
+
+ 0 words + UTF-8 +
+
+ Ln 1, Col 1 + Markdown +
+
+ ); +} +``` + +- [ ] **Step 3: Run test, commit** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/layout/StatusBar.test.tsx +git add src/renderer/components/layout/StatusBar.tsx tests/component/layout/StatusBar.test.tsx +git commit -m "feat(renderer): StatusBar placeholder (word count wired in Phase 3)" +``` + +--- + +### Task 20: Build AppShell with resizable panes + +**Files:** +- Create: `src/renderer/components/layout/AppShell.tsx` +- Create: `tests/component/layout/AppShell.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/component/layout/AppShell.test.tsx`: + +```tsx +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@/components/theme-provider'; +import { AppShell } from '@/components/layout/AppShell'; +import { useAppStore } from '@/stores/app-store'; + +describe('AppShell', () => { + beforeEach(() => { + localStorage.clear(); + useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false }); + }); + + it('renders all shell surfaces when sidebar and preview are visible', () => { + render( + + + + ); + expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument(); + expect(screen.getByText(/no files open/i)).toBeInTheDocument(); + expect(screen.getByText(/no file selected/i)).toBeInTheDocument(); + expect(screen.getByText(/0 words/i)).toBeInTheDocument(); + }); + + it('hides sidebar when sidebarVisible is false', () => { + useAppStore.setState({ sidebarVisible: false }); + render( + + + + ); + expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/layout/AppShell.tsx`: + +```tsx +import { AppHeader } from './AppHeader'; +import { TabBar } from './TabBar'; +import { Toolbar } from './Toolbar'; +import { Breadcrumb } from './Breadcrumb'; +import { StatusBar } from './StatusBar'; +import { useAppStore } from '@/stores/app-store'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; + +export function AppShell() { + const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore(); + + return ( +
+ + + + +
+ setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })} + > + {sidebarVisible && ( + <> + + + + + + )} + +
+ Editor placeholder +
+
+ {previewVisible && ( + <> + + +
+ Preview placeholder +
+
+ + )} +
+
+ +
+ ); +} +``` + +- [ ] **Step 3: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/layout/AppShell.test.tsx +``` + +Expected: PASS, 2 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/components/layout/AppShell.tsx tests/component/layout/AppShell.test.tsx +git commit -m "feat(renderer): AppShell with resizable panes, sidebar/preview toggle, persisted sizes" +``` + +--- + +### Task 21: Replace App.tsx with AppShell + +**Files:** +- Modify: `src/renderer/App.tsx` + +- [ ] **Step 1: Replace** + +Replace `/home/amith/apps/markdown-converter/src/renderer/App.tsx` with: + +```tsx +import { AppShell } from './components/layout/AppShell'; + +function App() { + return ; +} + +export default App; +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /home/amith/apps/markdown-converter +npx vite build --config vite.renderer.config.ts +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/App.tsx +git commit -m "feat(renderer): App.tsx renders AppShell" +``` + +--- + +### Task 22: Phase 2 verification + +- [ ] **Step 1: Run all tests** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run +``` + +Expected: all pass. + +- [ ] **Step 2: Tag and push** + +```bash +git tag -a phase-2-shell v1.0 -m "Phase 2 complete: AppShell with resizable panes, toggleable sidebar/preview, persisted sizes" +git push origin react-electron --tags +``` + +--- + +## Phase 3 — Editor Pane (Tasks 23-30) + +### Task 23: Create editor store (Zustand + Immer) + +**Files:** +- Create: `src/renderer/stores/editor-store.ts` +- Create: `tests/unit/stores/editor-store.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/unit/stores/editor-store.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { useEditorStore } from '@/stores/editor-store'; + +describe('useEditorStore', () => { + beforeEach(() => { + useEditorStore.setState({ buffers: new Map(), activeId: null }); + }); + + it('creates a new buffer with the given content', () => { + useEditorStore.getState().openBuffer('file-1', '/foo.md', '# hello'); + const buf = useEditorStore.getState().buffers.get('file-1'); + expect(buf?.content).toBe('# hello'); + expect(buf?.path).toBe('/foo.md'); + }); + + it('updates content and marks dirty', () => { + useEditorStore.getState().openBuffer('file-1', '/foo.md', ''); + useEditorStore.getState().updateContent('file-1', 'new content'); + const buf = useEditorStore.getState().buffers.get('file-1'); + expect(buf?.content).toBe('new content'); + expect(buf?.dirty).toBe(true); + }); + + it('marks a buffer clean after save', () => { + useEditorStore.getState().openBuffer('file-1', '/foo.md', ''); + useEditorStore.getState().updateContent('file-1', 'x'); + useEditorStore.getState().markSaved('file-1'); + expect(useEditorStore.getState().buffers.get('file-1')?.dirty).toBe(false); + }); + + it('closes a buffer and removes it', () => { + useEditorStore.getState().openBuffer('file-1', '/foo.md', ''); + useEditorStore.getState().closeBuffer('file-1'); + expect(useEditorStore.getState().buffers.has('file-1')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/stores/editor-store.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/stores/editor-store.ts`: + +```ts +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +export interface Buffer { + id: string; + path: string; + content: string; + dirty: boolean; + cursor?: { line: number; column: number }; +} + +interface EditorState { + buffers: Map; + activeId: string | null; + openBuffer: (id: string, path: string, content: string) => void; + updateContent: (id: string, content: string) => void; + markSaved: (id: string) => void; + setCursor: (id: string, line: number, column: number) => void; + closeBuffer: (id: string) => void; + setActive: (id: string) => void; +} + +export const useEditorStore = create()( + immer((set) => ({ + buffers: new Map(), + activeId: null, + openBuffer: (id, path, content) => + set((s) => { + s.buffers.set(id, { id, path, content, dirty: false }); + s.activeId = id; + }), + updateContent: (id, content) => + set((s) => { + const buf = s.buffers.get(id); + if (buf) { + buf.content = content; + buf.dirty = true; + } + }), + markSaved: (id) => + set((s) => { + const buf = s.buffers.get(id); + if (buf) buf.dirty = false; + }), + setCursor: (id, line, column) => + set((s) => { + const buf = s.buffers.get(id); + if (buf) buf.cursor = { line, column }; + }), + closeBuffer: (id) => + set((s) => { + s.buffers.delete(id); + if (s.activeId === id) s.activeId = null; + }), + setActive: (id) => + set((s) => { + s.activeId = id; + }), + })) +); +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/unit/stores/editor-store.test.ts +``` + +Expected: PASS, 4 tests passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/stores/editor-store.ts tests/unit/stores/editor-store.test.ts +git commit -m "feat(renderer): editor store with buffers, dirty state, cursor (immer)" +``` + +--- + +### Task 24: CodeMirror light theme + +**Files:** +- Create: `src/renderer/components/editor/themes/light.ts` + +- [ ] **Step 1: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/editor/themes/light.ts`: + +```ts +import { EditorView } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; + +const colors = { + background: '#ffffff', + foreground: '#0d0b09', + cursor: '#e5461f', + selection: 'rgba(229, 70, 31, 0.15)', + gutterBackground: '#fafbfc', + gutterForeground: '#7a7878', + lineHighlight: 'rgba(0, 0, 0, 0.04)', +}; + +export const lightTheme = EditorView.theme( + { + '&': { + backgroundColor: colors.background, + color: colors.foreground, + height: '100%', + }, + '.cm-content': { + caretColor: colors.cursor, + fontFamily: 'JetBrains Mono, Fira Code, monospace', + fontSize: '13.5px', + }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': { + backgroundColor: colors.selection, + }, + '.cm-gutters': { + backgroundColor: colors.gutterBackground, + color: colors.gutterForeground, + border: 'none', + }, + '.cm-activeLine': { backgroundColor: colors.lineHighlight }, + '.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' }, + }, + { dark: false } +); + +const highlightStyle = HighlightStyle.define([ + { tag: t.heading1, color: '#0d0b09', fontWeight: '700' }, + { tag: t.heading2, color: '#0d0b09', fontWeight: '700' }, + { tag: t.heading3, color: '#464646', fontWeight: '600' }, + { tag: t.link, color: '#e5461f', textDecoration: 'underline' }, + { tag: t.url, color: '#e5461f' }, + { tag: t.emphasis, fontStyle: 'italic' }, + { tag: t.strong, fontWeight: '700' }, + { tag: t.monospace, color: '#c93a18' }, + { tag: t.list, color: '#0ea5e9' }, + { tag: t.quote, color: '#7a7878', fontStyle: 'italic' }, +]); + +export const lightHighlight = syntaxHighlighting(highlightStyle); +``` + +- [ ] **Step 2: Install lezer highlight if not present** + +```bash +cd /home/amith/apps/markdown-converter +npm list @lezer/highlight || npm install -D @lezer/highlight +``` + +Expected: `@lezer/highlight` is installed (transitively or directly). + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/components/editor/themes/light.ts +git commit -m "feat(renderer): CodeMirror light theme with brand-aware syntax colors" +``` + +--- + +### Task 25: CodeMirrorEditor component + +**Files:** +- Create: `src/renderer/components/editor/CodeMirrorEditor.tsx` + +- [ ] **Step 1: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/editor/CodeMirrorEditor.tsx`: + +```tsx +import { useEffect, useRef } from 'react'; +import { EditorState, Compartment } from '@codemirror/state'; +import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { useTheme } from 'next-themes'; +import { lightTheme, lightHighlight } from './themes/light'; +import { useEditorStore } from '@/stores/editor-store'; + +interface Props { + bufferId: string; + initialContent: string; + onChange?: (content: string) => void; + onCursorChange?: (line: number, column: number) => void; +} + +export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) { + const ref = useRef(null); + const viewRef = useRef(null); + const themeCompartment = useRef(new Compartment()); + const { resolvedTheme } = useTheme(); + const updateContent = useEditorStore((s) => s.updateContent); + const setCursor = useEditorStore((s) => s.setCursor); + + useEffect(() => { + if (!ref.current) return; + const state = EditorState.create({ + doc: initialContent, + extensions: [ + lineNumbers(), + highlightActiveLine(), + highlightSelectionMatches(), + history(), + drawSelection(), + markdown({ base: markdownLanguage, codeLanguages: [] }), + autocompletion(), + keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]), + themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]), + EditorView.lineWrapping, + EditorView.updateListener.of((v) => { + if (v.docChanged) { + const content = v.state.doc.toString(); + updateContent(bufferId, content); + onChange?.(content); + } + if (v.selectionSet || v.docChanged) { + const head = v.state.selection.main.head; + const line = v.state.doc.lineAt(head); + const lineNo = line.number; + const col = head - line.from + 1; + setCursor(bufferId, lineNo, col); + onCursorChange?.(lineNo, col); + } + }), + ], + }); + const view = new EditorView({ state, parent: ref.current }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bufferId]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: themeCompartment.current.reconfigure( + resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight] + ), + }); + }, [resolvedTheme]); + + return
; +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /home/amith/apps/markdown-converter +npx tsc --noEmit -p . +``` + +Expected: no errors (or only pre-existing ones). + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/components/editor/CodeMirrorEditor.tsx +git commit -m "feat(renderer): CodeMirror 6 editor wrapper with markdown, themes, keymaps" +``` + +--- + +### Task 26: EditorPane + +**Files:** +- Create: `src/renderer/components/editor/EditorPane.tsx` +- Create: `tests/component/editor/EditorPane.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/component/editor/EditorPane.test.tsx`: + +```tsx +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@/components/theme-provider'; +import { EditorPane } from '@/components/editor/EditorPane'; +import { useEditorStore } from '@/stores/editor-store'; + +describe('EditorPane', () => { + beforeEach(() => { + useEditorStore.setState({ buffers: new Map(), activeId: null }); + }); + + it('renders the empty state when no buffer is open', () => { + render( + + + + ); + expect(screen.getByText(/no file open/i)).toBeInTheDocument(); + }); + + it('renders the editor when a buffer is open', () => { + useEditorStore.getState().openBuffer('b1', '/x.md', '# hello'); + render( + + + + ); + expect(screen.getByText('# hello')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Implement** + +Create `/home/amith/apps/markdown-converter/src/renderer/components/editor/EditorPane.tsx`: + +```tsx +import { CodeMirrorEditor } from './CodeMirrorEditor'; +import { useEditorStore } from '@/stores/editor-store'; + +export function EditorPane() { + const { buffers, activeId } = useEditorStore(); + const buf = activeId ? buffers.get(activeId) : null; + + if (!buf) { + return ( +
+

No file open. Use File → Open to start.

+
+ ); + } + + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 3: Run test, commit** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run tests/component/editor/EditorPane.test.tsx +git add src/renderer/components/editor/EditorPane.tsx tests/component/editor/EditorPane.test.tsx +git commit -m "feat(renderer): EditorPane with empty state and CodeMirror rendering" +``` + +--- + +### Task 27: Wire EditorPane into AppShell + +**Files:** +- Modify: `src/renderer/components/layout/AppShell.tsx` + +- [ ] **Step 1: Replace the editor placeholder** + +In `AppShell.tsx`, change the editor `
` from: + +```tsx +
+ Editor placeholder +
+``` + +to: + +```tsx + +``` + +And add the import at the top: + +```tsx +import { EditorPane } from '@/components/editor/EditorPane'; +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /home/amith/apps/markdown-converter +npx vite build --config vite.renderer.config.ts +``` + +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/components/layout/AppShell.tsx +git commit -m "feat(renderer): wire EditorPane into AppShell" +``` + +--- + +### Task 28: Update StatusBar to show word count + +**Files:** +- Modify: `src/renderer/components/layout/StatusBar.tsx` +- Modify: `src/renderer/components/layout/AppShell.tsx` + +- [ ] **Step 1: Update StatusBar to subscribe to editor store** + +Replace `StatusBar.tsx` with: + +```tsx +import { useEditorStore } from '@/stores/editor-store'; + +function countWords(text: string): number { + return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length; +} + +export function StatusBar() { + const { buffers, activeId } = useEditorStore(); + const buf = activeId ? buffers.get(activeId) : null; + const wordCount = buf ? countWords(buf.content) : 0; + const cursor = buf?.cursor ?? { line: 1, column: 1 }; + + return ( +
+
+ {wordCount} words + UTF-8 +
+
+ Ln {cursor.line}, Col {cursor.column} + Markdown +
+
+ ); +} +``` + +- [ ] **Step 2: Run all tests** + +```bash +cd /home/amith/apps/markdown-converter +npx vitest run +``` + +Expected: all pass (StatusBar test should still pass — it just shows "0 words" when no buffer). + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/components/layout/StatusBar.tsx +git commit -m "feat(renderer): StatusBar reads from editor store (word count, cursor pos)" +``` + +--- + +### Task 29: Verify the editor end-to-end + +- [ ] **Step 1: Build the renderer** + +```bash +cd /home/amith/apps/markdown-converter +npx vite build --config vite.renderer.config.ts +``` + +Expected: success. + +- [ ] **Step 2: Tag and push** + +```bash +git tag -a phase-3-editor v1.0 -m "Phase 3 complete: CodeMirror 6 editor wired to store + UI" +git push origin react-electron --tags +``` + +--- + +### Task 30: Phase 3 verification (optional polish) + +- [ ] **Step 1: Review editor for any obvious polish gaps** + - Indent guides? + - Code block language picker? + - Search panel? + +If any are obviously missing, file follow-up issues. Otherwise proceed to Phase 4. + +--- + +## Phase 4 — Preview Pane (Tasks 31-40) + +### Task 31: Markdown rendering lib + +**Files:** +- Create: `src/renderer/lib/markdown.ts` +- Create: `tests/unit/lib/markdown.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `/home/amith/apps/markdown-converter/tests/unit/lib/markdown.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { renderMarkdown } from '@/lib/markdown'; + +describe('renderMarkdown', () => { + it('renders headings', () => { + const html = renderMarkdown('# Hello'); + expect(html).toContain('

Hello

'); + }); + + it('renders bold and italic', () => { + const html = renderMarkdown('**bold** and *italic*'); + expect(html).toContain('bold'); + expect(html).toContain('italic'); + }); + + it('renders code blocks with language class', () => { + const html = renderMarkdown('```js\nconst x = 1;\n```'); + expect(html).toContain('language-js'); + }); + + it('renders mermaid blocks with a placeholder', () => { + const html = renderMarkdown('```mermaid\ngraph TD\nA-->B\n```'); + expect(html).toContain('data-mermaid-source='); + }); + + it('sanitizes dangerous HTML', () => { + const html = renderMarkdown(''); + expect(html).not.toContain('