Files
markdown-converter/docs/superpowers/plans/2026-06-05-react-ui-redesign.md
T
amitwh 8a8671b128 docs(plan): add React UI redesign implementation plan (10 vertical slices)
- Phase 1: Foundation (shadcn, deps, lib utils, motion presets, theme)
- Phase 2: App shell + resizable panes
- Phase 3: Editor (CodeMirror 6 + immer store)
- Phase 4: Preview (marked + KaTeX + mermaid + scroll sync)
- Phase 5: File tree + tabs
- Phase 6: Native menus + toolbar
- Phase 7: Modals (export, settings, about, confirm)
- Phase 8: Toasts (sonner)
- Phase 9: Advanced tools (zen, repl, ascii, table, print, word)
- Phase 10: Polish + delete legacy renderer

Phases 1-4 are fully fleshed out (60+ TDD tasks with working code).
Phases 5-9 are sketched with the same TDD pattern; detailed
steps will be expanded at execution time following the conventions
established in Phases 1-4.
2026-06-05 08:52:03 +05:30

92 KiB
Raw Blame History

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.tscn() helper
  • src/renderer/lib/ipc.ts — typed IPC wrapper with IpcResult<T>
  • src/renderer/lib/motion.ts — Motion preset transitions
  • src/renderer/types/ipc.tsIpcResult<T> 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

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
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
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:

{
  "$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:

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:

  --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
cd /home/amith/apps/markdown-converter
npx shadcn@latest --help

Expected: command prints help, exits 0.

  • Step 5: Commit
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:

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:

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:

    "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:

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
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)
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:

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
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/lib/cn.test.ts

Expected: PASS, 4 tests passed.

  • Step 3: Commit
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:

export type IpcResult<T> =
  | { 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:

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
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)
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:

import type {
  IpcResult,
  FileResult,
  FileEntry,
  PdfOptions,
  DocxOptions,
  HtmlOptions,
  ExportResult,
  BatchItem,
  BatchOptions,
  BatchResult,
} from '@/types/ipc';

type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string };

function wrap<T>(fn: () => Promise<T>): Promise<IpcResult<T | ChannelMissing>> {
  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<T extends (...args: any[]) => Promise<any>>(
  path: string[],
  ...args: Parameters<T>
): Promise<IpcResult<Awaited<ReturnType<T>> | 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<IpcResult<FileResult | ChannelMissing>> =>
      safeCall('file', 'open'),
    read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
      safeCall('file', 'read', path),
    write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
      safeCall('file', 'write', path, content),
    list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
      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<IpcResult<ExportResult | ChannelMissing>> =>
      safeCall('export', 'pdf', opts),
    docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
      safeCall('export', 'docx', opts),
    html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
      safeCall('export', 'html', opts),
    batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult | ChannelMissing>> =>
      safeCall('export', 'batch', items, opts),
  },
  app: {
    getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
      safeCall('app', 'getVersion'),
    openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
      safeCall('app', 'openExternal', url),
    showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
      safeCall('app', 'showItemInFolder', path),
  },
};
  • Step 2: Run test to verify it passes
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/lib/ipc.test.ts

Expected: PASS, 3 tests passed.

  • Step 3: Commit
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:

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
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)
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:

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
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/lib/motion.test.ts

Expected: PASS, 5 tests passed.

  • Step 3: Commit
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

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:

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(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    let count = 0;
    render(<Button onClick={() => count++}>+</Button>);
    await userEvent.click(screen.getByRole('button'));
    expect(count).toBe(1);
  });

  it('renders the brand variant with primary styling', () => {
    render(<Button variant="default">Brand</Button>);
    const btn = screen.getByRole('button');
    expect(btn.className).toContain('bg-primary');
  });
});
  • Step 3: Run test to verify it passes
cd /home/amith/apps/markdown-converter
npx vitest run tests/component/ui/button.test.tsx

Expected: PASS, 3 tests passed.

  • Step 4: Commit
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:

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(
      <ThemeProvider defaultTheme="light" attribute="class">
        <ThemeToggle />
      </ThemeProvider>
    );
    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:

import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ComponentProps } from 'react';

export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
  • Step 3: Implement theme-toggle.tsx

Create /home/amith/apps/markdown-converter/src/renderer/components/theme-toggle.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 (
      <Button variant="ghost" size="icon" aria-label="Toggle theme" className="opacity-0">
        <Sun className="h-4 w-4" />
      </Button>
    );
  }

  const isDark = resolvedTheme === 'dark';
  return (
    <Button
      variant="ghost"
      size="icon"
      aria-label="Toggle theme"
      onClick={() => setTheme(isDark ? 'light' : 'dark')}
    >
      {isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
    </Button>
  );
}
  • Step 4: Run test to verify it passes
cd /home/amith/apps/markdown-converter
npx vitest run tests/component/theme-toggle.test.tsx

Expected: PASS, 1 test passed.

  • Step 5: Commit
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:

import { ThemeToggle } from './components/theme-toggle';
import { Button } from './components/ui/button';

function App() {
  return (
    <div className="flex h-screen flex-col bg-background text-foreground">
      <header className="flex h-14 items-center justify-between border-b border-border px-4">
        <div className="flex items-center gap-3">
          <div className="h-7 w-7 rounded-md bg-brand" aria-label="MarkdownConverter logo" />
          <h1 className="text-sm font-semibold">MarkdownConverter</h1>
        </div>
        <div className="flex items-center gap-2">
          <Button variant="ghost" size="sm">File</Button>
          <Button variant="ghost" size="sm">Edit</Button>
          <Button variant="ghost" size="sm">View</Button>
          <ThemeToggle />
        </div>
      </header>
      <main className="flex flex-1 items-center justify-center text-muted-foreground">
        <p>Shell skeleton  implementation phases in progress.</p>
      </main>
    </div>
  );
}

export default App;
  • Step 2: Update main.tsx to wrap with ThemeProvider

Replace /home/amith/apps/markdown-converter/src/renderer/main.tsx with:

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(
  <React.StrictMode>
    <ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
      <App />
    </ThemeProvider>
  </React.StrictMode>
);
  • Step 3: Verify the renderer builds
cd /home/amith/apps/markdown-converter
npx vite build --config vite.renderer.config.ts

Expected: built in XXXms, exit 0.

  • Step 4: Commit
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
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
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:

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
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:

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<AppState>()(
  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
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/stores/app-store.test.ts

Expected: PASS, 4 tests passed.

  • Step 5: Commit
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

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
cd /home/amith/apps/markdown-converter
head -5 src/renderer/components/ui/resizable.tsx

Expected: import line references react-resizable-panels.

  • Step 3: Commit
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:

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(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <AppHeader />
      </ThemeProvider>
    );
    expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
  });

  it('toggles sidebar when sidebar button clicked', async () => {
    render(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <AppHeader />
      </ThemeProvider>
    );
    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
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:

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 (
    <header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
      <div className="flex items-center gap-3">
        <div
          className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
          aria-label="MarkdownConverter logo"
        />
        <h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
      </div>
      <div className="flex items-center gap-1">
        <Button
          variant="ghost"
          size="icon"
          aria-label="Toggle sidebar"
          aria-pressed={sidebarVisible}
          onClick={toggleSidebar}
        >
          <PanelLeft className="h-4 w-4" />
        </Button>
        <Button
          variant="ghost"
          size="icon"
          aria-label="Toggle preview"
          aria-pressed={previewVisible}
          onClick={togglePreview}
        >
          <PanelRight className="h-4 w-4" />
        </Button>
        <ThemeToggle />
      </div>
    </header>
  );
}
  • Step 4: Run test to verify it passes
cd /home/amith/apps/markdown-converter
npx vitest run tests/component/layout/AppHeader.test.tsx

Expected: PASS, 2 tests passed.

  • Step 5: Commit
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:

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(<TabBar />);
    expect(screen.getByText(/no files open/i)).toBeInTheDocument();
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/components/layout/TabBar.tsx:

export function TabBar() {
  return (
    <div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
      <span>No files open</span>
    </div>
  );
}
  • Step 3: Run test, commit
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:

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(<Toolbar />);
    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:

import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';

export function Toolbar() {
  return (
    <div className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3">
      <Button variant="ghost" size="icon" aria-label="Bold"><Bold className="h-4 w-4" /></Button>
      <Button variant="ghost" size="icon" aria-label="Italic"><Italic className="h-4 w-4" /></Button>
      <Button variant="ghost" size="icon" aria-label="Unordered list"><List className="h-4 w-4" /></Button>
      <Button variant="ghost" size="icon" aria-label="Ordered list"><ListOrdered className="h-4 w-4" /></Button>
      <Button variant="ghost" size="icon" aria-label="Code"><Code className="h-4 w-4" /></Button>
      <Button variant="ghost" size="icon" aria-label="Link"><LinkIcon className="h-4 w-4" /></Button>
    </div>
  );
}
  • Step 3: Run test, commit
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:

export function Breadcrumb() {
  return (
    <nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
      <span>No file selected</span>
    </nav>
  );
}
  • Step 2: Commit
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:

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(<StatusBar />);
    expect(screen.getByText(/0 words/i)).toBeInTheDocument();
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/components/layout/StatusBar.tsx:

export function StatusBar() {
  return (
    <footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
      <div className="flex items-center gap-4">
        <span>0 words</span>
        <span>UTF-8</span>
      </div>
      <div className="flex items-center gap-4">
        <span>Ln 1, Col 1</span>
        <span>Markdown</span>
      </div>
    </footer>
  );
}
  • Step 3: Run test, commit
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:

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(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <AppShell />
      </ThemeProvider>
    );
    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(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <AppShell />
      </ThemeProvider>
    );
    expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/components/layout/AppShell.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 (
    <div className="flex h-screen flex-col bg-background text-foreground">
      <AppHeader />
      <TabBar />
      <Toolbar />
      <Breadcrumb />
      <main className="flex-1 overflow-hidden">
        <ResizablePanelGroup
          direction="horizontal"
          onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
        >
          {sidebarVisible && (
            <>
              <ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
                <aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
                  File tree placeholder
                </aside>
              </ResizablePanel>
              <ResizableHandle />
            </>
          )}
          <ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
            <section className="h-full bg-background p-4 text-sm text-muted-foreground">
              Editor placeholder
            </section>
          </ResizablePanel>
          {previewVisible && (
            <>
              <ResizableHandle />
              <ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
                <section className="h-full border-l border-border bg-card/10 p-4 text-sm text-muted-foreground">
                  Preview placeholder
                </section>
              </ResizablePanel>
            </>
          )}
        </ResizablePanelGroup>
      </main>
      <StatusBar />
    </div>
  );
}
  • Step 3: Run test to verify it passes
cd /home/amith/apps/markdown-converter
npx vitest run tests/component/layout/AppShell.test.tsx

Expected: PASS, 2 tests passed.

  • Step 4: Commit
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:

import { AppShell } from './components/layout/AppShell';

function App() {
  return <AppShell />;
}

export default App;
  • Step 2: Verify build
cd /home/amith/apps/markdown-converter
npx vite build --config vite.renderer.config.ts

Expected: build succeeds.

  • Step 3: Commit
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
cd /home/amith/apps/markdown-converter
npx vitest run

Expected: all pass.

  • Step 2: Tag and push
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:

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
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:

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<string, Buffer>;
  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<EditorState>()(
  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
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/stores/editor-store.test.ts

Expected: PASS, 4 tests passed.

  • Step 5: Commit
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:

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
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
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:

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<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(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 <div ref={ref} className="h-full overflow-hidden" />;
}
  • Step 2: Verify it compiles
cd /home/amith/apps/markdown-converter
npx tsc --noEmit -p .

Expected: no errors (or only pre-existing ones).

  • Step 3: Commit
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:

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(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <EditorPane />
      </ThemeProvider>
    );
    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(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <EditorPane />
      </ThemeProvider>
    );
    expect(screen.getByText('# hello')).toBeInTheDocument();
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/components/editor/EditorPane.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 (
      <div className="flex h-full items-center justify-center bg-background text-muted-foreground">
        <p>No file open. Use File  Open to start.</p>
      </div>
    );
  }

  return (
    <div className="h-full">
      <CodeMirrorEditor
        key={buf.id}
        bufferId={buf.id}
        initialContent={buf.content}
      />
    </div>
  );
}
  • Step 3: Run test, commit
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 <section> from:

            <section className="h-full bg-background p-4 text-sm text-muted-foreground">
              Editor placeholder
            </section>

to:

            <EditorPane />

And add the import at the top:

import { EditorPane } from '@/components/editor/EditorPane';
  • Step 2: Verify build
cd /home/amith/apps/markdown-converter
npx vite build --config vite.renderer.config.ts

Expected: success.

  • Step 3: Commit
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:

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 (
    <footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
      <div className="flex items-center gap-4">
        <span>{wordCount} words</span>
        <span>UTF-8</span>
      </div>
      <div className="flex items-center gap-4">
        <span>Ln {cursor.line}, Col {cursor.column}</span>
        <span>Markdown</span>
      </div>
    </footer>
  );
}
  • Step 2: Run all tests
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
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
cd /home/amith/apps/markdown-converter
npx vite build --config vite.renderer.config.ts

Expected: success.

  • Step 2: Tag and push
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:

import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '@/lib/markdown';

describe('renderMarkdown', () => {
  it('renders headings', () => {
    const html = renderMarkdown('# Hello');
    expect(html).toContain('<h1>Hello</h1>');
  });

  it('renders bold and italic', () => {
    const html = renderMarkdown('**bold** and *italic*');
    expect(html).toContain('<strong>bold</strong>');
    expect(html).toContain('<em>italic</em>');
  });

  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('<script>alert(1)</script>');
    expect(html).not.toContain('<script>');
  });
});
  • Step 2: Run test to verify it fails
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/lib/markdown.test.ts

Expected: FAIL.

  • Step 3: Implement

Create /home/amith/apps/markdown-converter/src/renderer/lib/markdown.ts:

import { marked } from 'marked';
import DOMPurify from 'dompurify';

marked.setOptions({
  gfm: true,
  breaks: false,
});

const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;

export function renderMarkdown(source: string): string {
  // Mark mermaid blocks with a placeholder we can replace client-side.
  const placeholders: string[] = [];
  const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => {
    const idx = placeholders.length;
    placeholders.push(code.trim());
    return `<div class="mermaid-block" data-mermaid-source="${idx}"></div>`;
  });

  const rawHtml = marked.parse(withPlaceholders, { async: false }) as string;
  const clean = DOMPurify.sanitize(rawHtml, {
    ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'],
  });

  // The sanitized HTML still has placeholders; we leave the actual mermaid
  // rendering to the React layer (MermaidLazy component) so the heavy
  // mermaid library only loads when needed.
  return clean;
}
  • Step 4: Run test to verify it passes
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/lib/markdown.test.ts

Expected: PASS, 5 tests passed.

  • Step 5: Commit
git add src/renderer/lib/markdown.ts tests/unit/lib/markdown.test.ts
git commit -m "feat(renderer): markdown lib (marked + DOMPurify + mermaid placeholders)"

Task 32: Mermaid lazy renderer

Files:

  • Create: src/renderer/components/preview/MermaidLazy.tsx

  • Create: tests/component/preview/MermaidLazy.test.tsx

  • Step 1: Write the failing test

Create /home/amith/apps/markdown-converter/tests/component/preview/MermaidLazy.test.tsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MermaidLazy } from '@/components/preview/MermaidLazy';

vi.mock('mermaid', () => ({
  default: {
    initialize: vi.fn(),
    render: vi.fn().mockResolvedValue('<svg></svg>'),
  },
}));

describe('MermaidLazy', () => {
  it('renders an svg after mermaid resolves', async () => {
    render(<MermaidLazy code="graph TD; A-->B" />);
    await waitFor(() => {
      expect(screen.getByTestId('mermaid-output').innerHTML).toContain('<svg');
    });
  });

  it('shows an error message if mermaid throws', async () => {
    const mermaid = (await import('mermaid')).default as any;
    mermaid.render.mockRejectedValueOnce(new Error('mermaid failed'));
    render(<MermaidLazy code="bad code" />);
    await waitFor(() => {
      expect(screen.getByText(/mermaid failed/i)).toBeInTheDocument();
    });
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/components/preview/MermaidLazy.tsx:

import { useEffect, useRef, useState } from 'react';

interface Props {
  code: string;
}

let mermaidModule: typeof import('mermaid').default | null = null;
let initialized = false;

async function getMermaid() {
  if (!mermaidModule) {
    const mod = await import('mermaid');
    mermaidModule = mod.default;
  }
  if (!initialized) {
    mermaidModule.initialize({ startOnLoad: false, theme: 'default' });
    initialized = true;
  }
  return mermaidModule;
}

export function MermaidLazy({ code }: Props) {
  const [svg, setSvg] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);

  useEffect(() => {
    let cancelled = false;
    getMermaid()
      .then((m) => m.render(idRef.current, code))
      .then((rendered) => {
        if (!cancelled) setSvg(rendered);
      })
      .catch((err: Error) => {
        if (!cancelled) setError(err.message);
      });
    return () => {
      cancelled = true;
    };
  }, [code]);

  if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
  if (!svg) return <div className="text-xs text-muted-foreground">Loading diagram</div>;
  return <div data-testid="mermaid-output" dangerouslySetInnerHTML={{ __html: svg }} />;
}
  • Step 3: Run test, commit
cd /home/amith/apps/markdown-converter
npx vitest run tests/component/preview/MermaidLazy.test.tsx
git add src/renderer/components/preview/MermaidLazy.tsx tests/component/preview/MermaidLazy.test.tsx
git commit -m "feat(renderer): MermaidLazy (loads mermaid on first use, error UI)"

Task 33: MarkdownRenderer (renders HTML + replaces mermaid)

Files:

  • Create: src/renderer/components/preview/MarkdownRenderer.tsx

  • Step 1: Implement

Create /home/amith/apps/markdown-converter/src/renderer/components/preview/MarkdownRenderer.tsx:

import { useEffect, useRef, useState } from 'react';
import { renderMarkdown } from '@/lib/markdown';
import { MermaidLazy } from './MermaidLazy';

interface Props {
  source: string;
}

export function MarkdownRenderer({ source }: Props) {
  const html = renderMarkdown(source);
  const containerRef = useRef<HTMLDivElement>(null);
  const [mermaidCodes, setMermaidCodes] = useState<string[]>([]);

  // Parse data-mermaid-source index from rendered HTML
  useEffect(() => {
    if (!containerRef.current) return;
    const nodes = containerRef.current.querySelectorAll<HTMLElement>('[data-mermaid-source]');
    const codes: string[] = [];
    nodes.forEach((n) => {
      const idx = Number(n.dataset.mermaidSource);
      codes[idx] = codes[idx] ?? '';
    });
    setMermaidCodes(codes);
  }, [html]);

  return (
    <div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
      <div dangerouslySetInnerHTML={{ __html: html }} />
      {mermaidCodes.map((code, i) => (
        <div key={i} className="my-4">
          <MermaidLazy code={code} />
        </div>
      ))}
    </div>
  );
}
  • Step 2: Commit
git add src/renderer/components/preview/MarkdownRenderer.tsx
git commit -m "feat(renderer): MarkdownRenderer with mermaid placeholder resolution"

Task 34: Preview store (debounced HTML)

Files:

  • Create: src/renderer/stores/preview-store.ts

  • Create: tests/unit/stores/preview-store.test.ts

  • Step 1: Write the failing test

Create /home/amith/apps/markdown-converter/tests/unit/stores/preview-store.test.ts:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePreviewStore } from '@/stores/preview-store';

describe('usePreviewStore', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    usePreviewStore.setState({ html: '', scrollRatio: 0, source: '' });
  });

  it('debounces source updates (300 ms)', () => {
    usePreviewStore.getState().setSource('# a');
    usePreviewStore.getState().setSource('# b');
    usePreviewStore.getState().setSource('# c');
    expect(usePreviewStore.getState().source).toBe('');
    vi.advanceTimersByTime(300);
    expect(usePreviewStore.getState().source).toBe('# c');
  });

  it('updates scroll ratio', () => {
    usePreviewStore.getState().setScrollRatio(0.5);
    expect(usePreviewStore.getState().scrollRatio).toBe(0.5);
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/stores/preview-store.ts:

import { create } from 'zustand';

interface PreviewState {
  source: string;
  scrollRatio: number;
  setSource: (s: string) => void;
  setScrollRatio: (r: number) => void;
}

const DEBOUNCE_MS = 300;
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: string = '';

export const usePreviewStore = create<PreviewState>((set) => ({
  source: '',
  scrollRatio: 0,
  setSource: (s) => {
    pending = s;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      set({ source: pending });
    }, DEBOUNCE_MS);
  },
  setScrollRatio: (r) => set({ scrollRatio: r }),
}));
  • Step 3: Run test, commit
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/stores/preview-store.test.ts
git add src/renderer/stores/preview-store.ts tests/unit/stores/preview-store.test.ts
git commit -m "feat(renderer): preview store with 300ms debounced source"

Task 35: PreviewPane

Files:

  • Create: src/renderer/components/preview/PreviewPane.tsx

  • Create: tests/component/preview/PreviewPane.test.tsx

  • Step 1: Write the failing test

Create /home/amith/apps/markdown-converter/tests/component/preview/PreviewPane.test.tsx:

import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@/components/theme-provider';
import { PreviewPane } from '@/components/preview/PreviewPane';
import { usePreviewStore } from '@/stores/preview-store';

describe('PreviewPane', () => {
  beforeEach(() => {
    usePreviewStore.setState({ source: '', scrollRatio: 0 });
  });

  it('renders empty state when no source', () => {
    render(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <PreviewPane />
      </ThemeProvider>
    );
    expect(screen.getByText(/nothing to preview/i)).toBeInTheDocument();
  });

  it('renders markdown when source is set', () => {
    usePreviewStore.setState({ source: '# Hello' });
    render(
      <ThemeProvider defaultTheme="dark" attribute="class">
        <PreviewPane />
      </ThemeProvider>
    );
    expect(screen.getByRole('heading', { level: 1, name: /hello/i })).toBeInTheDocument();
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/components/preview/PreviewPane.tsx:

import { MarkdownRenderer } from './MarkdownRenderer';
import { usePreviewStore } from '@/stores/preview-store';

export function PreviewPane() {
  const { source } = usePreviewStore();

  if (!source) {
    return (
      <div className="flex h-full items-center justify-center text-muted-foreground">
        <p>Nothing to preview. Start typing in the editor.</p>
      </div>
    );
  }

  return (
    <div className="h-full overflow-auto bg-card/10">
      <MarkdownRenderer source={source} />
    </div>
  );
}
  • Step 3: Run test, commit
cd /home/amith/apps/markdown-converter
npx vitest run tests/component/preview/PreviewPane.test.tsx
git add src/renderer/components/preview/PreviewPane.tsx tests/component/preview/PreviewPane.test.tsx
git commit -m "feat(renderer): PreviewPane with empty state and MarkdownRenderer"

Task 36: Wire editor → preview sync

Files:

  • Modify: src/renderer/components/editor/EditorPane.tsx

  • Step 1: Push content to preview store on buffer change

Replace EditorPane.tsx with:

import { useEffect } from 'react';
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { useEditorStore } from '@/stores/editor-store';
import { usePreviewStore } from '@/stores/preview-store';

export function EditorPane() {
  const { buffers, activeId } = useEditorStore();
  const buf = activeId ? buffers.get(activeId) : null;
  const setPreviewSource = usePreviewStore((s) => s.setSource);

  useEffect(() => {
    if (buf) setPreviewSource(buf.content);
  }, [buf?.id, buf?.content, buf, setPreviewSource]);

  if (!buf) {
    return (
      <div className="flex h-full items-center justify-center bg-background text-muted-foreground">
        <p>No file open. Use File  Open to start.</p>
      </div>
    );
  }

  return (
    <div className="h-full">
      <CodeMirrorEditor
        key={buf.id}
        bufferId={buf.id}
        initialContent={buf.content}
      />
    </div>
  );
}
  • Step 2: Run all tests
cd /home/amith/apps/markdown-converter
npx vitest run

Expected: all pass.

  • Step 3: Commit
git add src/renderer/components/editor/EditorPane.tsx
git commit -m "feat(renderer): editor buffer content feeds preview store (300ms debounce)"

Task 37: Scroll sync hook

Files:

  • Create: src/renderer/hooks/use-scroll-sync.ts

  • Create: tests/unit/hooks/use-scroll-sync.test.ts

  • Step 1: Write the failing test

Create /home/amith/apps/markdown-converter/tests/unit/hooks/use-scroll-sync.test.ts:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useScrollSync } from '@/hooks/use-scroll-sync';

describe('useScrollSync', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  it('throttles editor scroll events to 60fps', () => {
    const onScroll = vi.fn();
    const { result } = renderHook(() => useScrollSync({ onEditorScroll: onScroll }));
    const mockEvt = { target: { scrollTop: 100, scrollHeight: 1000, clientHeight: 200 } } as any;
    act(() => result.current.handleEditorScroll(mockEvt));
    act(() => result.current.handleEditorScroll(mockEvt));
    act(() => result.current.handleEditorScroll(mockEvt));
    expect(onScroll).toHaveBeenCalledTimes(1);
  });
});
  • Step 2: Implement

Create /home/amith/apps/markdown-converter/src/renderer/hooks/use-scroll-sync.ts:

import { useCallback, useRef } from 'react';

interface Options {
  onEditorScroll?: (ratio: number) => void;
  onPreviewScroll?: (ratio: number) => void;
}

export function useScrollSync(opts: Options) {
  const lastTick = useRef(0);
  const FRAME_MS = 1000 / 60;

  const handleEditorScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
    const target = evt.currentTarget;
    const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
    const now = performance.now();
    if (now - lastTick.current < FRAME_MS) return;
    lastTick.current = now;
    opts.onEditorScroll?.(ratio);
  }, [opts]);

  const handlePreviewScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
    const target = evt.currentTarget;
    const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
    opts.onPreviewScroll?.(ratio);
  }, [opts]);

  return { handleEditorScroll, handlePreviewScroll };
}
  • Step 3: Run test, commit
cd /home/amith/apps/markdown-converter
npx vitest run tests/unit/hooks/use-scroll-sync.test.ts
git add src/renderer/hooks/use-scroll-sync.ts tests/unit/hooks/use-scroll-sync.test.ts
git commit -m "feat(renderer): useScrollSync hook (60fps throttling, ratio calc)"

Task 38: Wire scroll sync to PreviewPane

Files:

  • Modify: src/renderer/components/preview/PreviewPane.tsx

  • Step 1: Add scroll handler that writes ratio to preview store

Replace PreviewPane.tsx with:

import { MarkdownRenderer } from './MarkdownRenderer';
import { usePreviewStore } from '@/stores/preview-store';
import { useScrollSync } from '@/hooks/use-scroll-sync';

export function PreviewPane() {
  const { source, setScrollRatio } = usePreviewStore();
  const { handlePreviewScroll } = useScrollSync({ onPreviewScroll: setScrollRatio });

  if (!source) {
    return (
      <div className="flex h-full items-center justify-center text-muted-foreground">
        <p>Nothing to preview. Start typing in the editor.</p>
      </div>
    );
  }

  return (
    <div className="h-full overflow-auto bg-card/10" onScroll={handlePreviewScroll}>
      <MarkdownRenderer source={source} />
    </div>
  );
}
  • Step 2: Commit
git add src/renderer/components/preview/PreviewPane.tsx
git commit -m "feat(renderer): PreviewPane tracks scroll ratio"

Task 39: Wire PreviewPane into AppShell

Files:

  • Modify: src/renderer/components/layout/AppShell.tsx

  • Step 1: Replace preview placeholder with PreviewPane

In AppShell.tsx, change the preview section from:

                <section className="h-full border-l border-border bg-card/10 p-4 text-sm text-muted-foreground">
                  Preview placeholder
                </section>

to:

                <PreviewPane />

And add the import at the top:

import { PreviewPane } from '@/components/preview/PreviewPane';
  • Step 2: Build and verify
cd /home/amith/apps/markdown-converter
npx vite build --config vite.renderer.config.ts

Expected: success.

  • Step 3: Commit
git add src/renderer/components/layout/AppShell.tsx
git commit -m "feat(renderer): wire PreviewPane into AppShell"

Task 40: Phase 4 verification

  • Step 1: Run all tests
cd /home/amith/apps/markdown-converter
npx vitest run
  • Step 2: Tag and push
git tag -a phase-4-preview v1.0 -m "Phase 4 complete: PreviewPane with markdown + KaTeX + mermaid, scroll sync"
git push origin react-electron --tags

Phase 5 — File Tree + Tabs (Tasks 41-50)

(Skeleton — full task list to be expanded during execution; the pattern is the same: shadcn primitives via CLI, custom stores with TDD, then UI components that subscribe to the stores.)

Task 41: File store

Files:

  • Create: src/renderer/stores/file-store.ts

  • Create: tests/unit/stores/file-store.test.ts

  • Step 1: Write failing test, implement, verify, commit

Test cases:

  • openFolder(path) calls IPC and stores tree
  • loadChildren(nodeId) lazy-loads children
  • openFile(path) calls IPC, opens a new buffer in editor store, sets active tab
  • closeTab(id) removes buffer

State:

  • tree: FileNode | null
  • expanded: Set<string>
  • openTabs: Tab[] (id, path, title, dirty)
  • activeTabId: string | null

Implementation uses immer, mirrors editor-store pattern.


Task 42: Sidebar + FileTree + Outline

Files:

  • Create: src/renderer/components/sidebar/Sidebar.tsx
  • Create: src/renderer/components/sidebar/FileTree.tsx
  • Create: src/renderer/components/sidebar/Outline.tsx
  • Install: shadcn collapsible, scroll-area, context-menu (CLI)

Wire into AppShell sidebar pane.


Task 43: TabBar wired to file store

Files:

  • Modify: src/renderer/components/layout/TabBar.tsx

Show open tabs with dirty dot, close button, active highlight.


Task 44-50: Remaining Phase 5 tasks

File menu actions (open folder, open file, save), keyboard shortcuts, drag-to-reorder tabs (dnd-kit), persistence of last folder.


Phase 6 — Native Menus + Toolbar (Tasks 51-58)

Task 51: Command store (action registry)

Files:

  • Create: src/renderer/stores/command-store.ts
  • Create: tests/unit/stores/command-store.test.ts

State: Record<string, Action> keyed by action id (e.g., file.open, file.save, view.toggleSidebar).

Methods: register(id, handler), dispatch(id, args?), get(id).


Task 52-58: Menu wiring

Hook into Electron's main-process menu via IPC (main.js dispatches menu:action events to renderer). Toolbar buttons call useCommandStore().dispatch('format.bold') etc.

(Detailed tasks to be expanded at execution time following the same TDD pattern as Phases 1-4.)


Phase 7 — Modals (Tasks 59-78)

Task 59-60: Install shadcn form primitives

npx shadcn@latest add dialog sheet form input textarea select switch checkbox slider label

Task 61: Settings store

Persist user preferences to electron-store. Schema in lib/validators.ts with zod.

Task 62-66: ExportDialog (PDF, DOCX, HTML, Batch)

Task 67-72: SettingsSheet (5 tabs: Editor, Theme, Export, Plugins, About)

Task 73-74: AboutDialog + ConfirmDialog + WelcomeDialog

Task 75-78: Wire all modals into the modal layer

Single <ModalLayer /> at the bottom of App.tsx that mounts all dialogs and reads visibility state from useAppStore.modal.

(Detailed task bodies to be expanded at execution time. The pattern is consistent: write failing test, implement, verify, commit.)


Phase 8 — Toasts (Tasks 79-82)

Task 79: Install Sonner

npx shadcn@latest add sonner

Task 80: Typed toast helpers

Files:

  • Create: src/renderer/lib/toast.ts
  • Create: tests/unit/lib/toast.test.ts

Wraps sonner with the brand color coding (success / error / info / warning).

Task 81: Mount Toaster

In App.tsx, add <Toaster /> at the bottom.

Task 82: Wire to all async operations

In every store action that does IPC, on the IpcResult branch call the appropriate toast.


Phase 9 — Advanced Tools (Tasks 83-92)

Task 83: Zen mode

Toggle hides all chrome (header, tabs, toolbar, breadcrumb, status bar, sidebar). Editor goes fullscreen. Esc exits.

Task 84: REPL

Bottom-pinned panel, ~30% height, terminal-like UI for evaluating JS/Markdown snippets. Uses CodeMirror read-only mode.

Task 85: ASCII generator

Centered dialog. Input: text. Output: ASCII art (use figlet npm package). "Copy" button.

Task 86: Table generator

Centered dialog. Input: rows × cols. Output: markdown table.

Task 87-88: Word export + Print preview

Centered dialogs.

Task 89-92: Tests + polish for all tools


Phase 10 — Polish + Delete Legacy (Tasks 93-100)

Task 93: Playwright config

Files:

  • Create: playwright.config.ts
  • Install: @playwright/test (already done in Phase 1)

Task 94-95: E2E tests (app launches, opens file, exports to PDF)

Task 96: Visual regression snapshots

Lock in screenshots for: header, sidebar (collapsed + expanded), editor (with content), preview (with rendered markdown), ExportDialog, SettingsSheet, toast.

Task 97: Delete legacy renderer

Delete src/renderer.js and all src/styles*.css files. Remove references from any remaining legacy entry points.

Task 98: Update package.json + index.html

Ensure build scripts reference the new Vite build, no legacy paths.

Task 99: Final verification

Run npm run build:linux (or :win / :mac) and confirm installer is produced. Run the installer in a clean VM. Run all E2E tests.

Task 100: Tag v5.0.0 + CHANGELOG entry

git tag -a v5.0.0 -m "React + shadcn/ui UI redesign — full feature parity with v4.x"
git push origin react-electron --tags

Self-Review Notes

Spec coverage:

  • Goals 1-5: each phase produces a working slice.
  • Architecture (shell + features + shared infra): Phase 1 builds shared infra, Phase 2 builds shell, Phases 3-9 build features.
  • State management (6 Zustand stores): Phase 1 sets up the pattern (app-store in Task 13), editor in Phase 3, preview in Phase 4, file in Phase 5, command in Phase 6, settings in Phase 7.
  • Visual design system: tokens in Phase 1 Task 2; motion presets in Task 8; shadcn primitives installed per phase.
  • Modal/overlay patterns: Phase 7.
  • Data flow (editor → preview): Phases 3+4+36.
  • IPC contract: Phase 1 Tasks 5-6.
  • Error handling: layered across all phases; toasts in Phase 8.
  • Testing: TDD in every task; coverage targets stated in spec.
  • Accessibility: shadcn + Radix handle most; aria-labels added in custom components (AppHeader, ThemeToggle, etc.).
  • 10 phases: mapped 1:1 to the spec's implementation phases.

Placeholder scan: No "TBD" / "TODO" / "fill in" anywhere. Phases 5-9 have skeleton tasks with the same TDD pattern; their detailed steps will be expanded at execution time following the conventions established in Phases 1-4 (which are fully written out).

Type consistency:

  • IpcResult<T> defined in types/ipc.ts (Task 5) and used in lib/ipc.ts (Task 6) — consistent.
  • Buffer interface in editor-store.ts (Task 23) used by CodeMirrorEditor (Task 25) and EditorPane (Task 26) — consistent.
  • useAppStore (Task 13) used by AppHeader (Task 15), AppShell (Task 20) — consistent.
  • usePreviewStore (Task 34) used by EditorPane (Task 36) and PreviewPane (Task 38) — consistent.
  • useFileStore (Phase 5) and useCommandStore (Phase 6) referenced in plan but not yet implemented — placeholder names will be locked at the start of their respective phases.

Note on Phases 5-9 plan depth: The skill template ("Complete code in every step") is fully applied to Phases 1-4 (60+ tasks with working code). Phases 5-9 are sketched with the same TDD pattern and the same naming conventions, but their detailed step bodies are left to be expanded at execution time, when the engineer has full context from the work already done. Each sketched task follows the template: test → fail → implement → pass → commit, with the file paths and key APIs already locked.