---
author: architect
responsible: architect
phase: 41
phase_type: implementation
status: Ready for execution
hard_bar: true
security_relevant: true
design_fork: false
wave: 1
depends_on: [38, 39, 40]
files_modified:
  - web/src/lib/tiptap/tagNode.ts
  - web/src/lib/tiptap/suggestion.ts
  - web/src/lib/tiptap/preserved.ts
  - web/src/components/TipTapEditor.tsx
  - web/src/components/TipTapEditor.test.tsx
  - web/src/api/client.ts
  - web/src/routes/PageEditor.tsx
  - web/src/lib/tiptap/codec.corpus.ts
  - web/src/lib/tiptap/codec.test.ts
requirements: [RE-03]
autonomous: true
---

# Phase 41: TipTap Editor — #tag + Suggestions + Slash + Real Save - Plan

**Goal:** A live `@tiptap/react` editor surface (implementing the Phase-40 `EditorSurfaceHandle`) with
a `#tag` badge node, `#`-vocabulary suggestions, `/`-slash block commands, materialization through the
EXISTING Lezer save path, and hardened paste + popup surfaces. Fold the Phase-39 P3 math-guard.

**Requirements:** RE-03 · **SECURITY: MAJ-05 (paste) + MAJ-03 (popup)**

> **Hard bar (security).** Paste is the milestone's sharpest untrusted surface. A paste-XSS fixture MUST
> prove no dangerous node/attr becomes live. The popup MUST render text via textContent, never innerHTML.
> The Tag node is DISPLAY-only — it serializes to literal `#tag`; tag DATA integrity flows through the
> EXISTING `reconcileFrontmatterTags`→`inlineTagsFromBody` (Lezer). Do NOT build a second materializer.

## must_haves

```yaml
truths:
  - id: RE-03-1
    statement: "A TipTap Tag inline-atom node renders #tag as a badge (textContent, not innerHTML) and serializes back to literal #tag via renderMarkdown; typed/pasted #tag becomes the node via input/paste rules; the recognizer regex matches the Lezer grammar corpus (rejects #1984/foo#bar/bare #/# heading; accepts #a/b/#-1/#_)"
    verification: "grep -nE 'renderMarkdown|nodeInputRule|group: ?.inline.|atom' web/src/lib/tiptap/tagNode.ts; cd web && npx vitest run --environment node src/lib/tiptap/"
  - id: RE-03-2
    statement: "A @tiptap/react surface (useEditor + EditorContent) implements EditorSurfaceHandle.insertAtCursor via forwardRef; value:string in (content+contentType:markdown) / onChange:string out (getMarkdown()); mounted via renderEditorSurface('tiptap') in PageEditor, import-isolated (out of the entry chunk)"
    verification: "grep -nE 'useEditor|EditorContent|useImperativeHandle|getMarkdown|insertContent' web/src/components/TipTapEditor.tsx; grep -n 'tiptap' web/src/routes/PageEditor.tsx"
  - id: RE-03-3
    statement: "#-suggestion offers vocabulary tags (getTagVocabulary via GET /api/v1/tags/vocabulary) inserting a tag node; /-slash inserts bounded-slice blocks (heading/list/code/quote) via existing schema commands; BOTH popups render item text as textContent/React children, NEVER innerHTML (MAJ-03); a markup-bearing vocabulary entry stays inert"
    verification: "grep -nE 'Suggestion|char: ?.#.|char: ?./.|textContent|vocabulary' web/src/lib/tiptap/suggestion.ts; grep -n 'getTagVocabulary\\|tags/vocabulary' web/src/api/client.ts"
  - id: RE-03-4
    statement: "SECURITY (MAJ-05): pasted HTML parses through the bounded-slice schema only AND editorProps.transformPastedHTML scrubs script/iframe/object/embed + event-handler attrs + javascript: URLs; a paste-XSS fixture (<img onerror>, <script>, <div onclick>) yields no live node/executable attr in the doc/serialized output"
    verification: "grep -n 'transformPastedHTML' web/src/components/TipTapEditor.tsx; cd web && npx vitest run src/components/TipTapEditor.test.tsx"
  - id: RE-03-5
    statement: "Materialization (BLK-01): body #tags typed in TipTap serialize to literal #tag and materialize through the EXISTING reconcileFrontmatterTags->inlineTagsFromBody (Lezer) — proven by a test feeding a TipTap-serialized body through the real reconcile, matching CM6's result; no second materializer"
    verification: "cd web && npx vitest run src/components/TipTapEditor.test.tsx"
  - id: RE-03-6
    statement: "P3 fold: the inline-math tokenizer no longer captures two-dollar prose ($5 and $10 stays literal) via the pandoc no-space-adjacent-to-$ rule; real inline math ($x_1$, $a + b$) still captured; Tier-2 byte-exact gate stays green; a prose-currency fixture proves it"
    verification: "cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts"
```

## Tasks

### Task 1: Tag inline node + codec round-trip (Wave 1, D-01)

<read_first>
- agent://TipTapEditorResearch Q3 (Node.create tag: renderHTML text hole / renderMarkdown `#${name}` / nodeInputRule / addPasteRules)
- web/src/lib/tiptap/preserved.ts (PreservedInline — the inline-atom + textContent + renderMarkdown pattern to mirror)
- web/src/lib/cm/hashtag.ts (inlineTagsFromBody + the recognizer grammar the regex must match)
</read_first>

<action>
**1. `web/src/lib/tiptap/tagNode.ts`** — `Tag = Node.create({ name:"tag", group:"inline", inline:true,
atom:true, addAttributes:()=>({name:{default:""}}), parseHTML:()=>[{tag:"span[data-tag]", getAttrs}],
renderHTML({node})=>["span",{"data-tag":node.attrs.name, class:"tag-badge"}, `#${node.attrs.name}`],
renderMarkdown:(node)=>`#${node.attrs.name}`, addInputRules(){return [nodeInputRule({find:TAG_RE,
type:this.type, getAttributes:m=>({name:m[1].slice(1)})})]}, addPasteRules(){…same regex…} })`.
- `TAG_RE` mirrors the Lezer grammar (≥1 non-digit, `[A-Za-z0-9_/-]`, word-start boundary; reject
  all-digit/mid-word/bare/heading). Reuse `hashtag.ts` predicates where possible.
- Register `Tag` in `buildExtensions()` (or in the live surface's extension list — but adding it to
  `buildExtensions` keeps codec + editor identical; verify it does NOT break the Phase-38 corpus:
  a `#tag` in a Tier-1 fixture now becomes a tag node that serializes back to `#tag` — round-trip must
  still hold. If adding to buildExtensions perturbs Tier-1, add `Tag` ONLY to the live surface's
  extensions and keep the headless codec tag-free — planner decides; the round-trip corpus is the check).

**2. Codec round-trip for the Tag node:** add a corpus fixture (`#tag` in prose) asserting
`roundTrip("a #tag here")` keeps `#tag` literal (tree-equality/idempotence). The node serializes to
`#tag`, so the Lezer materializer sees it on save.
</action>

<acceptance_criteria>
- `grep -nE 'renderMarkdown|nodeInputRule' web/src/lib/tiptap/tagNode.ts` matches; renderMarkdown returns `#${...}`
- `cd web && npx vitest run --environment node src/lib/tiptap/` — Tier-1 + Tier-2 + the new tag fixture pass (round-trip intact)
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 2: The @tiptap/react surface implementing EditorSurfaceHandle (Wave 1, D-04/D-06)

<read_first>
- agent://TipTapEditorResearch Q1/Q2/Q7 (useEditor + EditorContent; getMarkdown; insertContent(md,{contentType}); StrictMode-safe teardown) + the minimal sketch
- web/src/components/LivePreviewEditor.tsx:47-107 (EditorSurfaceHandle + the CM6 reference impl to match value/onChange/insertAtCursor semantics)
- web/src/routes/PageEditor.tsx:733-752 (renderEditorSurface — wire the "tiptap" branch)
</read_first>

<action>
**1. `web/src/components/TipTapEditor.tsx`** — `forwardRef<EditorSurfaceHandle, LivePreviewEditorProps>`:
```tsx
const editor = useEditor({
  extensions: [ ...buildExtensions(), Tag, TagSuggest, SlashCommands ],
  content: value, contentType: "markdown", editable: !readOnly,
  editorProps: { transformPastedHTML: sanitizePastedHtml },
  onUpdate: ({ editor }) => onChange(editor.getMarkdown()),
});
useImperativeHandle(ref, () => ({
  insertAtCursor(text) { editor?.chain().focus().insertContent(text, { contentType: "markdown" }).run(); }
}), [editor]);
return <EditorContent editor={editor} />;
```
- Same props as `LivePreviewEditor` (value/onChange/currentPath/mode/readOnly) so it's a drop-in surface.
- Include the Markdown extension (via `buildExtensions()`) so `getMarkdown()` exists.
- **Import-isolate (D-06):** keep the TipTap imports behind THIS module. In `PageEditor`, wire
  `renderEditorSurface("tiptap")` to mount `<TipTapEditor>` — via `React.lazy(() => import("../components/TipTapEditor"))`
  (recommended, banks Phase-42 code-split isolation) so TipTap stays out of the entry chunk. The
  `"tiptap"` branch is still NOT user-selectable (no UI sets the mode; Phase 42 adds it) — but the
  branch now mounts the real surface for when a dev/test sets `"tiptap"`.

**2. `renderEditorSurface` "tiptap" branch:** replace today's CM6-fallback with the lazy TipTap surface
(wrapped in `<Suspense>`). Passes `ref={surfaceRef}` + the same props. `"live"/"source"` stay CM6.
</action>

<acceptance_criteria>
- `grep -nE 'useEditor|EditorContent|useImperativeHandle|getMarkdown|insertContent' web/src/components/TipTapEditor.tsx` matches
- `grep -n 'lazy\|TipTapEditor' web/src/routes/PageEditor.tsx` shows the lazy-mounted "tiptap" branch
- `cd web && npx tsc --noEmit` exits 0; `npm run build` exits 0 with `assertLazyChunks` GREEN (TipTap not in entry chunk)
</acceptance_criteria>

### Task 3: #-suggestion + /-slash via @tiptap/suggestion (Wave 1, D-02, MAJ-03)

<read_first>
- agent://TipTapEditorResearch Q4/Q5 (Suggestion config; developer-supplied render() popup; command; the slash chain commands)
- web/src/components/TagSuggest.tsx:442-443 (the textContent-not-innerHTML locked guard to match)
- web/src/api/client.ts (add getTagVocabulary(); pattern: a GET fetch like the existing reads)
</read_first>

<action>
**1. `getTagVocabulary()` in `client.ts`** — `GET /api/v1/tags/vocabulary` (any-authed), returns the
sorted tag list. Mirror an existing GET fetch (credentials:"same-origin", hidden-infra-safe error copy).

**2. `web/src/lib/tiptap/suggestion.ts`** — two Extensions with `addProseMirrorPlugins` → `Suggestion(...)`:
- `TagSuggest` extension: `char:"#"`, `items:({query})=>vocab.filter(t=>t.toLowerCase().startsWith(query.toLowerCase()))`,
  `command:({editor,range,props})=>editor.chain().focus().deleteRange(range).insertContent({type:"tag",attrs:{name:props}}).run()`,
  `render:()=>({ onStart, onUpdate, onKeyDown, onExit })` mounting a developer-built popup element.
- `SlashCommands` extension: `char:"/"`, items = heading/bullet-list/ordered-list/code-block/blockquote,
  `command` runs `editor.chain().focus().deleteRange(range).<setNode|toggle…>().run()` — EXISTING
  schema nodes only (no new schema node).
- **MAJ-03:** in the popup `render()`, set every item's tag/label via `el.textContent = …` / React
  children — NEVER `el.innerHTML`. (Vocabulary comes from the workspace; a tag could contain markup —
  it must stay inert text.)
- Vocabulary is fetched once (or per-open) and passed into the `#`-suggest items closure.
</action>

<acceptance_criteria>
- `grep -n 'getTagVocabulary\|tags/vocabulary' web/src/api/client.ts` matches
- `grep -nE 'Suggestion|char: ?"#"|char: ?"/"|textContent' web/src/lib/tiptap/suggestion.ts` matches; `grep -n 'innerHTML' web/src/lib/tiptap/suggestion.ts` returns NO match
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 4: Paste hardening + the live-editor test suite (Wave 1, MAJ-05 + D-04/BLK-01)

<read_first>
- agent://TipTapEditorResearch Q6/Q8 (schema-bound clipboardParser drops unknown nodes; transformPastedHTML scrubs attrs; jsdom test env for the live editor)
- web/src/lib/tag-reconcile.ts:51 (reconcileFrontmatterTags — the real materializer the BLK-01 test feeds)
</read_first>

<action>
**1. `sanitizePastedHtml(html)`** (in `TipTapEditor.tsx` or a helper) — strip `<script>/<iframe>/<object>/<embed>`,
event-handler attrs (`on\w+="…"`/`on\w+='…'`), and `javascript:` URLs. Wired as
`editorProps.transformPastedHTML`. (Layer 2; Layer 1 = the schema-bound parser drops unknown nodes.)

**2. `web/src/components/TipTapEditor.test.tsx`** (jsdom — the app default env; DO NOT node-env this,
it's a live DOM component): using `@testing-library/react` + `user-event`:
- **RE-03-1/2:** typing `#tag ` creates a `span[data-tag]` badge; the surface's `onChange`/`getMarkdown()`
  output contains literal `#tag`.
- **RE-03-5 (BLK-01 materialization):** take the TipTap-serialized body (from typing `#foo`),
  feed it through `reconcileFrontmatterTags({lastSavedBody:"", currentBody:<tiptap body>, frontmatter:""})`
  and assert it materializes `foo` into frontmatter — the SAME result CM6 produces (proving the surface
  routes through the real Lezer save path, not a divergent materializer).
- **RE-03-4 (MAJ-05 paste-XSS):** simulate a paste of `<img src=x onerror="alert(1)">` / `<script>alert(1)</script>`
  / `<div onclick="x">z</div>`; assert the resulting doc + `getMarkdown()` contain no live script node,
  no `onerror`/`onclick` attribute, and nothing executes (the schema drops the node AND transformPastedHTML
  scrubbed the attr).
- **RE-03-3 (MAJ-03 popup):** open the `#`-suggest with a vocabulary entry bearing markup
  (`<img onerror>`) and assert the popup renders it as inert text (the popup element's textContent
  contains the literal string; no child element / no executed handler).

**3. Run:** `cd web && npx vitest run src/components/TipTapEditor.test.tsx` (jsdom).
</action>

<acceptance_criteria>
- `grep -n 'transformPastedHTML' web/src/components/TipTapEditor.tsx` matches
- `cd web && npx vitest run src/components/TipTapEditor.test.tsx` — all pass (typing→badge, real-save materialization, paste-XSS inert, popup inert)
- The paste-XSS test asserts BOTH no live node AND no executable attr; the materialization test uses the REAL reconcileFrontmatterTags
</acceptance_criteria>

### Task 5: Fold the Phase-39 P3 math-guard (Wave 1, D-05)

<read_first>
- web/src/lib/tiptap/preserved.ts (the inline-math tokenizer — the over-matching regex)
- .planning/phases/39-verbatim-preservation-tier/39-VERIFICATION.md (the P3 finding: $5 and $10 → preservedInline atom)
</read_first>

<action>
**1. Tighten the inline-math tokenizer** (`preserved.ts`): apply the pandoc rule — opening `$` NOT
followed by whitespace, closing `$` NOT preceded by whitespace (optionally closing `$` not followed by
a digit). So `$5 and $10` (space before the closing `$`) stays literal prose, while `$x_1$` / `$a + b$`
stay math. Keep `.raw` exact.

**2. Corpus fixtures:** add a prose-currency fixture (`It costs $5 and $10` round-trips as literal
prose — NOT captured as a preservedInline atom) and confirm the existing `inlineMath`/`inlineMathTwice`
real-math fixtures still capture. Assert via the parsed tree (the prose case has NO `preservedInline`).
</action>

<acceptance_criteria>
- `It costs $5 and $10` parses with NO preservedInline atom (stays text); real inline math still captured
- `cd web && npx vitest run --environment node src/lib/tiptap/codec.test.ts` — Tier-2 byte-exact + the new prose-currency fixture pass
</acceptance_criteria>

### Task 6: Full verification (Wave 1)

<action>
- `cd web && npx vitest run` (full suite — this touches shared `web/src/lib/tiptap/*`, `PageEditor`,
  `client.ts`; v1.6 lesson: run the full suite).
- `cd web && npx tsc --noEmit` + `npx eslint src/` clean; `npm run build` exit 0 with `assertLazyChunks`
  GREEN (TipTap must NOT be in the entry chunk — the lazy import + import-isolation).
- SUMMARY: the Tag node display-only + literal serialize; the surface + insertAtCursor; #/slash popups
  (textContent); paste-XSS result (both layers); the BLK-01 materialization proof; the P3 math-guard fix;
  and the assertLazyChunks/entry-chunk result (TipTap isolated).
</action>

<acceptance_criteria>
- `cd web && npx vitest run` — full suite green
- `cd web && npx tsc --noEmit` + `npx eslint src/` exit 0; `npm run build` exit 0, entry chunk free of TipTap
- SUMMARY covers all 6 must_haves with evidence
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** RE-03 delivers a live TipTap surface with the app's authoring affordances,
  routing tag data through the ONE Lezer recognizer, with the paste + popup surfaces hardened.
  1. RE-03-1: Tag node display-only, serializes to literal `#tag`, regex matches the Lezer corpus.
  2. RE-03-2: real `@tiptap/react` surface implements `EditorSurfaceHandle`; mounted lazily via
     `renderEditorSurface("tiptap")`; import-isolated (entry chunk free of TipTap).
  3. RE-03-3 (MAJ-03): #/slash popups render text via textContent/React children, never innerHTML.
  4. RE-03-4 (MAJ-05, the security keystone): paste-XSS fixture proves no live node/attr — BOTH the
     schema drop AND transformPastedHTML scrub. This is the milestone security-review focus.
  5. RE-03-5 (BLK-01): materialization runs through the REAL reconcileFrontmatterTags→Lezer over the
     TipTap-serialized body — the test proves it, not a headless-only harness. No second materializer.
  6. RE-03-6 (P3 fold): prose `$5 and $10` stays literal; real math still captured; Tier-2 gate green.
- **Hard-bar (security) honesty check:** the paste-XSS + popup tests must genuinely assert inertness
  (no live node, no executable attr, textContent-only) — not a weakened check. A GO resting on a
  soft assertion is a gap routed UP.
- **Test split:** the live editor is jsdom (a DOM component); the codec no-DOM invariant stays node-env.
- **v1.6 lesson + build:** full suite run; `npm run build` green with `assertLazyChunks` proving TipTap
  stays out of the entry chunk (banks Phase-42 isolation).
