---
author: architect
responsible: architect
phase: 42
phase_type: implementation
status: Ready for execution
hard_bar: false
security_relevant: true
design_fork: true
design_fork_resolution: "informed-consent (tree-equality guard + one-time notice)"
wave: 1
depends_on: [40, 41]
files_modified:
  - web/src/lib/tiptap/guard.ts
  - web/src/lib/tiptap/guard.test.ts
  - web/src/routes/PageEditor.tsx
  - web/src/components/TipTapConsentNotice.tsx
  - web/vite.config.ts
  - web/src/routes/PageEditor.test.tsx
requirements: [RE-04]
autonomous: true
---

# Phase 42: Toggle + Load Guard + Code-Split (user-facing opt-in) - Plan

**Goal:** Expose TipTap as a user-selectable opt-in mode (3-way toggle), gate entry with a
tree-equality load guard that warns AND falls back on non-round-trippable content, show a one-time
informed-consent notice, and build-enforce that the whole TipTap tree stays out of the entry chunk.

**Requirements:** RE-04 · **DESIGN FORK RESOLVED: informed consent (tree-equality + one-time notice).**

> The design fork is DECIDED (co-driver: informed consent). Implement the tree-equality guard + the
> one-time notice. Do NOT add a byte-delta fallback (rejected option ii). The guard is a guard, not a
> toast: non-round-trippable content warns AND falls back to CM6, on BOTH open and switch.

## must_haves

```yaml
truths:
  - id: RE-04-1
    statement: "A user-selectable 3-way mode toggle (live/source/tiptap) in the PageEditor toolbar sets editorMode; CM6 (live) stays the default; selecting tiptap routes through the load guard before mounting"
    verification: "grep -nE 'tiptap|setMode|segmented|3-way|three' web/src/routes/PageEditor.tsx | head"
  - id: RE-04-2
    statement: "isLosslessRoundTrip(md) = tree-equality (parse(roundTrip(md)) deep-equals parse(md)); the load guard runs on BOTH open-in-tiptap AND switch-to-tiptap against the CURRENT live buffer; non-round-trippable content WARNS AND FALLS BACK to CM6 (never enters TipTap)"
    verification: "grep -nE 'isLosslessRoundTrip|roundTrip|parse' web/src/lib/tiptap/guard.ts; cd web && npx vitest run src/lib/tiptap/guard.test.ts"
  - id: RE-04-3
    statement: "A one-time (dismiss-persisted) informed-consent notice states TipTap normalizes modeled syntax for all viewers; shown on first TipTap entry; not a per-save nag"
    verification: "grep -nE 'consent|normaliz|localStorage|okf.tiptap' web/src/components/TipTapConsentNotice.tsx web/src/routes/PageEditor.tsx | head"
  - id: RE-04-4
    statement: "assertLazyChunks forbidden set covers the WHOLE TipTap tree (@tiptap/* + prosemirror-* + marked); the mermaid-UNIQUE comment on marked is corrected (dual-rooted); npm run build stays green with TipTap absent from the entry chunk"
    verification: "grep -nE '@tiptap|prosemirror|TIPTAP_FORBIDDEN|dual-root' web/vite.config.ts; cd web && npm run build"
  - id: RE-04-5
    statement: "TipTap-mode save goes through the existing revision-checked byte-stable save; a stale-revision save 409s (opens the conflict flow) as in CM6 mode"
    verification: "cd web && npx vitest run src/routes/PageEditor.test.tsx"
```

## Tasks

### Task 1: isLosslessRoundTrip guard (Wave 1, D-02)

<read_first>
- web/src/lib/tiptap/codec.ts:26-43 (parse/roundTrip — compose the guard from these)
- .planning/phases/42-toggle-load-guard-codesplit/42-CONTEXT.md D-02 (tree-equality; warn AND fall back; both entries)
</read_first>

<action>
**1. `web/src/lib/tiptap/guard.ts`** — `isLosslessRoundTrip(md: string): boolean`:
```typescript
import { parse, roundTrip } from "./codec";
import { deepEqual } from "…"; // or a small structural compare (the codec test's helper pattern)
export function isLosslessRoundTrip(md: string): boolean {
  try { return deepEqual(parse(roundTrip(md)), parse(md)); }
  catch { return false; } // a parse/serialize throw = not safe to enter TipTap
}
```
- Tree-equality (informed-consent semantics): modeled-syntax normalization PASSES (semantically
  lossless); only genuine content loss (a construct the bounded slice + preserved tier can't
  round-trip, or a throw) returns false.

**2. `web/src/lib/tiptap/guard.test.ts`** (node env — pure codec logic, no DOM):
- A page that round-trips (prose, headings, lists, a table, `$math$`, `#tag`) → `true`.
- A page with modeled-syntax that normalizes (`* bullet` → `- bullet`) → still `true` (tree-equal;
  informed consent covers the byte reflow).
- A genuinely non-round-trippable input (if one exists after the preserved tier — construct a fixture
  that fails tree-equality, e.g. something the codec drops) → `false`. If NOTHING in-scope fails
  tree-equality (the preserved tier is comprehensive), assert the throw-path returns false + document
  that the guard is a backstop.
</action>

<acceptance_criteria>
- `grep -n 'isLosslessRoundTrip' web/src/lib/tiptap/guard.ts` matches; composes `parse`/`roundTrip`
- `cd web && npx vitest run src/lib/tiptap/guard.test.ts` passes (round-trippable→true, normalization→true, loss/throw→false)
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 2: The 3-way toggle + guarded entry (Wave 1, D-01/D-02, BLK-03/MED-07)

<read_first>
- web/src/routes/PageEditor.tsx:748-774 (renderEditorSurface — tiptap already mounted) + the toolbar toggle button (~:759, currently 2-way live⇄source)
- web/src/stores/editorMode.ts (setMode accepts all three)
</read_first>

<action>
**1. Replace the 2-way toolbar flip with a 3-way mode control** (segmented control or 3-cycle button):
Live / Source / TipTap. `"live"`/`"source"` call `setMode` directly (CM6, unchanged). CM6 `"live"` is
the default.

**2. Guarded TipTap entry (BLK-03 + MED-07).** Selecting `"tiptap"` — on BOTH a fresh open in TipTap
mode AND a mid-session switch — first runs `isLosslessRoundTrip(bodyRef.current)` (the CURRENT live
buffer):
- `true` → set `mode="tiptap"` (mount the surface); show the one-time notice (Task 3) if not dismissed.
- `false` → **warn AND fall back to CM6** (do NOT set `mode="tiptap"`): a visible message like "This
  page has content the rich editor can't preserve — staying in the Markdown editor." Persisted? No —
  it's a per-attempt guard message. TipTap is NOT entered.
- Apply the SAME check when the persisted `mode` is already `"tiptap"` on page open (a page loaded in
  TipTap mode whose content can't round-trip must fall back too — don't mount TipTap on it).

**3. Do NOT touch `runSaver`** — TipTap-mode save already routes through it (surface-agnostic).
</action>

<acceptance_criteria>
- The toolbar exposes a 3-way live/source/tiptap control; CM6 stays default
- Selecting/opening tiptap runs `isLosslessRoundTrip` on the live buffer; false → warn + stay on CM6 (grep the guard call in PageEditor)
- `grep -n 'isLosslessRoundTrip' web/src/routes/PageEditor.tsx` matches (guard wired on entry)
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 3: One-time informed-consent notice (Wave 1, D-03 — the fork resolution)

<read_first>
- 42-CONTEXT.md D-03 (informed consent: once, dismiss-persisted; NOT a byte-delta fallback)
- web/src/stores/editorMode.ts (the localStorage-persist pattern to mirror for the dismissal flag)
</read_first>

<action>
**1. `web/src/components/TipTapConsentNotice.tsx`** — a one-time notice (a dismissible banner shown on
first TipTap entry): "The rich editor normalizes bullet/emphasis/spacing style. Saved changes reflow
those bytes for everyone viewing this page." A "Got it" dismiss persists to localStorage (key e.g.
`okf.tiptap.consent`, mirroring the `editorMode` persist pattern). Once dismissed, never shown again.

**2. Wire it in `PageEditor`:** render the notice when `mode==="tiptap"` AND the consent flag is not
set. Dismiss sets the flag. Do NOT block editing (it's informed consent, not a gate) and do NOT re-show
per save. This is the ENTIRE fork resolution — no byte-delta behavior (option ii was rejected).
</action>

<acceptance_criteria>
- `grep -nE 'consent|normaliz|localStorage' web/src/components/TipTapConsentNotice.tsx` matches
- The notice shows on first TipTap entry, dismiss persists, never re-shown; editing not blocked
- No byte-delta fallback added (the guard stays tree-equality per Task 1)
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 4: Code-split forbidden set — whole TipTap tree (Wave 1, MIN-08/MIN-04)

<read_first>
- web/vite.config.ts:41-58 (MERMAID_FORBIDDEN incl. mislabeled marked :48; FORBIDDEN_IN_ENTRY)
- web/vite-plugins/assertLazyChunks.ts (the guard; forbidden: RegExp[])
</read_first>

<action>
**1. Add a `TIPTAP_FORBIDDEN` array** (or extend the shared set) in `vite.config.ts` asserting the
TipTap tree never reaches the entry chunk:
```typescript
const TIPTAP_FORBIDDEN = [
  /node_modules[\\/]@tiptap[\\/]/,      // core, pm, react, starter-kit, markdown, extension-list, suggestion
  /node_modules[\\/]prosemirror-[a-z-]+[\\/]/,  // via @tiptap/pm
  // marked is shared below (dual-rooted: mermaid@16 + @tiptap/markdown@17)
];
const FORBIDDEN_IN_ENTRY = [ …existing…, ...MERMAID_FORBIDDEN, ...TIPTAP_FORBIDDEN ];
```
**2. Fix the `marked` mislabel:** the "mermaid-UNIQUE" comment (`:37-40`) is now FALSE — `marked` is
dual-rooted (mermaid + `@tiptap/markdown`). Move `marked` out of the mermaid-unique list into a shared
position (or annotate it dual-rooted). It stays forbidden either way; the comment must be accurate.
**3. `npm run build`** must stay green with the extended forbidden set — Phase 41 already lazy-loads
TipTap, so the entry chunk is already tiptap-free; this makes it a build-enforced invariant.
</action>

<acceptance_criteria>
- `grep -nE '@tiptap|prosemirror|TIPTAP_FORBIDDEN' web/vite.config.ts` matches; the marked "mermaid-UNIQUE" comment is corrected
- `cd web && npm run build` exits 0 (assertLazyChunks green — TipTap + prosemirror + marked all absent from the entry chunk)
</acceptance_criteria>

### Task 5: Revision-checked save from TipTap + full verify (Wave 1, D-05)

<action>
**1. Confirm the 409 flow from TipTap mode** — add/extend a `PageEditor.test.tsx` case asserting a
stale-revision save in TipTap mode 409s (opens the conflict flow) exactly as CM6 (TipTap routes through
the same `runSaver`; this is a confirmation, not new code).

**2. Full verify:** `cd web && npx vitest run` (full suite — touches shared `PageEditor` + `vite.config`);
`tsc --noEmit` + `eslint src/` clean; `npm run build` exit 0 with assertLazyChunks green.

**3. SUMMARY:** the 3-way toggle; the tree-equality guard (warn+fall-back, both entries); the one-time
notice (the fork resolution); the forbidden-set extension + the entry-chunk result; the 409 confirmation.
</action>

<acceptance_criteria>
- `cd web && npx vitest run` full suite green (incl. the TipTap-mode 409 test)
- `cd web && npx tsc --noEmit` + `npx eslint src/` exit 0; `npm run build` exit 0
- SUMMARY covers all 5 must_haves with evidence
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** RE-04 turns the Phase-41 surface into a shipped, guarded, opt-in feature.
  1. RE-04-1: a user-selectable 3-way toggle; CM6 default; tiptap routes through the guard.
  2. RE-04-2: `isLosslessRoundTrip` = tree-equality; guard on BOTH entries against the live buffer;
     non-round-trippable → warn AND fall back to CM6 (a guard, not a toast).
  3. RE-04-3: the one-time informed-consent notice (the fork resolution); dismiss-persisted; not a nag;
     NO byte-delta fallback (option ii was rejected — confirm the guard stays tree-equality).
  4. RE-04-4: the forbidden set covers @tiptap/* + prosemirror-* + marked (dual-root comment fixed);
     `npm run build` green, entry chunk tiptap-free (build-enforced).
  5. RE-04-5: TipTap-mode save is revision-checked (409 confirmed).
- **Design-fork honesty check:** the resolution implemented is EXACTLY informed consent (tree-equality
  guard + one-time notice). If the guard were quietly byte-equality, or the notice missing, that's a
  gap — the co-driver's decision must be honored as chosen.
- **v1.6 lesson + build:** full suite (shared `PageEditor`); `npm run build` green with the extended
  `assertLazyChunks` proving the whole TipTap tree stays out of the entry chunk.
