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) + * +``` + +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. diff --git a/src/index.css b/src/index.css index 7edf5f8..c166d80 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;
@@ -1810,6 +1814,32 @@ button:focus:not(:focus-visible) {
   cursor: text;
 }
 
+/* Live-preview editor: preview area + error chip + textarea. */
+/* .preview-source-preview has no rules: the preview's natural sizing is
+ * whatever the plugin renders (mermaid SVG is `max-width: 100%; height: auto;`). */
+.preview-source-error {
+  margin: 4px 0 0;
+  padding: 6px 10px;
+  border-radius: 4px;
+  background: var(--error-bg, rgba(220, 38, 38, 0.12));
+  color: var(--error-text, #b91c1c);
+  font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
+  font-size: 12px;
+  line-height: 1.4;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.preview-source-error[hidden] {
+  display: none;
+}
+
+.preview-source-preview + .preview-source-error + .slice-raw-editor,
+.preview-source-preview + .slice-raw-editor {
+  /* Small gap between preview/error and the editor textarea. */
+  margin-top: 6px;
+}
+
 /* Slim raw-markdown editor (used by the "Edit as markdown" toggle) */
 .slice-raw-editor {
   width: 100%;
diff --git a/src/plugins/builtin/MermaidPlugin.ts b/src/plugins/builtin/MermaidPlugin.ts
index 45238f9..89c9a36 100644
--- a/src/plugins/builtin/MermaidPlugin.ts
+++ b/src/plugins/builtin/MermaidPlugin.ts
@@ -20,6 +20,8 @@ import type {
 } from '@shared/types';
 import type { PluginThemeDeclaration } from '../../themes/types';
 import type MarkdownIt from 'markdown-it';
+import type { PreviewablePlugin } from '../types/preview';
+import type { MarkdownSlice } from '../../renderer/services/MarkdownSlicer';
 
 /**
  * Options for the MermaidPlugin
@@ -34,7 +36,7 @@ export interface MermaidOptions extends PluginOptions {
 /**
  * Mermaid diagram rendering plugin
  */
-export class MermaidPlugin implements MarkdownPlugin {
+export class MermaidPlugin implements MarkdownPlugin, PreviewablePlugin {
   metadata: PluginMetadata = {
     id: BUILTIN_PLUGINS.MERMAID,
     name: 'Mermaid Diagrams',
@@ -600,6 +602,52 @@ export class MermaidPlugin implements MarkdownPlugin {
     return `https://mermaid.live/edit#pako:${base64}`;
   }
 
+  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;
+    // Strip opening fence (``` or ~~~ followed by "mermaid").
+    if (lines[0] && /^(?:```|~~~)mermaid\b/.test(lines[0].trimStart())) {
+      start = 1;
+    }
+    // Strip closing fence (``` or ~~~ on its own).
+    const lastLine = lines[lines.length - 1]?.trimEnd();
+    if (lastLine === '```' || lastLine === '~~~') {
+      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 fence = match?.[1] ?? '```';
+    const info = match?.[2] ?? 'mermaid';
+    return fence + info + '\n' + source + '\n' + fence;
+  }
+
   destroy(): void {
     // Reset counter
     this.diagramCounter = 0;
diff --git a/src/plugins/core/PluginManager.ts b/src/plugins/core/PluginManager.ts
index 4af186c..6023bb8 100644
--- a/src/plugins/core/PluginManager.ts
+++ b/src/plugins/core/PluginManager.ts
@@ -4,6 +4,7 @@
 import { PluginAlreadyRegisteredError } from '@shared/errors';
 
 import { MarkdownRenderer, createMarkdownRenderer } from './MarkdownRenderer';
+import { isPreviewablePlugin, type PreviewablePlugin } from '../types/preview';
 
 import type { MarkdownRendererOptions } from './MarkdownRenderer';
 import type {
@@ -206,6 +207,21 @@ export class PluginManager {
     return this.renderer.getPlugin(pluginId);
   }
 
+  /**
+   * 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.enabledPlugins) {
+      const plugin = this.renderer.getPlugin(id);
+      if (plugin && isPreviewablePlugin(plugin)) {
+        result.push(plugin);
+      }
+    }
+    return result;
+  }
+
   /**
    * Get current configuration
    */
diff --git a/src/plugins/types/preview.ts b/src/plugins/types/preview.ts
new file mode 100644
index 0000000..7e24bb2
--- /dev/null
+++ b/src/plugins/types/preview.ts
@@ -0,0 +1,46 @@
+/**
+ * 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';
+}
diff --git a/src/renderer/components/EditModeController.ts b/src/renderer/components/EditModeController.ts
index be41f18..72b9ed6 100644
--- a/src/renderer/components/EditModeController.ts
+++ b/src/renderer/components/EditModeController.ts
@@ -11,6 +11,8 @@ import { InlineEditor } from './InlineEditor';
 import type { InlineMark } from './InlineEditor';
 import { FloatingFormatToolbar, type ToolbarAction } from './FloatingFormatToolbar';
 import { canSerialize } from '../services/inlineMarkdownSerializer';
+import { PreviewableSourceEditor } from './PreviewableSourceEditor';
+import type { PreviewablePlugin } from '../../plugins/types/preview';
 
 /**
  * Callbacks for EditModeController events
@@ -47,6 +49,7 @@ export class EditModeController {
   private activeEditIndex: number | null = null;
   private activeInlineEditor: InlineEditor | null = null;
   private activeRawTextarea: HTMLTextAreaElement | null = null;
+  private activePreviewableEditor: PreviewableSourceEditor | null = null;
   private toolbarVisible = false;
   private toolbar: FloatingFormatToolbar | null = null;
   private activeMenu: HTMLElement | null = null;
@@ -187,7 +190,27 @@ export class EditModeController {
     const contentEl = el.querySelector('.slice-content');
     if (!contentEl) return;
 
-    // Unsupported inline content is handled by the raw editor (Task 10).
+    // Previewable plugin opt-in (e.g. mermaid live preview).
+    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;
+    }
+
+    // Code slices without a previewable plugin fall back to raw editing —
+    // WYSIWYG round-tripping of fenced code blocks is not supported.
+    if (slice.type === 'code') {
+      this.startRawEdit(sliceIndex);
+      return;
+    }
+
+    // Unsupported inline content is handled by the raw editor.
     if (!canSerialize(contentEl)) {
       this.startRawEdit(sliceIndex);
       return;
@@ -293,19 +316,26 @@ export class EditModeController {
   }
 
   /**
-   * Commit a raw-textarea edit: the textarea value is the slice's markdown
-   * verbatim — no block-prefix reconciliation needed.
+   * Commit a raw-textarea edit: reads the textarea value then delegates to
+   * applyRawCommit for the shared re-render path.
    */
   private commitRawEdit(sliceIndex: number): void {
     const textarea = this.activeRawTextarea;
     this.activeRawTextarea = null;
     if (!textarea) return;
+    this.applyRawCommit(sliceIndex, textarea.value);
+  }
 
+  /**
+   * Shared "raw commit" path used by both the slim raw textarea and the
+   * PreviewableSourceEditor. Updates the slice via the slicer, fires
+   * onContentChange, and re-renders the slice.
+   */
+  private applyRawCommit(sliceIndex: number, newRaw: string): void {
     const slice = this.slices.find((s) => s.index === sliceIndex);
     const el = this.sliceElements.get(sliceIndex);
     if (!slice || !el) return;
 
-    const newRaw = textarea.value;
     if (newRaw !== slice.raw) {
       const result = this.slicer.updateSlice(this.slices, sliceIndex, newRaw);
       this.rawMarkdown = result.markdown;
@@ -329,6 +359,20 @@ export class EditModeController {
     }
   }
 
+  /**
+   * Find the first previewable plugin that claims the given slice, or null.
+   */
+  private findPreviewablePluginFor(slice: MarkdownSlice): PreviewablePlugin | null {
+    const pm = this.pluginManager as PluginManager & {
+      getPreviewablePlugins?: () => (PreviewablePlugin & { metadata: unknown })[];
+    };
+    if (typeof pm.getPreviewablePlugins !== 'function') return null;
+    for (const plugin of pm.getPreviewablePlugins()) {
+      if (plugin.matchesSlice(slice)) return plugin;
+    }
+    return null;
+  }
+
   /**
    * Toggle the active slice between WYSIWYG and raw-markdown editing.
    * Bound to Cmd+/ and the "Edit as markdown" handle-menu item.
@@ -472,6 +516,12 @@ export class EditModeController {
     const sliceIndex = this.activeEditIndex;
     this.activeEditIndex = null;
 
+    if (this.activePreviewableEditor) {
+      const editor = this.activePreviewableEditor;
+      this.activePreviewableEditor = null;
+      editor.commit();
+      return;
+    }
     if (this.activeRawTextarea) {
       this.commitRawEdit(sliceIndex);
       return;
diff --git a/src/renderer/components/PreviewableSourceEditor.ts b/src/renderer/components/PreviewableSourceEditor.ts
new file mode 100644
index 0000000..d171545
--- /dev/null
+++ b/src/renderer/components/PreviewableSourceEditor.ts
@@ -0,0 +1,124 @@
+/**
+ * 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) + *