From abadca7a7e04e4bc9539b2e54de6c2293a6d888d Mon Sep 17 00:00:00 2001 From: Paris Theofanidis Date: Tue, 19 May 2026 19:53:06 +0300 Subject: [PATCH 01/22] docs: add design spec for live mermaid preview while editing Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-mermaid-live-preview-design.md | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-mermaid-live-preview-design.md diff --git a/docs/superpowers/specs/2026-05-19-mermaid-live-preview-design.md b/docs/superpowers/specs/2026-05-19-mermaid-live-preview-design.md new file mode 100644 index 0000000..b77fed6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-mermaid-live-preview-design.md @@ -0,0 +1,340 @@ +# Live Mermaid Preview While Editing — Design + +**Date:** 2026-05-19 +**Status:** Approved + +## Problem + +Editing a mermaid diagram in edit mode currently means clicking the slice and +seeing the slim raw-markdown textarea — the rendered diagram disappears. The +user types blind, commits, and only then sees whether the change worked. For +visual diagrams this is the wrong feedback loop. + +## Goal + +While the user is editing a mermaid block, show the rendered diagram **above** +the source so changes are reflected in near-realtime as they type. Keep the +last successful render visible whenever the source is mid-edit and invalid; a +small inline error chip surfaces the syntax problem without removing the +diagram. + +Design the feature so it generalises: any plugin that can render a fenced +code block to visual content can opt into the same live-preview surface. + +## Approach + +Introduce a generic plugin capability — `PreviewablePlugin` — and a single +new component, `PreviewableSourceEditor`, that drives editing sessions for +slices owned by such plugins. `EditModeController` gains one new branch in +`startEdit`: if any previewable plugin matches the slice, open +`PreviewableSourceEditor` instead of the existing WYSIWYG-or-raw routing. + +The mermaid plugin is the first implementer. Future plugins (math, graphviz, +embedded HTML, etc.) get live-preview for free by implementing the same four +methods. + +Rejected alternatives: + +- **Special-case mermaid in `EditModeController` directly.** Cheaper to ship + but no abstraction for future plugins. We have one such plugin today; + rather than add the second one before the contract exists, we accept the + modest extra design surface up front. +- **Dedicated mermaid editor modal / split view.** Too big a departure from + the inline-edit flow; complicates focus, escape semantics, and slice-state + bookkeeping. + +## Components + +### New + +| Component | Responsibility | Location | +|---|---|---| +| `PreviewablePlugin` interface | Optional plugin capability. Declares `matchesSlice`, `extractSource`, `renderPreview`, `applySourceToRaw`. | `src/plugins/types/preview.ts` | +| `PreviewableSourceEditor` | One editing session. Owns the layout (preview top, error chip middle, textarea bottom), the debounced re-render, the error-chip toggling, the idempotent commit. | `src/renderer/components/` | + +### Modified + +- **`MermaidPlugin`** — implements `PreviewablePlugin`. Adds four methods. + Existing `apply` (fence override) and `postRender` (async render) paths are + unchanged; live preview is additive. +- **`EditModeController`** — adds `findPreviewablePluginFor(slice)` lookup + and one new branch at the top of `startEdit`. Also factors the existing + `commitRawEdit` logic into a shared `applyRawCommit` helper so both raw + paths (slim textarea, previewable editor) share the same slice-update + + re-render code. +- **`PluginManager`** — adds `getPreviewablePlugins()` returning the subset + of plugins that implement the optional capability (type-narrowed). +- **`index.css`** — styles for the preview area, error chip, and the + textarea-below-preview layout. + +## The plugin contract + +```ts +export interface PreviewablePlugin { + /** Does this plugin own the given slice? Fast and side-effect-free. */ + matchesSlice(slice: MarkdownSlice): boolean; + + /** Extract the user-editable source from the slice's raw markdown + * (e.g. strip ```mermaid fences). */ + extractSource(slice: MarkdownSlice): string; + + /** Render `source` into `target`, replacing its contents on success. + * MUST NOT throw — errors are returned so the caller can keep the + * previous good preview visible. */ + renderPreview( + source: string, + target: HTMLElement + ): Promise<{ ok: true } | { ok: false; error: string }>; + + /** Inverse of extractSource — wrap the edited source back into a full + * slice raw. Implementations may consult `slice.raw` to preserve + * details like a fence's info-string. */ + applySourceToRaw(slice: MarkdownSlice, source: string): string; +} +``` + +Key contract guarantees: + +- `renderPreview` never throws. The `{ ok, error }` shape lets the editor + keep the last good preview while showing an error chip. +- On `{ ok: true }`, `target`'s contents are replaced. On `{ ok: false }`, + `target` is left alone. The caller can rely on "new content = render + succeeded." +- The four methods are stateless on the plugin (matches/extract/apply are + pure; renderPreview is async + mutates the target). All session state + lives in `PreviewableSourceEditor`. + +## `PreviewableSourceEditor` + +### Public API + +```ts +export interface PreviewableSourceEditorCallbacks { + /** Called once when the session commits, with the new slice raw. */ + onCommit: (newRaw: string) => void; +} + +export class PreviewableSourceEditor { + constructor( + contentEl: HTMLElement, + slice: MarkdownSlice, + plugin: PreviewablePlugin, + callbacks: PreviewableSourceEditorCallbacks + ); + start(): void; + commit(): void; // idempotent +} +``` + +### DOM it builds inside `contentEl` + +``` +
← passed-in contentEl +
← preview area (top) + +
+ + +
+``` + +The preview area uses the **existing rendered SVG** as its starting state — +when `start()` runs, the slice-content already contains the +`` placeholder filled with SVG. The editor wraps it in +the new structure rather than re-rendering. + +### Behavior + +- **Initial:** existing preview visible, textarea focused, error chip hidden. +- **Typing:** debounce 250ms after the last keystroke → call + `plugin.renderPreview(textarea.value, previewArea)`. +- **On success:** preview area's contents are replaced. Error chip stays + hidden. +- **On error:** preview area is unchanged. Error chip becomes visible with + the error text. +- **Race control:** each `renderPreview` call carries a monotonic + `requestId`. When the promise resolves, the result is dropped if a newer + call has been issued in between. Last keystroke wins. +- **`Esc`, blur, click-away:** committed via `commit()`. +- **`commit()`** runs `plugin.applySourceToRaw(slice, textarea.value)` → + `callbacks.onCommit(newRaw)`. Cancels any pending debounced render before + committing. Idempotent (boolean flag). + +## MermaidPlugin implementation + +```ts +matchesSlice(slice: MarkdownSlice): boolean { + if (slice.type !== 'code') return false; + const firstLine = slice.raw.trimStart().split('\n', 1)[0] ?? ''; + return /^```mermaid\b/.test(firstLine); +} + +extractSource(slice: MarkdownSlice): string { + const lines = slice.raw.split('\n'); + let start = 0; + let end = lines.length; + if (lines[0]?.trimStart().startsWith('```mermaid')) start = 1; + if (lines[lines.length - 1]?.trim() === '```') end = lines.length - 1; + return lines.slice(start, end).join('\n'); +} + +async renderPreview( + source: string, + target: HTMLElement +): Promise<{ ok: true } | { ok: false; error: string }> { + if (!this.mermaid) return { ok: false, error: 'Mermaid not initialised' }; + try { + const id = `mermaid-preview-${this.diagramCounter++}`; + const { svg } = await this.mermaid.render(id, source); + target.replaceChildren(); + target.insertAdjacentHTML('afterbegin', svg); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +applySourceToRaw(slice: MarkdownSlice, source: string): string { + const firstLine = slice.raw.trimStart().split('\n', 1)[0] ?? ''; + const match = firstLine.match(/^```(.*)$/); + const info = match?.[1] ?? 'mermaid'; + return '```' + info + '\n' + source + '\n```'; +} +``` + +Notes: + +- `matchesSlice` is conservative: only fenced code blocks whose info-string + starts with `mermaid`. `` ```js ``, `` ```python `` etc. fall through to + the existing raw editor. +- `applySourceToRaw` recovers the original info-string from `slice.raw`'s + opening fence. This preserves details like `` ```mermaid theme=dark ``. + Falls back to `mermaid` if the original fence doesn't parse. +- `renderPreview` reuses the plugin's `diagramCounter` so live and + post-render ids never collide. +- The existing `postRender` path is unchanged. Live preview is purely + additive. + +## Controller wiring + +```ts +// In EditModeController.startEdit, BEFORE the canSerialize check: +const previewable = this.findPreviewablePluginFor(slice); +if (previewable) { + this.activeEditIndex = sliceIndex; + el.classList.add('slice-editing'); + this.activePreviewableEditor = new PreviewableSourceEditor( + contentEl, slice, previewable, + { onCommit: (newRaw) => this.applyRawCommit(sliceIndex, newRaw) } + ); + this.activePreviewableEditor.start(); + return; +} +// existing canSerialize / WYSIWYG / raw routing follows +``` + +`commitActiveEdit` learns one more branch: route to +`this.activePreviewableEditor.commit()` if it's set. + +`applyRawCommit(sliceIndex, newRaw)` is the existing `commitRawEdit` body +factored out so both the slim raw editor and the previewable editor share +the same slice-update + re-render path. + +## Data flow + +``` +Open + click mermaid slice + → startEdit + → findPreviewablePluginFor → MermaidPlugin + → new PreviewableSourceEditor(contentEl, slice, plugin, callbacks) + → editor.start() + → wrap contentEl: [preview][error hidden][textarea] + → textarea.value = plugin.extractSource(slice) + → textarea.focus() + +Type + textarea input → debounce 250ms + → requestId = ++renderCounter + → plugin.renderPreview(source, previewArea) + → { ok: true } & latest: preview replaced, error chip hidden + → { ok: false } & latest: preview untouched, error chip shown + → stale: result discarded + +Commit (Esc / blur / segment switch) + editor.commit() + → cancel pending debounce + → newRaw = plugin.applySourceToRaw(slice, textarea.value) + → callbacks.onCommit(newRaw) + → controller.applyRawCommit(sliceIndex, newRaw) + → slicer.updateSlice → rawMarkdown, slices updated + → callbacks.onContentChange(rawMarkdown) + → re-render slice via standard path (postRender re-renders mermaid) +``` + +## Error handling + +| Situation | Behavior | +|---|---| +| Mermaid library not initialised | `{ ok: false, error: 'Mermaid not initialised' }`. Error chip; preview unchanged (likely empty on first render). Practically unreachable. | +| Mermaid syntax error | `{ ok: false, error: }`. Error chip shows the message; last good preview stays. | +| `renderPreview` throws unexpectedly | Caught and converted to `{ ok: false }`. Contract: never throws. | +| Concurrent renders | Monotonic `requestId` discards stale results. Last keystroke wins. | +| Commit before debounce fires | Pending render is cancelled. Commit uses the textarea's current value. | +| User presses `Esc` over an invalid source | Slice is committed with whatever's in the textarea — invalid mermaid round-trips back as a slice that the post-render path will mark as a ``. Matches existing behavior for raw commits. | + +The last good preview is sticky for the entire edit session. The error chip +makes invalid state visible without losing context. + +## Testing + +- **`MermaidPlugin.test.ts`** — extend with: + - `matchesSlice` truth table (mermaid, mermaid-with-info-string, other + languages, non-code slices, missing fence). + - `extractSource` strips fences and preserves inner content verbatim incl. + blank lines. + - `applySourceToRaw` round-trips `` ```mermaid `` and `` ```mermaid info ``; + falls back to `mermaid` when slice.raw has no recognisable fence. + - `renderPreview` success path (target replaced; `{ ok: true }`), failure + path (target untouched; `{ ok: false, error }`), and uninitialised path. + Mocks the mermaid library. + +- **`PreviewableSourceEditor.test.ts`** (new) — jsdom: + - `start()` builds the expected DOM and focuses the textarea. + - Initial textarea value equals `plugin.extractSource(slice)`. + - Typing triggers a debounced `renderPreview` call (`vi.useFakeTimers`). + - `{ ok: true }` replaces preview; error chip stays hidden. + - `{ ok: false }` leaves preview alone; error chip becomes visible. + - Concurrent renders: a slow first call followed by a fast second call — + only the second result is applied. + - `commit()` calls `applySourceToRaw` and forwards to `onCommit`. + - `commit()` is idempotent. + - `Esc` on the textarea commits. + +- **`EditModeController.test.ts`** — extend with: + - Clicking a `` ```mermaid `` slice opens the previewable editor (assert + via DOM: preview + textarea coexist). + - Clicking a `` ```js `` slice opens the regular raw textarea (no preview + area). + - Clicking a paragraph still opens the WYSIWYG inline editor. + - Committing a previewable edit updates the slice raw with the new fenced + content; `onContentChange` fires. + +Tests use a `FakePreviewablePlugin` stub so the controller test doesn't +depend on the real mermaid library. + +## Out of scope + +- Editing other code-block languages with live preview (e.g. live HTML + rendering, math). The contract supports it; implementation is per-plugin + and lands separately when a second consumer exists. +- Syntax highlighting inside the textarea (CodeMirror or similar). The + existing `.slice-raw-editor` plain textarea is reused. +- A `Cmd+/` toggle to expand/collapse the preview area. Defer until users + ask. +- Slice handle alignment for mermaid blocks (the separate reported issue). + Handled as a small follow-up CSS tweak independent of this design. From b75eec0645b22da8397ba9190241fce75393140e Mon Sep 17 00:00:00 2001 From: Paris Theofanidis Date: Tue, 19 May 2026 20:02:34 +0300 Subject: [PATCH 02/22] style: align slice handle for mermaid blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mermaid blocks share the 'code' slice type with regular code fences but render as rather than
. The 13px
handle offset tuned for 
's 16px top padding plus 85% font lines
was pushing the handle down into the diagram. Override to 0px for
mermaid via :has(.mermaid-container) so the handle sits at the
slice's top edge — aligned with the diagram's bounding box.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 src/index.css | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/index.css b/src/index.css
index 7edf5f8..7019ca3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1751,6 +1751,10 @@ button:focus:not(:focus-visible) {
 .slice-h5 { --slice-handle-top: 20px; } /* 0.875em * 1.25 = 15.3, center y=31.65 */
 .slice-h6 { --slice-handle-top: 19px; } /* 0.85em * 1.25 = 14.9, center y=31.45 */
 .slice-code { --slice-handle-top: 13px; } /* 
 padding-top 16 + 85% * 1.45 ≈ 17, center y=24.5 */
+/* Mermaid blocks share the 'code' slice type but render as 
+ * (no 
, no padding). Their content starts flush with the slice top, so the
+ * 13px offset tuned for 
 would push the handle into the diagram. */
+.slice-code:has(.mermaid-container) { --slice-handle-top: 0px; }
 
 .slice-handle-btn {
   display: flex;

From e8ae49c704a454c4c28d4f0ec86295c203e46f43 Mon Sep 17 00:00:00 2001
From: Paris Theofanidis 
Date: Tue, 19 May 2026 21:52:26 +0300
Subject: [PATCH 03/22] docs: add implementation plan for live mermaid preview

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 .../plans/2026-05-19-mermaid-live-preview.md  | 1537 +++++++++++++++++
 1 file changed, 1537 insertions(+)
 create mode 100644 docs/superpowers/plans/2026-05-19-mermaid-live-preview.md

diff --git a/docs/superpowers/plans/2026-05-19-mermaid-live-preview.md b/docs/superpowers/plans/2026-05-19-mermaid-live-preview.md
new file mode 100644
index 0000000..1f3d43b
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-mermaid-live-preview.md
@@ -0,0 +1,1537 @@
+# Live Mermaid Preview 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:** While editing a mermaid block, show the rendered diagram above the source and update it in near-realtime as the user types. Generalise the mechanism so any plugin that renders a fenced code block can opt in.
+
+**Architecture:** A new optional plugin capability `PreviewablePlugin` (`matchesSlice`, `extractSource`, `renderPreview`, `applySourceToRaw`) and one new component `PreviewableSourceEditor` that owns the preview-above-textarea editing surface. `EditModeController.startEdit` gains a single new branch that opens the previewable editor for slices owned by a previewable plugin; mermaid is the first implementer.
+
+**Tech Stack:** TypeScript, Vitest (`environment: 'node'`, opt into jsdom per-file via `// @vitest-environment jsdom`), the existing `mermaid` module (already module-mocked in `MermaidPlugin.test.ts`).
+
+**Reference spec:** `docs/superpowers/specs/2026-05-19-mermaid-live-preview-design.md`
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `src/plugins/types/preview.ts` (create) | Declares the `PreviewablePlugin` interface and a `isPreviewablePlugin` runtime type-guard. No runtime behaviour. |
+| `src/plugins/core/PluginManager.ts` (modify) | Adds `getPreviewablePlugins()` returning the subset of enabled plugins that implement the optional capability. |
+| `src/plugins/builtin/MermaidPlugin.ts` (modify) | Adds the four methods: `matchesSlice`, `extractSource`, `renderPreview`, `applySourceToRaw`. Existing `apply` and `postRender` paths unchanged. |
+| `src/renderer/components/PreviewableSourceEditor.ts` (create) | One editing session. Builds preview/error-chip/textarea DOM, drives debounced re-render with race control, idempotent commit. |
+| `src/renderer/components/EditModeController.ts` (modify) | `findPreviewablePluginFor(slice)` lookup, one new branch in `startEdit`, dispatch in `commitActiveEdit`. Factor `commitRawEdit`'s post-update + re-render path into a shared `applyRawCommit` helper. |
+| `src/index.css` (modify) | Styles for `.preview-source-preview`, `.preview-source-error`. |
+| `tests/unit/plugins/builtin/MermaidPlugin.test.ts` (modify) | Add suites for the four new methods. |
+| `tests/unit/renderer/components/PreviewableSourceEditor.test.ts` (create) | DOM-lifecycle, debounced render, race control, error chip, commit. |
+| `tests/unit/renderer/components/EditModeController.test.ts` (modify) | Mermaid slice → previewable editor; other code blocks → raw editor; paragraph → WYSIWYG; commit updates slice. |
+
+**DOM-construction note:** test fixtures build DOM with `element.insertAdjacentHTML('afterbegin', html)` and production re-renders use `replaceChildren()` + `insertAdjacentHTML('afterbegin', html)`. Matches the existing slice-menu code in `EditModeController`.
+
+---
+
+## Task 1: `PreviewablePlugin` interface + type guard
+
+**Files:**
+- Create: `src/plugins/types/preview.ts`
+
+This is a pure type declaration. No tests; subsequent tasks exercise it through their consumers.
+
+- [ ] **Step 1: Create the file**
+
+```ts
+/**
+ * PreviewablePlugin - Optional plugin capability for live-preview editing.
+ *
+ * A plugin that owns a particular kind of slice (e.g. MermaidPlugin owns
+ * ```mermaid fences) implements this so the editor can give the user a
+ * live-preview-above-source editing surface. The four methods are stateless
+ * on the plugin: matchesSlice/extractSource/applySourceToRaw are pure;
+ * renderPreview is async and mutates the target element.
+ */
+import type { MarkdownPlugin } from '@shared/types';
+import type { MarkdownSlice } from '../../renderer/services/MarkdownSlicer';
+
+export interface PreviewablePlugin {
+  /** Does this plugin own the given slice? Fast and side-effect-free. */
+  matchesSlice(slice: MarkdownSlice): boolean;
+
+  /** Extract the user-editable source from the slice's raw markdown
+   *  (e.g. strip ```mermaid fences). */
+  extractSource(slice: MarkdownSlice): string;
+
+  /** Render `source` into `target`, replacing its contents on success.
+   *  MUST NOT throw — errors are returned so the caller can keep the
+   *  previous good preview visible. On `{ ok: false }` `target` MUST
+   *  be left unchanged. */
+  renderPreview(
+    source: string,
+    target: HTMLElement,
+  ): Promise<{ ok: true } | { ok: false; error: string }>;
+
+  /** Inverse of extractSource — wrap the edited source back into a full
+   *  slice raw. Implementations may consult `slice.raw` to preserve
+   *  details like a fence's info-string. */
+  applySourceToRaw(slice: MarkdownSlice, source: string): string;
+}
+
+/** Runtime duck-type check. Returns true when `plugin` implements all four
+ *  PreviewablePlugin methods. */
+export function isPreviewablePlugin(
+  plugin: MarkdownPlugin,
+): plugin is MarkdownPlugin & PreviewablePlugin {
+  const p = plugin as Partial;
+  return typeof p.matchesSlice === 'function'
+      && typeof p.extractSource === 'function'
+      && typeof p.renderPreview === 'function'
+      && typeof p.applySourceToRaw === 'function';
+}
+```
+
+- [ ] **Step 2: Verify typecheck**
+
+Run: `pnpm typecheck`
+Expected: PASS — no errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/plugins/types/preview.ts
+git commit -m "feat: declare PreviewablePlugin capability interface"
+```
+
+---
+
+## Task 2: `PluginManager.getPreviewablePlugins()`
+
+**Files:**
+- Modify: `src/plugins/core/PluginManager.ts`
+- Test: `tests/unit/plugins/core/PluginManager.test.ts`
+
+- [ ] **Step 1: Write the failing test (append a new describe block)**
+
+```ts
+import {
+  isPreviewablePlugin,
+  type PreviewablePlugin,
+} from '@plugins/types/preview';
+import type { MarkdownPlugin } from '@shared/types';
+
+function fakePreviewablePlugin(id: string): MarkdownPlugin & PreviewablePlugin {
+  return {
+    metadata: { id, name: id, version: '1.0.0', description: '' },
+    matchesSlice: () => false,
+    extractSource: () => '',
+    renderPreview: async () => ({ ok: true }),
+    applySourceToRaw: (_s, source) => source,
+  };
+}
+
+describe('getPreviewablePlugins', () => {
+  it('returns only enabled plugins that implement the previewable capability', async () => {
+    const pm = new PluginManager();
+    pm.registerPluginFactory('mermaid-like', () => fakePreviewablePlugin('mermaid-like'));
+    pm.registerPluginFactory('plain', () => ({
+      metadata: { id: 'plain', name: 'plain', version: '1.0.0', description: '' },
+    }));
+    await pm.enablePlugin('mermaid-like');
+    await pm.enablePlugin('plain');
+
+    const previewable = pm.getPreviewablePlugins();
+    expect(previewable).toHaveLength(1);
+    expect(isPreviewablePlugin(previewable[0]!)).toBe(true);
+    expect(previewable[0]!.metadata.id).toBe('mermaid-like');
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm exec vitest run tests/unit/plugins/core/PluginManager.test.ts`
+Expected: FAIL — `pm.getPreviewablePlugins is not a function`.
+
+- [ ] **Step 3: Add the implementation**
+
+Add an import at the top of `src/plugins/core/PluginManager.ts`:
+
+```ts
+import { isPreviewablePlugin, type PreviewablePlugin } from '../types/preview';
+import type { MarkdownPlugin } from '@shared/types';
+```
+
+Add this method to the `PluginManager` class near `getPlugin`:
+
+```ts
+  /**
+   * Return all enabled plugins that implement the PreviewablePlugin
+   * capability. Order matches the order plugins were enabled.
+   */
+  getPreviewablePlugins(): (MarkdownPlugin & PreviewablePlugin)[] {
+    const result: (MarkdownPlugin & PreviewablePlugin)[] = [];
+    for (const id of this.renderer.getEnabledPlugins()) {
+      const plugin = this.renderer.getPlugin(id);
+      if (plugin && isPreviewablePlugin(plugin)) {
+        result.push(plugin);
+      }
+    }
+    return result;
+  }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm exec vitest run tests/unit/plugins/core/PluginManager.test.ts`
+Expected: PASS — all suites green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/plugins/core/PluginManager.ts tests/unit/plugins/core/PluginManager.test.ts
+git commit -m "feat: PluginManager.getPreviewablePlugins"
+```
+
+---
+
+## Task 3: `MermaidPlugin.matchesSlice`
+
+**Files:**
+- Modify: `src/plugins/builtin/MermaidPlugin.ts`
+- Test: `tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+
+- [ ] **Step 1: Write the failing test (append a new describe block)**
+
+```ts
+import type { MarkdownSlice } from '@renderer/services/MarkdownSlicer';
+
+function slice(partial: Partial): MarkdownSlice {
+  return {
+    index: 0,
+    type: 'code',
+    raw: '',
+    startLine: 0,
+    endLine: 0,
+    ...partial,
+  };
+}
+
+describe('matchesSlice', () => {
+  it('returns true for a code slice with a ```mermaid opening fence', () => {
+    expect(plugin.matchesSlice(slice({ raw: '```mermaid\nA --> B\n```' }))).toBe(true);
+  });
+
+  it('returns true when the mermaid fence has an info-string suffix', () => {
+    expect(plugin.matchesSlice(slice({ raw: '```mermaid theme=dark\nA --> B\n```' }))).toBe(true);
+  });
+
+  it('returns false for a code slice in another language', () => {
+    expect(plugin.matchesSlice(slice({ raw: '```js\nconsole.log(1);\n```' }))).toBe(false);
+  });
+
+  it('returns false for non-code slice types', () => {
+    expect(plugin.matchesSlice(slice({ type: 'paragraph', raw: 'mermaid' }))).toBe(false);
+  });
+
+  it('returns false when the slice has no opening fence', () => {
+    expect(plugin.matchesSlice(slice({ raw: 'graph TD\nA --> B' }))).toBe(false);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: FAIL — `plugin.matchesSlice is not a function`.
+
+- [ ] **Step 3: Add the implementation**
+
+Add an import at the top of `src/plugins/builtin/MermaidPlugin.ts`:
+
+```ts
+import type { PreviewablePlugin } from '../types/preview';
+import type { MarkdownSlice } from '../../renderer/services/MarkdownSlicer';
+```
+
+Change the class signature:
+
+```ts
+export class MermaidPlugin implements MarkdownPlugin, PreviewablePlugin {
+```
+
+Add the method to the class:
+
+```ts
+  matchesSlice(slice: MarkdownSlice): boolean {
+    if (slice.type !== 'code') return false;
+    const firstLine = slice.raw.trimStart().split('\n', 1)[0] ?? '';
+    return /^```mermaid\b/.test(firstLine);
+  }
+```
+
+The class will fail to compile (PreviewablePlugin is missing three more methods). To keep typecheck green until the next tasks land, add stubs that throw:
+
+```ts
+  extractSource(_slice: MarkdownSlice): string {
+    throw new Error('not implemented');
+  }
+  async renderPreview(
+    _source: string,
+    _target: HTMLElement,
+  ): Promise<{ ok: true } | { ok: false; error: string }> {
+    throw new Error('not implemented');
+  }
+  applySourceToRaw(_slice: MarkdownSlice, _source: string): string {
+    throw new Error('not implemented');
+  }
+```
+
+Tasks 4-6 replace each stub with a real implementation.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: PASS — matchesSlice tests pass; the stubbed methods aren't exercised yet.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/plugins/builtin/MermaidPlugin.ts tests/unit/plugins/builtin/MermaidPlugin.test.ts
+git commit -m "feat: MermaidPlugin.matchesSlice"
+```
+
+---
+
+## Task 4: `MermaidPlugin.extractSource`
+
+**Files:**
+- Modify: `src/plugins/builtin/MermaidPlugin.ts`
+- Test: `tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+
+- [ ] **Step 1: Write the failing test (append a new describe block)**
+
+```ts
+describe('extractSource', () => {
+  it('strips opening ```mermaid fence and closing ``` fence', () => {
+    const s = slice({ raw: '```mermaid\ngraph TD\nA --> B\n```' });
+    expect(plugin.extractSource(s)).toBe('graph TD\nA --> B');
+  });
+
+  it('strips opening fence with info-string suffix', () => {
+    const s = slice({ raw: '```mermaid theme=dark\ngraph TD\nA --> B\n```' });
+    expect(plugin.extractSource(s)).toBe('graph TD\nA --> B');
+  });
+
+  it('preserves blank lines and trailing whitespace inside the content', () => {
+    const s = slice({ raw: '```mermaid\ngraph TD\n\n  A --> B\n```' });
+    expect(plugin.extractSource(s)).toBe('graph TD\n\n  A --> B');
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: FAIL — `extractSource` throws `not implemented`.
+
+- [ ] **Step 3: Replace the stub**
+
+In `src/plugins/builtin/MermaidPlugin.ts`, replace the `extractSource` stub with:
+
+```ts
+  extractSource(slice: MarkdownSlice): string {
+    const lines = slice.raw.split('\n');
+    let start = 0;
+    let end = lines.length;
+    if (lines[0]?.trimStart().startsWith('```mermaid')) start = 1;
+    if (lines[lines.length - 1]?.trim() === '```') end = lines.length - 1;
+    return lines.slice(start, end).join('\n');
+  }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/plugins/builtin/MermaidPlugin.ts tests/unit/plugins/builtin/MermaidPlugin.test.ts
+git commit -m "feat: MermaidPlugin.extractSource"
+```
+
+---
+
+## Task 5: `MermaidPlugin.applySourceToRaw`
+
+**Files:**
+- Modify: `src/plugins/builtin/MermaidPlugin.ts`
+- Test: `tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+
+- [ ] **Step 1: Write the failing test (append a new describe block)**
+
+```ts
+describe('applySourceToRaw', () => {
+  it("re-wraps source with the slice's original opening info-string", () => {
+    const s = slice({ raw: '```mermaid\nold\n```' });
+    expect(plugin.applySourceToRaw(s, 'graph TD\nA --> B')).toBe(
+      '```mermaid\ngraph TD\nA --> B\n```',
+    );
+  });
+
+  it('preserves an info-string suffix on the opening fence', () => {
+    const s = slice({ raw: '```mermaid theme=dark\nold\n```' });
+    expect(plugin.applySourceToRaw(s, 'graph TD\nA --> B')).toBe(
+      '```mermaid theme=dark\ngraph TD\nA --> B\n```',
+    );
+  });
+
+  it('falls back to "mermaid" when slice.raw has no recognisable opening fence', () => {
+    const s = slice({ raw: 'no fence' });
+    expect(plugin.applySourceToRaw(s, 'graph TD')).toBe('```mermaid\ngraph TD\n```');
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: FAIL — `applySourceToRaw` throws `not implemented`.
+
+- [ ] **Step 3: Replace the stub**
+
+```ts
+  applySourceToRaw(slice: MarkdownSlice, source: string): string {
+    const firstLine = slice.raw.trimStart().split('\n', 1)[0] ?? '';
+    const match = firstLine.match(/^```(.*)$/);
+    const info = match?.[1] ?? 'mermaid';
+    return '```' + info + '\n' + source + '\n```';
+  }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/plugins/builtin/MermaidPlugin.ts tests/unit/plugins/builtin/MermaidPlugin.test.ts
+git commit -m "feat: MermaidPlugin.applySourceToRaw preserves info-string"
+```
+
+---
+
+## Task 6: `MermaidPlugin.renderPreview`
+
+**Files:**
+- Modify: `src/plugins/builtin/MermaidPlugin.ts`
+- Test: `tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+
+- [ ] **Step 1: Write the failing test (append a new describe block)**
+
+```ts
+/**
+ * @vitest-environment jsdom
+ */
+// NOTE: This describe needs the DOM; ensure the file's first-line annotation
+// is `@vitest-environment jsdom`. The existing MermaidPlugin.test.ts already
+// uses jsdom — if not, add the annotation to the top of the file.
+
+describe('renderPreview', () => {
+  it('replaces target contents with rendered SVG on success', async () => {
+    const target = document.createElement('div');
+    target.textContent = 'placeholder';
+    const result = await plugin.renderPreview('graph TD\nA --> B', target);
+    expect(result).toEqual({ ok: true });
+    expect(target.children.length).toBe(1);
+    expect(target.children[0]!.tagName.toLowerCase()).toBe('svg');
+    expect(target.textContent).toBe('Mock SVG');
+  });
+
+  it('leaves target untouched and returns the error on failure', async () => {
+    const mermaid = (await import('mermaid')).default;
+    vi.mocked(mermaid.render).mockRejectedValueOnce(new Error('Parse error'));
+    const target = document.createElement('div');
+    target.textContent = 'previous good preview';
+    const result = await plugin.renderPreview('bogus', target);
+    expect(result).toEqual({ ok: false, error: 'Parse error' });
+    expect(target.textContent).toBe('previous good preview');
+  });
+
+  it('returns an error when the mermaid library is not initialised', async () => {
+    const uninit = new MermaidPlugin();
+    const target = document.createElement('div');
+    const result = await uninit.renderPreview('graph TD', target);
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toMatch(/not initialised/i);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: FAIL — `renderPreview` throws `not implemented`.
+
+- [ ] **Step 3: Replace the stub**
+
+```ts
+  async renderPreview(
+    source: string,
+    target: HTMLElement,
+  ): Promise<{ ok: true } | { ok: false; error: string }> {
+    if (!this.mermaid) return { ok: false, error: 'Mermaid not initialised' };
+    try {
+      const id = `mermaid-preview-${this.diagramCounter++}`;
+      const { svg } = await this.mermaid.render(id, source);
+      target.replaceChildren();
+      target.insertAdjacentHTML('afterbegin', svg);
+      return { ok: true };
+    } catch (e) {
+      return { ok: false, error: e instanceof Error ? e.message : String(e) };
+    }
+  }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm exec vitest run tests/unit/plugins/builtin/MermaidPlugin.test.ts`
+Expected: PASS — all MermaidPlugin tests green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/plugins/builtin/MermaidPlugin.ts tests/unit/plugins/builtin/MermaidPlugin.test.ts
+git commit -m "feat: MermaidPlugin.renderPreview with contract-conforming error handling"
+```
+
+---
+
+## Task 7: `PreviewableSourceEditor` lifecycle (start + idempotent commit)
+
+**Files:**
+- Create: `src/renderer/components/PreviewableSourceEditor.ts`
+- Test: `tests/unit/renderer/components/PreviewableSourceEditor.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { PreviewableSourceEditor } from '../../../../src/renderer/components/PreviewableSourceEditor';
+import type { PreviewablePlugin } from '../../../../src/plugins/types/preview';
+import type { MarkdownSlice } from '../../../../src/renderer/services/MarkdownSlicer';
+import type { MarkdownPlugin } from '../../../../src/shared/types';
+
+function contentEl(html: string): HTMLElement {
+  const el = document.createElement('div');
+  el.className = 'slice-content';
+  el.insertAdjacentHTML('afterbegin', html);
+  document.body.appendChild(el);
+  return el;
+}
+
+function slice(partial: Partial = {}): MarkdownSlice {
+  return { index: 0, type: 'code', raw: '```mermaid\nA --> B\n```', startLine: 0, endLine: 2, ...partial };
+}
+
+function fakePlugin(overrides: Partial = {}): MarkdownPlugin & PreviewablePlugin {
+  return {
+    metadata: { id: 'fake', name: 'fake', version: '1.0.0', description: '' },
+    matchesSlice: () => true,
+    extractSource: () => 'A --> B',
+    renderPreview: vi.fn(async () => ({ ok: true })),
+    applySourceToRaw: (_s, source) => '```mermaid\n' + source + '\n```',
+    ...overrides,
+  };
+}
+
+describe('PreviewableSourceEditor lifecycle', () => {
+  it('start() builds preview/error-chip/textarea structure inside contentEl', () => {
+    const el = contentEl('
old
'); + const editor = new PreviewableSourceEditor(el, slice(), fakePlugin(), { onCommit: vi.fn() }); + editor.start(); + expect(el.querySelector('.preview-source-preview')).not.toBe(null); + expect(el.querySelector('.preview-source-error')).not.toBe(null); + expect(el.querySelector('textarea.slice-raw-editor')).not.toBe(null); + // existing rendered SVG was moved into the preview area + const movedSvg = el.querySelector('.preview-source-preview .mermaid-container svg'); + expect(movedSvg).not.toBe(null); + expect(movedSvg!.textContent).toBe('old'); + }); + + it('start() seeds textarea with plugin.extractSource and focuses it', () => { + const el = contentEl('
'); + const editor = new PreviewableSourceEditor(el, slice(), fakePlugin(), { onCommit: vi.fn() }); + editor.start(); + const ta = el.querySelector('textarea')!; + expect(ta.value).toBe('A --> B'); + expect(document.activeElement).toBe(ta); + }); + + it('start() hides the error chip initially', () => { + const el = contentEl('
'); + const editor = new PreviewableSourceEditor(el, slice(), fakePlugin(), { onCommit: vi.fn() }); + editor.start(); + expect(el.querySelector('.preview-source-error')!.hidden).toBe(true); + }); + + it('commit() calls plugin.applySourceToRaw with the textarea value and forwards to onCommit', () => { + const el = contentEl('
'); + const onCommit = vi.fn(); + const editor = new PreviewableSourceEditor(el, slice(), fakePlugin(), { onCommit }); + editor.start(); + el.querySelector('textarea')!.value = 'C --> D'; + editor.commit(); + expect(onCommit).toHaveBeenCalledWith('```mermaid\nC --> D\n```'); + }); + + it('commit() is idempotent — a second call is a no-op', () => { + const el = contentEl('
'); + const onCommit = vi.fn(); + const editor = new PreviewableSourceEditor(el, slice(), fakePlugin(), { onCommit }); + editor.start(); + editor.commit(); + editor.commit(); + expect(onCommit).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm exec vitest run tests/unit/renderer/components/PreviewableSourceEditor.test.ts` +Expected: FAIL — cannot resolve `PreviewableSourceEditor`. + +- [ ] **Step 3: Write the minimal implementation** + +```ts +/** + * PreviewableSourceEditor - Drives a "preview above source" editing session + * for a slice owned by a PreviewablePlugin. + * + * Layout it builds inside the passed-in `.slice-content`: + * + *
+ *
← preview (top, populated initially + * with the existing rendered content + * moved from the slice-content) + *