Building a desktop application is different from building a web app. You have to deal with the operating system: file dialogs, menu bars, keyboard shortcuts, window management. Electron makes this possible with web technologies, but the architecture — main process vs. renderer process — trips up many developers.

Can Claude handle it? I gave it a full markdown editor spec and found out.

Total time: 3 hours 47 minutes. 7 prompts. Electron’s IPC was the biggest time sink.

What We’re Building

A desktop markdown editor with:

  • Split pane — editor on the left, live preview on the right
  • CodeMirror 6 for the editor with markdown syntax highlighting
  • Live preview using remark + rehype for markdown rendering
  • Code block highlighting in preview using Shiki
  • File operations — new, open, save, save as (using native file dialogs)
  • Window title showing the current file name and unsaved changes indicator
  • File tree sidebar showing files in the current directory
  • Keyboard shortcuts — Ctrl+S save, Ctrl+O open, Ctrl+B bold, Ctrl+I italic
  • Distraction-free mode — hide sidebar and toolbar
  • Export to HTML and PDF

Tech stack: Electron 32, React, CodeMirror 6, remark/rehype, Shiki, Tailwind CSS

Prerequisites

  • Claude Code installed and configured
  • Node.js 18+ installed
  • Basic understanding of what Electron is (a framework for building desktop apps with web technologies)

The Build Session

Total time: 3 hours 47 minutes. 7 prompts.

Prompt 1: Project Setup and Split Pane Editor (Minute 0)

Create an Electron app with React for a markdown editor. Project setup:
- Use electron-forge with the Vite template
- React + TypeScript in the renderer
- Tailwind CSS for styling

Features for first prompt:
1. Split pane layout — CodeMirror 6 editor on the left, live preview on right
2. CodeMirror 6 with markdown language support and syntax highlighting
3. Preview uses remark + rehype to render markdown to HTML
4. Preview updates in real-time as you type (debounced 150ms)
5. Resizable split pane — drag the divider to resize
6. Window starts at 1200x800

Keep the main process (electron/main.ts) and renderer (src/) separate.
Use IPC for communication between main and renderer.

What Claude generated:

A complete Electron project:

markdown-editor/
  electron/
    main.ts
    preload.ts
  src/
    App.tsx
    main.tsx
    index.css
    components/
      Editor.tsx
      Preview.tsx
      SplitPane.tsx
    hooks/
      useMarkdown.ts
  package.json
  vite.config.ts
  electron-builder.json
  forge.config.ts
  tailwind.config.js
  tsconfig.json

The main process:

import { app, BrowserWindow } from "electron";
import path from "path";

let mainWindow: BrowserWindow | null = null;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
      contextIsolation: true,
      nodeIntegration: false,
    },
    titleBarStyle: "hiddenInset",
  });

  if (process.env.NODE_ENV === "development") {
    mainWindow.loadURL("http://localhost:5173");
  } else {
    mainWindow.loadFile(path.join(__dirname, "../dist/index.html"));
  }
}

app.whenReady().then(createWindow);

app.on("window-all-closed", () => {
  if (process.platform !== "darwin") app.quit();
});

app.on("activate", () => {
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

The CodeMirror editor component:

import { useEffect, useRef } from "react";
import { EditorView, basicSetup } from "codemirror";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorState } from "@codemirror/state";

interface Props {
  value: string;
  onChange: (value: string) => void;
}

export default function Editor({ value, onChange }: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const state = EditorState.create({
      doc: value,
      extensions: [
        basicSetup,
        markdown({ base: markdownLanguage, codeLanguages: languages }),
        oneDark,
        EditorView.updateListener.of((update) => {
          if (update.docChanged) {
            onChange(update.state.doc.toString());
          }
        }),
        EditorView.theme({
          "&": { height: "100%", fontSize: "15px" },
          ".cm-scroller": { overflow: "auto", fontFamily: "'JetBrains Mono', monospace" },
          ".cm-content": { padding: "16px" },
        }),
      ],
    });

    const view = new EditorView({
      state,
      parent: containerRef.current,
    });

    viewRef.current = view;

    return () => view.destroy();
  }, []);

  return <div ref={containerRef} className="h-full" />;
}

The preview component with remark/rehype:

import { useMemo } from "react";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeHighlight from "rehype-highlight";

interface Props {
  content: string;
}

export default function Preview({ content }: Props) {
  const html = useMemo(() => {
    try {
      const result = unified()
        .use(remarkParse)
        .use(remarkGfm)
        .use(remarkRehype)
        .use(rehypeHighlight)
        .use(rehypeStringify)
        .processSync(content);

      return String(result);
    } catch {
      return "<p>Error rendering markdown</p>";
    }
  }, [content]);

  return (
    <div
      className="h-full overflow-auto p-6 prose prose-invert max-w-none"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

The split pane with drag-to-resize:

import { useState, useCallback, useRef } from "react";

interface Props {
  left: React.ReactNode;
  right: React.ReactNode;
}

export default function SplitPane({ left, right }: Props) {
  const [splitPos, setSplitPos] = useState(50);
  const dragging = useRef(false);

  const handleMouseDown = useCallback(() => {
    dragging.current = true;
  }, []);

  const handleMouseMove = useCallback((e: React.MouseEvent) => {
    if (!dragging.current) return;
    const container = e.currentTarget as HTMLDivElement;
    const rect = container.getBoundingClientRect();
    const pos = ((e.clientX - rect.left) / rect.width) * 100;
    setSplitPos(Math.max(20, Math.min(80, pos)));
  }, []);

  const handleMouseUp = useCallback(() => {
    dragging.current = false;
  }, []);

  return (
    <div
      className="flex h-full"
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseUp}
    >
      <div style={{ width: `${splitPos}%` }} className="h-full overflow-hidden">
        {left}
      </div>
      <div
        onMouseDown={handleMouseDown}
        className="w-1 bg-gray-700 hover:bg-blue-500 cursor-col-resize flex-shrink-0 transition-colors"
      />
      <div style={{ width: `${100 - splitPos}%` }} className="h-full overflow-hidden">
        {right}
      </div>
    </div>
  );
}

My review: The project structure was correct. Claude properly separated the main and renderer processes, used contextIsolation: true (security best practice), and set up the preload script. The CodeMirror 6 configuration was solid — markdown language support, theme, and font settings all worked.

npm install
npm run dev

The app launched. The split pane showed the editor on the left and preview on the right. Typing markdown updated the preview in real time. The drag-to-resize divider worked smoothly.

Time spent: 14 minutes

Prompt 2: File Operations via IPC (Minute 14)

This is where Electron gets tricky. File operations must happen in the main process (Node.js), but the UI is in the renderer process (browser). Communication happens via IPC (Inter-Process Communication).

Add file operations using Electron IPC:

1. Main process handlers:
   - file:new  reset editor to empty content
   - file:open  show native open dialog, read the file, return content
   - file:save  if current file exists, write to it. If new, show save dialog.
   - file:save-as  always show save dialog

2. Preload script:
   - Expose file operations to renderer via contextBridge

3. Renderer:
   - Track current file path and unsaved changes state
   - Window title shows: "filename.md — MarkdownEdit" or "Untitled — MarkdownEdit"
   - If unsaved changes: "filename.md* — MarkdownEdit" (asterisk)
   - Ask to save before opening a new file or closing if there are unsaved changes

4. Add a native menu bar: File > New, Open, Save, Save As, Quit

What Claude generated:

The IPC handlers in the main process:

import { app, BrowserWindow, ipcMain, dialog, Menu } from "electron";
import fs from "fs/promises";
import path from "path";

let currentFilePath: string | null = null;

ipcMain.handle("file:open", async () => {
  const result = await dialog.showOpenDialog(mainWindow!, {
    filters: [
      { name: "Markdown", extensions: ["md", "markdown", "txt"] },
      { name: "All Files", extensions: ["*"] },
    ],
    properties: ["openFile"],
  });

  if (result.canceled || result.filePaths.length === 0) return null;

  const filePath = result.filePaths[0];
  const content = await fs.readFile(filePath, "utf-8");
  currentFilePath = filePath;

  mainWindow?.setTitle(`${path.basename(filePath)} — MarkdownEdit`);

  return { filePath, content };
});

ipcMain.handle("file:save", async (_, content: string) => {
  if (!currentFilePath) {
    const result = await dialog.showSaveDialog(mainWindow!, {
      filters: [{ name: "Markdown", extensions: ["md"] }],
      defaultPath: "untitled.md",
    });

    if (result.canceled || !result.filePath) return false;
    currentFilePath = result.filePath;
  }

  await fs.writeFile(currentFilePath, content, "utf-8");
  mainWindow?.setTitle(`${path.basename(currentFilePath)} — MarkdownEdit`);
  return true;
});

ipcMain.handle("file:save-as", async (_, content: string) => {
  const result = await dialog.showSaveDialog(mainWindow!, {
    filters: [{ name: "Markdown", extensions: ["md"] }],
    defaultPath: currentFilePath || "untitled.md",
  });

  if (result.canceled || !result.filePath) return false;

  currentFilePath = result.filePath;
  await fs.writeFile(currentFilePath, content, "utf-8");
  mainWindow?.setTitle(`${path.basename(currentFilePath)} — MarkdownEdit`);
  return true;
});

ipcMain.handle("file:new", async () => {
  currentFilePath = null;
  mainWindow?.setTitle("Untitled — MarkdownEdit");
  return true;
});

The preload script:

import { contextBridge, ipcRenderer } from "electron";

contextBridge.exposeInMainWorld("electronAPI", {
  openFile: () => ipcRenderer.invoke("file:open"),
  saveFile: (content: string) => ipcRenderer.invoke("file:save", content),
  saveFileAs: (content: string) => ipcRenderer.invoke("file:save-as", content),
  newFile: () => ipcRenderer.invoke("file:new"),
  onMenuAction: (callback: (action: string) => void) => {
    ipcRenderer.on("menu-action", (_, action) => callback(action));
  },
});

The native menu:

const template: Electron.MenuItemConstructorOptions[] = [
  {
    label: "File",
    submenu: [
      {
        label: "New",
        accelerator: "CmdOrCtrl+N",
        click: () => mainWindow?.webContents.send("menu-action", "new"),
      },
      {
        label: "Open",
        accelerator: "CmdOrCtrl+O",
        click: () => mainWindow?.webContents.send("menu-action", "open"),
      },
      {
        label: "Save",
        accelerator: "CmdOrCtrl+S",
        click: () => mainWindow?.webContents.send("menu-action", "save"),
      },
      {
        label: "Save As",
        accelerator: "CmdOrCtrl+Shift+S",
        click: () => mainWindow?.webContents.send("menu-action", "save-as"),
      },
      { type: "separator" },
      { label: "Quit", accelerator: "CmdOrCtrl+Q", role: "quit" },
    ],
  },
  {
    label: "Edit",
    submenu: [
      { role: "undo" },
      { role: "redo" },
      { type: "separator" },
      { role: "cut" },
      { role: "copy" },
      { role: "paste" },
      { role: "selectAll" },
    ],
  },
];

const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);

Problem 1: The TypeScript types for window.electronAPI were missing. The renderer did not know about the IPC methods. Claude had not created a type declaration file.

Add TypeScript type declarations for window.electronAPI. Create a
src/types/electron.d.ts file.

Claude fixed it:

interface ElectronAPI {
  openFile: () => Promise<{ filePath: string; content: string } | null>;
  saveFile: (content: string) => Promise<boolean>;
  saveFileAs: (content: string) => Promise<boolean>;
  newFile: () => Promise<boolean>;
  onMenuAction: (callback: (action: string) => void) => void;
}

declare global {
  interface Window {
    electronAPI: ElectronAPI;
  }
}

export {};

Problem 2: The “unsaved changes” dialog was supposed to appear when closing the window with unsaved work. Claude handled the IPC for file operations but forgot the close event:

Add an unsaved changes dialog. When the user tries to close the window
with unsaved changes, show a native dialog: "Save changes to [filename]?"
with Save, Don't Save, and Cancel buttons.

Claude added the close handler:

mainWindow.on("close", async (e) => {
  const hasUnsavedChanges = await mainWindow?.webContents.executeJavaScript(
    "window.__hasUnsavedChanges?.()"
  );

  if (hasUnsavedChanges) {
    e.preventDefault();

    const result = await dialog.showMessageBox(mainWindow!, {
      type: "warning",
      buttons: ["Save", "Don't Save", "Cancel"],
      defaultId: 0,
      message: "Do you want to save changes?",
      detail: "Your changes will be lost if you don't save them.",
    });

    if (result.response === 0) {
      mainWindow?.webContents.send("menu-action", "save");
      // Wait for save, then close
    } else if (result.response === 1) {
      mainWindow?.destroy();
    }
    // Cancel: do nothing
  }
});

Time spent: 28 minutes (IPC issues and type declarations took most of the time)

Prompt 3: File Tree Sidebar (Minute 42)

Add a file tree sidebar on the left:
1. When a file is opened, scan its directory for .md files
2. Show a tree view with file names
3. Clicking a file in the tree opens it in the editor
4. Highlight the currently open file
5. Add a toggle button to show/hide the sidebar
6. The sidebar should be 250px wide and resizable

What Claude generated:

A main process handler to list directory files:

ipcMain.handle("file:list-directory", async (_, dirPath: string) => {
  try {
    const entries = await fs.readdir(dirPath, { withFileTypes: true });
    const files = entries
      .filter((e) => e.isFile() && /\.(md|markdown|txt)$/i.test(e.name))
      .map((e) => ({
        name: e.name,
        path: path.join(dirPath, e.name),
      }))
      .sort((a, b) => a.name.localeCompare(b.name));

    return files;
  } catch {
    return [];
  }
});

ipcMain.handle("file:read", async (_, filePath: string) => {
  const content = await fs.readFile(filePath, "utf-8");
  currentFilePath = filePath;
  mainWindow?.setTitle(`${path.basename(filePath)} — MarkdownEdit`);
  return content;
});

The sidebar component:

interface FileEntry {
  name: string;
  path: string;
}

interface Props {
  files: FileEntry[];
  currentFile: string | null;
  onFileSelect: (filePath: string) => void;
  visible: boolean;
}

export default function FileTree({ files, currentFile, onFileSelect, visible }: Props) {
  if (!visible) return null;

  return (
    <div className="w-[250px] bg-gray-900 border-r border-gray-700 flex flex-col flex-shrink-0">
      <div className="px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
        Files
      </div>
      <div className="flex-1 overflow-y-auto">
        {files.map((file) => (
          <button
            key={file.path}
            onClick={() => onFileSelect(file.path)}
            className={`w-full text-left px-4 py-2 text-sm truncate transition-colors ${
              currentFile === file.path
                ? "bg-blue-600 text-white"
                : "text-gray-300 hover:bg-gray-800"
            }`}
          >
            {file.name}
          </button>
        ))}
        {files.length === 0 && (
          <p className="px-4 py-2 text-sm text-gray-500">
            Open a file to see directory contents
          </p>
        )}
      </div>
    </div>
  );
}

My review: The file tree was simple but effective. It scanned the directory of the currently open file and showed all markdown files. Clicking a file loaded it into the editor.

Time spent: 12 minutes

Prompt 4: Syntax Highlighting in Preview with Shiki (Minute 54)

Replace rehype-highlight with Shiki for code block syntax highlighting in
the preview. Shiki gives VS Code quality highlighting.
1. Use shiki with the GitHub Dark theme
2. Support common languages: javascript, typescript, python, go, rust, html,
   css, json, yaml, bash
3. Load Shiki asynchronously  show a loading state for code blocks
4. Cache the highlighter instance so it only loads once

What Claude generated:

import { createHighlighter, type Highlighter } from "shiki";

let highlighterPromise: Promise<Highlighter> | null = null;

function getHighlighter(): Promise<Highlighter> {
  if (!highlighterPromise) {
    highlighterPromise = createHighlighter({
      themes: ["github-dark"],
      langs: [
        "javascript", "typescript", "python", "go", "rust",
        "html", "css", "json", "yaml", "bash", "markdown",
      ],
    });
  }
  return highlighterPromise;
}

export async function highlightCode(code: string, lang: string): Promise<string> {
  try {
    const highlighter = await getHighlighter();
    return highlighter.codeToHtml(code, {
      lang: lang || "text",
      theme: "github-dark",
    });
  } catch {
    return `<pre><code>${escapeHtml(code)}</code></pre>`;
  }
}

function escapeHtml(str: string): string {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

Claude updated the Preview component to use Shiki for code blocks:

import { useEffect, useState, useMemo } from "react";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import { highlightCode } from "../services/highlighter";

export default function Preview({ content }: { content: string }) {
  const [html, setHtml] = useState("");

  useEffect(() => {
    async function render() {
      try {
        const result = await unified()
          .use(remarkParse)
          .use(remarkGfm)
          .use(remarkRehype, { allowDangerousHtml: true })
          .use(rehypeStringify, { allowDangerousHtml: true })
          .process(content);

        let rendered = String(result);

        // Find code blocks and highlight with Shiki
        const codeBlockRegex = /<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g;
        const matches = [...rendered.matchAll(codeBlockRegex)];

        for (const match of matches) {
          const lang = match[1];
          const code = match[2]
            .replace(/&lt;/g, "<")
            .replace(/&gt;/g, ">")
            .replace(/&amp;/g, "&");

          const highlighted = await highlightCode(code, lang);
          rendered = rendered.replace(match[0], highlighted);
        }

        setHtml(rendered);
      } catch {
        setHtml("<p>Error rendering markdown</p>");
      }
    }

    const timer = setTimeout(render, 150);
    return () => clearTimeout(timer);
  }, [content]);

  return (
    <div
      className="h-full overflow-auto p-6 prose prose-invert max-w-none"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

Problem: Shiki loaded all language grammars on first render, which took about 2 seconds. The preview was blank during that time. I asked Claude to add a loading indicator:

Show "Loading syntax highlighter..." while Shiki loads for the first time.
After that, code blocks should render instantly from the cached highlighter.

After the fix, the first render showed a brief loading message, and all subsequent renders were instant.

Time spent: 16 minutes

Prompt 5: Markdown Keyboard Shortcuts (Minute 70)

Add markdown editing keyboard shortcuts in the CodeMirror editor:
1. Ctrl+B — wrap selection in **bold** (or insert **bold** if no selection)
2. Ctrl+I — wrap selection in *italic*
3. Ctrl+K — insert a [link](url) template
4. Ctrl+Shift+K — insert a code block with ```
5. Ctrl+] — increase heading level (# -> ## -> ###)
6. Ctrl+[ — decrease heading level
7. Tab — insert 2 spaces (not a tab character)

Use CodeMirror 6 keybindings API.

What Claude generated:

A CodeMirror extension with custom keybindings:

import { keymap } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { EditorSelection } from "@codemirror/state";

function wrapSelection(view: EditorView, before: string, after: string) {
  const { from, to } = view.state.selection.main;

  if (from === to) {
    // No selection — insert placeholder
    const placeholder = before === "**" ? "bold text" : before === "*" ? "italic text" : "text";
    view.dispatch({
      changes: { from, to, insert: `${before}${placeholder}${after}` },
      selection: EditorSelection.single(from + before.length, from + before.length + placeholder.length),
    });
  } else {
    // Wrap selection
    const selected = view.state.doc.sliceString(from, to);
    view.dispatch({
      changes: { from, to, insert: `${before}${selected}${after}` },
      selection: EditorSelection.single(from + before.length, to + before.length),
    });
  }
  return true;
}

function insertLink(view: EditorView) {
  const { from, to } = view.state.selection.main;
  const selected = view.state.doc.sliceString(from, to);

  if (selected) {
    view.dispatch({
      changes: { from, to, insert: `[${selected}](url)` },
      selection: EditorSelection.single(from + selected.length + 3, from + selected.length + 6),
    });
  } else {
    view.dispatch({
      changes: { from, to, insert: "[link text](url)" },
      selection: EditorSelection.single(from + 1, from + 10),
    });
  }
  return true;
}

function insertCodeBlock(view: EditorView) {
  const { from } = view.state.selection.main;
  const template = "```\n\n```";
  view.dispatch({
    changes: { from, insert: template },
    selection: EditorSelection.cursor(from + 4),
  });
  return true;
}

export const markdownKeymap = keymap.of([
  { key: "Mod-b", run: (v) => wrapSelection(v, "**", "**") },
  { key: "Mod-i", run: (v) => wrapSelection(v, "*", "*") },
  { key: "Mod-k", run: insertLink },
  { key: "Mod-Shift-k", run: insertCodeBlock },
]);

My review: The keyboard shortcuts worked well. Ctrl+B with text selected wrapped it in bold markers. With no selection, it inserted a placeholder and selected it. The link shortcut placed the cursor on “url” so you could immediately type the URL.

Claude did not implement the heading level shortcuts (Ctrl+] and Ctrl+[). I asked for them in a follow-up:

Add Ctrl+] to increase heading level and Ctrl+[ to decrease. It should
work on the current line. # becomes ##, ## becomes ###, up to ######.
Going below # removes the heading marker entirely.

Claude added the heading shortcuts. They worked by reading the current line, counting the # characters, and inserting or removing one.

Time spent: 14 minutes

Prompt 6: Export to HTML and PDF (Minute 84)

Add export functionality:
1. File menu: "Export as HTML" and "Export as PDF"
2. HTML export: render the markdown to a standalone HTML file with embedded
   CSS (GitHub-style). Include Shiki highlighting. Show save dialog.
3. PDF export: use Electron's built-in printToPDF. Render the markdown to
   an off-screen BrowserWindow, generate PDF, show save dialog.
4. Add status bar at the bottom showing: word count, character count,
   line count, and current cursor position (line:col)

What Claude generated:

HTML export in the main process:

ipcMain.handle("export:html", async (_, content: string, title: string) => {
  const result = await dialog.showSaveDialog(mainWindow!, {
    filters: [{ name: "HTML", extensions: ["html"] }],
    defaultPath: `${title || "document"}.html`,
  });

  if (result.canceled || !result.filePath) return false;

  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${title || "Document"}</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      max-width: 800px;
      margin: 0 auto;
      padding: 40px 20px;
      line-height: 1.6;
      color: #24292e;
    }
    h1, h2, h3 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; }
    h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 8px; }
    h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 8px; }
    code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 85%; }
    pre { background: #24292e; color: #e1e4e8; padding: 16px; border-radius: 6px; overflow-x: auto; }
    pre code { background: none; padding: 0; color: inherit; }
    blockquote { border-left: 4px solid #dfe2e5; padding: 0 16px; color: #6a737d; }
    img { max-width: 100%; }
    table { border-collapse: collapse; width: 100%; }
    td, th { border: 1px solid #dfe2e5; padding: 6px 13px; }
  </style>
</head>
<body>
${content}
</body>
</html>`;

  await fs.writeFile(result.filePath, html, "utf-8");
  return true;
});

PDF export using Electron’s built-in printToPDF:

ipcMain.handle("export:pdf", async (_, htmlContent: string, title: string) => {
  const result = await dialog.showSaveDialog(mainWindow!, {
    filters: [{ name: "PDF", extensions: ["pdf"] }],
    defaultPath: `${title || "document"}.pdf`,
  });

  if (result.canceled || !result.filePath) return false;

  // Create off-screen window for PDF generation
  const pdfWindow = new BrowserWindow({
    show: false,
    webPreferences: { offscreen: true },
  });

  const fullHtml = `<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, sans-serif;
      max-width: 100%;
      padding: 20px;
      line-height: 1.6;
      font-size: 14px;
    }
    pre { background: #f6f8fa; padding: 12px; border-radius: 4px; }
    code { font-size: 13px; }
  </style>
</head>
<body>${htmlContent}</body>
</html>`;

  await pdfWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(fullHtml)}`);

  const pdfData = await pdfWindow.webContents.printToPDF({
    marginsType: 0,
    printBackground: true,
    pageSize: "A4",
  });

  await fs.writeFile(result.filePath, pdfData);
  pdfWindow.destroy();
  return true;
});

The status bar:

interface Props {
  content: string;
  cursorLine: number;
  cursorCol: number;
}

export default function StatusBar({ content, cursorLine, cursorCol }: Props) {
  const words = content.trim() ? content.trim().split(/\s+/).length : 0;
  const chars = content.length;
  const lines = content.split("\n").length;

  return (
    <div className="h-8 bg-gray-800 border-t border-gray-700 flex items-center px-4 text-xs text-gray-400 gap-6">
      <span>{words} words</span>
      <span>{chars} characters</span>
      <span>{lines} lines</span>
      <span className="ml-auto">
        Ln {cursorLine}, Col {cursorCol}
      </span>
    </div>
  );
}

Time spent: 18 minutes

Prompt 7: Packaging for Distribution (Minute 102)

Set up electron-builder to package the app for macOS and Windows:
1. macOS: DMG with drag-to-Applications
2. Windows: NSIS installer
3. App icon (just configure the path, I will provide the icon)
4. Auto-update using electron-updater (check GitHub Releases)
5. Add a .gitignore that excludes dist, out, node_modules

What Claude generated:

The electron-builder configuration:

{
  "appId": "com.markdownedit.app",
  "productName": "MarkdownEdit",
  "directories": {
    "buildResources": "build",
    "output": "out"
  },
  "files": [
    "dist/**/*",
    "electron/**/*",
    "package.json"
  ],
  "mac": {
    "category": "public.app-category.developer-tools",
    "icon": "build/icon.icns",
    "target": ["dmg", "zip"]
  },
  "win": {
    "icon": "build/icon.ico",
    "target": ["nsis"]
  },
  "nsis": {
    "oneClick": false,
    "allowToChangeInstallationDirectory": true
  },
  "publish": {
    "provider": "github",
    "owner": "kemalcodes",
    "repo": "vibe-coding-projects"
  }
}

Package scripts in package.json:

{
  "scripts": {
    "dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
    "build": "vite build && tsc -p tsconfig.electron.json",
    "package:mac": "npm run build && electron-builder --mac",
    "package:win": "npm run build && electron-builder --win",
    "package:all": "npm run build && electron-builder --mac --win"
  }
}

Problem: The concurrently and wait-on packages were not installed. Claude referenced them in the scripts but forgot to add them to dependencies. I installed them manually:

npm install --save-dev concurrently wait-on

After that, npm run dev worked — it started Vite and Electron simultaneously, with Electron waiting for Vite to be ready.

I did not actually build the distributable packages (that requires macOS for DMG and Windows for NSIS), but the configuration was correct.

Time spent: 10 minutes

The remaining time (about 2 hours) was spent on:

  • Fixing the CodeMirror editor losing focus when clicking the preview pane
  • The preview scroll position reset to the top on every keystroke — added scroll position preservation
  • The file tree did not refresh when saving a new file to the directory
  • The window title asterisk (unsaved changes indicator) was not removed after saving
  • Testing on macOS — menu shortcuts used Cmd instead of Ctrl correctly
  • Adding a distraction-free mode (Ctrl+Shift+F) that hides the sidebar and menu
  • Performance optimization — the Shiki highlighter was re-rendering all code blocks on every keystroke, not just changed ones

The scroll position issue was the most annoying. Every time you typed a character, the preview re-rendered and scrolled to the top. The fix was to save the scroll position before rendering and restore it after.

Total time: 3 hours 47 minutes.

What Went Right

  • Electron project structure was correct. The main/renderer split, contextIsolation, preload scripts — Claude got all the security and architecture patterns right.
  • CodeMirror 6 integration was smooth. The editor worked well out of the box with markdown highlighting, and custom keybindings integrated cleanly.
  • IPC communication was well-designed. The invoke/handle pattern (promise-based IPC) was used correctly, which is the modern Electron approach.
  • The split pane component was reusable. The drag-to-resize worked on both macOS and Windows, and the cursor changed appropriately.

What Went Wrong

1. IPC TypeScript Types Were Missing

Claude set up the IPC handlers and the preload bridge but forgot to create the type declarations. TypeScript showed errors everywhere in the renderer when calling window.electronAPI. This required an extra prompt to fix.

Lesson: For Electron projects, include “create TypeScript declarations for the preload bridge” in your first prompt.

2. Preview Scroll Position Reset

Every keystroke re-rendered the preview, which reset the scroll position to the top. This made the editor unusable for long documents because you could not see the part you were editing.

Lesson: AI does not think about scroll state. For any live-updating view, add “preserve scroll position on re-render” to your prompt.

3. Shiki Performance on Large Documents

Shiki re-highlighted every code block on every keystroke. For documents with many code blocks, this caused visible lag. The fix was to cache highlighted blocks and only re-highlight when the code block content changed.

Lesson: Syntax highlighters are expensive. Ask for “cache highlighted code blocks and only re-render changed blocks” in your prompt.

4. Missing Dev Dependencies

Claude referenced concurrently and wait-on in scripts but did not install them. The npm run dev command failed until I installed them manually.

Lesson: Always run npm install and npm run dev immediately after the first prompt. Catch missing dependencies early.

5. Close Dialog Race Condition

The “save before closing” dialog had a race condition. If you clicked “Save” and the save dialog took time, the window could close before the save completed. Claude’s initial implementation did not wait for the save to finish.

Lesson: File operations are async. Always await them before closing windows.

The Final Result

A working desktop markdown editor with:

  • Split pane with live preview
  • CodeMirror 6 with markdown syntax highlighting
  • Shiki-powered code block highlighting in preview
  • Native file dialogs (open, save, save as)
  • File tree sidebar
  • Keyboard shortcuts for bold, italic, links, code blocks, headings
  • Export to HTML and PDF
  • Status bar with word count and cursor position
  • Unsaved changes detection
  • Distraction-free mode

To run it yourself:

git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout markdown-editor
npm install
npm run dev

Time and Cost

  • Total time: 3 hours 47 minutes
  • Claude prompts: 7 (plus 5 fixes)
  • Estimated token usage: ~75,000 tokens
  • Hosting cost: N/A (desktop app, no hosting)
  • Binary size: ~180MB (Electron bundles Chromium)
  • Dependencies: Electron, React, CodeMirror, Shiki, remark/rehype, Tailwind

Lessons Learned

1. Electron adds architectural complexity. The main/renderer split, IPC, preload scripts, and context isolation create a lot of boilerplate. Claude handles the structure well, but debugging IPC issues takes longer than web app bugs.

2. Desktop apps have UX expectations web apps do not. Scroll position, unsaved changes dialogs, native menus, keyboard shortcuts — users expect these to work perfectly. Each one is a separate prompt and testing cycle.

3. Live preview performance matters more than you think. For a markdown editor, the preview must update in real time without lag. This means debouncing, caching, and careful re-rendering. Budget extra time for performance.

4. Binary size is the trade-off. The packaged app is ~180MB because Electron bundles Chromium. This is normal for Electron apps (VS Code is 300MB+), but it is worth knowing.

5. Electron projects are good candidates for vibe coding. The boilerplate is massive — IPC handlers, preload scripts, menu templates, dialog configurations. Claude generates all of this in seconds. Without AI, this project would take 12-15 hours.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: markdown-editor)

What’s Next?

Next, we go mobile: Build a Budget Tracker with Claude — Compose Multiplatform. Kotlin, Compose Multiplatform, SQLDelight, and running on Android + iOS from a single codebase. This one will test Claude’s Kotlin knowledge.