---
author: architect
responsible: architect
phase: 40
phase_type: implementation
status: Ready for execution
hard_bar: false
security_relevant: false
design_fork: false
wave: 1
depends_on: []
files_modified:
  - web/src/stores/editorMode.ts
  - web/src/lib/cm/mode.ts
  - web/src/components/LivePreviewEditor.tsx
  - web/src/routes/PageEditor.tsx
requirements: [RE-05]
autonomous: true
---

# Phase 40: Editor-Host Abstraction (safe refactor, no behavior change) - Plan

**Goal:** Abstract the editor-surface coupling in `PageEditor` behind a stable `EditorSurface`
interface (props + an imperative `insertAtCursor` handle), extend `editorMode` to 3-way, and keep the
CM6 path **byte-identical**. Isolate the refactor from TipTap risk (MAJ-03); unblock RE-03 (BLK-01).

**Requirements:** RE-05

> **Zero behavior change is the bar.** Structure changes; behavior does not. `runSaver` and the
> save/lock/409/reconcile flow are ALREADY surface-agnostic — **do NOT touch them.** The full existing
> editor + PageEditor suites must pass unchanged (a moved `vi.mock` seam aside, MIN-10).

## must_haves

```yaml
truths:
  - id: RE-05-1
    statement: "A stable EditorSurface interface (value/onChange/mode/readOnly/currentPath + imperative insertAtCursor handle) exists; LivePreviewEditor implements it via forwardRef/useImperativeHandle; PageEditor mounts the surface through it and no longer reaches into .cm-editor cmView.view for insert"
    verification: "grep -n 'insertAtCursor\\|useImperativeHandle\\|forwardRef' web/src/components/LivePreviewEditor.tsx; grep -n 'cmView' web/src/routes/PageEditor.tsx | grep -v '// '"
  - id: RE-05-2
    statement: "editorMode is extended to EditorMode = 'live' | 'source' | 'tiptap' in the EXISTING store (key okf.editor.mode, no new file); the CM6 toggle()/Mod-e keymap + mode.ts setMode stay live<->source only and cannot receive 'tiptap'; 'tiptap' is not yet user-selectable"
    verification: "grep -n 'tiptap' web/src/stores/editorMode.ts; grep -rn 'editorMode' web/src/lib/editorMode.ts 2>/dev/null || echo 'no new file (correct)'"
  - id: RE-05-3
    statement: "Zero behavior change: the full existing editor + PageEditor suites pass unchanged (a moved vi.mock seam aside); CM6 save/lock/409/reconcile/insert behavior is byte-identical; runSaver is untouched"
    verification: "cd web && npx vitest run src/components/LivePreviewEditor.test.tsx src/lib/cm/mode.test.ts src/routes/PageEditor.test.tsx src/routes/PageEditor.conflict.test.tsx src/routes/PageEditor.reconcile.adversarial.test.tsx; git diff --stat web/src/routes/PageEditor.tsx | grep -oE 'runSaver' || echo 'runSaver not structurally moved'"
```

## Tasks

### Task 1: 3-way editorMode + narrowed CM6 setMode (Wave 1, MED-05/BLK-02)

<read_first>
- web/src/stores/editorMode.ts (the whole file — 2-way EditorMode/setMode/toggle, key okf.editor.mode)
- web/src/lib/cm/mode.ts (setMode(view, mode) + toggleKeymap Mod-e — CM6-internal 2-way)
</read_first>

<action>
**1. Extend the store type** in `web/src/stores/editorMode.ts` (do NOT create a new file — BLK-02):
```typescript
export type EditorMode = "live" | "source" | "tiptap";
```
- Keep the persisted key `okf.editor.mode`, default `"live"`.
- `setMode(mode: EditorMode)` (the store setter) accepts all three (it's the toolbar's source of truth).
- `toggle()` (the store's Cmd/Ctrl-E flip) stays **`live` ⇄ `source` ONLY** — a `"tiptap"` current
  value toggles back to `"live"` (never toggles INTO tiptap; the keymap is a CM6-only affordance).
  Document this.

**2. Narrow the CM6 `mode.ts` `setMode`** so it can never receive `"tiptap"`: its parameter type is a
CM6-surface mode `"live" | "source"` (a distinct narrower type, or a guard that no-ops on `"tiptap"`).
The CM6 `toggleKeymap` (Mod-e) dispatches only `live`⇄`source`. `"tiptap"` never reaches the CM6 view
config (a TipTap surface isn't a CM6 view).
</action>

<acceptance_criteria>
- `grep -n '"tiptap"' web/src/stores/editorMode.ts` matches (union extended)
- No new file: `ls web/src/lib/editorMode.ts 2>/dev/null` returns nothing (BLK-02)
- `cd web && npx tsc --noEmit` exits 0
- `cd web && npx vitest run src/lib/cm/mode.test.ts` exits 0 (CM6 toggle unchanged)
</acceptance_criteria>

### Task 2: EditorSurface interface + LivePreviewEditor imperative handle (Wave 1, MED-06)

<read_first>
- web/src/components/LivePreviewEditor.tsx (props :39-46; the view useRef :86; view created :199; cmView stamp :205)
- web/src/routes/PageEditor.tsx:529-542 (insertTemplateAtCursor — the .cm-editor reach-in to replace)
</read_first>

<action>
**1. Define the surface interface** (in `LivePreviewEditor.tsx` or a small `web/src/components/editorSurface.ts`):
```typescript
export interface EditorSurfaceHandle { insertAtCursor(text: string): void; }
// props: value, onChange, mode, readOnly, currentPath (the existing LivePreviewEditorProps)
```

**2. Convert `LivePreviewEditor` to `forwardRef<EditorSurfaceHandle, LivePreviewEditorProps>`** and add:
```typescript
useImperativeHandle(ref, () => ({
  insertAtCursor(text: string) {
    const v = view.current;
    if (!v) return;
    v.dispatch(v.state.replaceSelection(text));
    // onChange fires via the existing updateListener on docChanged — same path as today
  },
}), []);
```
- The `view` useRef (`:86`) already holds the `EditorView`; reuse it. KEEP the `cmView.view` stamp
  (`:205`) — existing tests read it. The default export stays default (forwardRef default export works
  with `vi.mock`).

**3. Rewire `PageEditor`'s inserts** to use a `surfaceRef` instead of the DOM reach-in:
- Add `const surfaceRef = useRef<EditorSurfaceHandle>(null);`
- `insertTemplateAtCursor` (`:529-542`): replace the `editorSurfaceRef.current?.querySelector(".cm-editor")`
  → `cmView.view` → `view.dispatch(...)` block with `surfaceRef.current?.insertAtCursor(substituted);`
- `insertLink` (`:507`): unchanged if it uses `setBody` (it does — leave it, OR route through
  `insertAtCursor` for consistency; the plan permits either — recommend `insertAtCursor` so both inserts
  share one path, but `setBody` append is not a regression). Keep whichever preserves today's behavior.
- Pass `ref={surfaceRef}` to `<LivePreviewEditor>`. The `editorSurfaceRef` wrapper div may stay (it's
  the CSS surface container) but is no longer used to reach the view.
</action>

<acceptance_criteria>
- `grep -n 'insertAtCursor\|useImperativeHandle\|forwardRef' web/src/components/LivePreviewEditor.tsx` matches
- `grep -n 'querySelector(".cm-editor")' web/src/routes/PageEditor.tsx` returns NO match (reach-in removed)
- `grep -n 'cmView' web/src/components/LivePreviewEditor.tsx` still matches (the test-visible stamp kept)
- `cd web && npx tsc --noEmit` exits 0
- `cd web && npx vitest run src/components/LivePreviewEditor.test.tsx` exits 0
</acceptance_criteria>

### Task 3: Mount seam + guard `"tiptap"` unreachable (Wave 1, D-02/D-03)

<read_first>
- web/src/routes/PageEditor.tsx:921-927 (the <LivePreviewEditor> mount), :759 (the 2-way toolbar toggle)
</read_first>

<action>
**1. Add the mount seam.** A minimal `renderEditorSurface()` (or a small `<EditorHost>` wrapper) that
mounts `<LivePreviewEditor ref={surfaceRef} value={body} onChange={onBodyChange} currentPath={path}
mode={mode} readOnly={readOnly} />` for `mode === "live" | "source"`. For `mode === "tiptap"` (not
user-selectable this phase), fall back to mounting CM6 in `"live"` (Phase 41 replaces this branch with
the real TipTap surface; Phase 42 gates selection). Do NOT add a TipTap import.

**2. Leave the toolbar 2-way flip** (`:759`) as `live`⇄`source` this phase (Phase 42 adds the 3-way
selector + load guard).

**3. Do NOT touch `runSaver`** or the conflict/lock/reconcile state — they read `body`/`frontmatter`
refs and are already surface-agnostic.
</action>

<acceptance_criteria>
- `<LivePreviewEditor>` is mounted through the seam with a `ref`; `mode === "tiptap"` falls back to CM6 (no crash, no TipTap import)
- `grep -n '@tiptap' web/src/routes/PageEditor.tsx` returns NO match (no TipTap import this phase)
- `runSaver` body is unchanged (structurally): `git diff web/src/routes/PageEditor.tsx` shows no edits inside the runSaver useCallback except call-site-neutral
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** RE-05 delivers a surface seam + 3-way mode with ZERO behavior change.
  1. RE-05-1: the `EditorSurface` interface + `insertAtCursor` handle exist; LivePreviewEditor
     implements it; PageEditor no longer reaches into `.cm-editor` for insert (grep confirms).
  2. RE-05-2: `editorMode` is 3-way in the existing store (no new file); CM6 keymap/`setMode` stay
     2-way and can't receive `"tiptap"`; `"tiptap"` is not user-selectable.
  3. RE-05-3 (THE bar): the FULL existing editor + PageEditor suites pass UNCHANGED (a moved `vi.mock`
     seam aside). `runSaver` is untouched. Behavior byte-identical.
- **Zero-behavior-change check (hard):** qa runs `LivePreviewEditor.test.tsx`, `mode.test.ts`,
  `PageEditor.test.tsx` + `.conflict` + `.reconcile.adversarial` — all pass. If any test needed a real
  assertion change (not just a mock re-point) to pass, that's a behavior change → gap, routed UP.
- **v1.6 lesson applied:** this touches shared components (`PageEditor`, `LivePreviewEditor`,
  `editorMode`) — run each component's FULL suite, plus `tsc --noEmit` + `npm run build` (now green
  after the pre-existing-breakage fix; must STAY green).
- **No bundle change:** no TipTap import this phase; `npm run build` + `assertLazyChunks` stay green.
