---
phase: 22-model-b-materialization-tag-ui
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [web/src/lib/cm/hashtag.ts, web/src/lib/cm/markdown.ts, web/src/lib/cm/hashtag.test.ts, web/src/lib/tag-reconcile.ts, web/src/lib/tag-reconcile.test.ts, web/src/routes/PageEditor.tsx, web/src/routes/PageEditor.test.tsx, web/src/components/TagBadgeBar.tsx, web/src/components/TagBadgeBar.css, web/src/components/TagBadgeBar.test.tsx, web/src/components/InlineTagEditor.tsx, web/src/components/InlineTagEditor.css, web/src/components/InlineTagEditor.test.tsx, web/src/lib/cm/livePreview.test.ts]
autonomous: true
requirements: [ED-02b, ED-02c]

must_haves:
  truths:
    - "inlineTagsFromBody runs the SHARED Lezer parser (same extensions as render) and is code-region-aware"
    - "markdown.ts and inlineTagsFromBody consume ONE shared extension list (no parser fork)"
    - "On save, reconcile is a DIFF: removedInline = inline(lastSavedBody) - inline(currentBody)"
    - "frontmatter.tags = (priorFrontmatterTags - removedInline) UNION inline(currentBody)"
    - "Adding #deploy to body adds 'deploy' to frontmatter; deleting it removes it; an LLM-only tag persists"
    - "Reconcile is byte-stable: setTags is called ONLY when the tag set actually changed"
    - "Reconcile runs before the PUT in runSaver, on sentBody/sentFrontmatter, against lastSavedBody"
    - "Search/graph/sweep unchanged — no indexer change; server re-validates tags on save"
    - "InlineTagEditor chip field removed from PageEditor and the orphaned component deleted"
    - "TagBadgeBar shows effective tags; non-inline tags get an X (byte-stable removal), inline-origin tags do not"
    - "A golden test does add + remove + LLM-tag-persists in one save"
---

<objective>
Materialize body `#tags` into canonical frontmatter `tags:` on save — client-side, as a diff
(add AND remove), reusing the Phase-21 Lezer parse (no second parser, no indexer change) — and
replace the `InlineTagEditor` chip field with a minimal read-only badge bar whose ✕ removes only
non-inline tags. This carries the milestone's load-bearing decision and unblocks Phase 23.
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md §ED-02b §ED-02c
@.planning/phases/22-model-b-materialization-tag-ui/22-CONTEXT.md
@.planning/phases/21-inline-tag-parser-badge-render/21-VERIFICATION.md
</context>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: inlineTagsFromBody via the shared Lezer parser + markdown.ts refactor</name>
  <files>web/src/lib/cm/hashtag.ts, web/src/lib/cm/markdown.ts, web/src/lib/cm/hashtag.test.ts</files>
  <read_first>
    - web/src/lib/cm/hashtag.ts — hashtagExtension, parseInlineTags (the scanner being retired from materialize)
    - web/src/lib/cm/markdown.ts — the extensions array [Table, Strikethrough, TaskList, Autolink, hashtagExtension]
    - web/node_modules/@lezer/markdown/dist/index.d.ts — `parser`, `MarkdownParser.configure`, `parse(text): Tree` (AUTHORITATIVE)
  </read_first>
  <behavior>
    Single source of truth for the parser extension set + a code-region-aware tag extractor.
    - In `hashtag.ts`, export `tagParserExtensions = [Table, Strikethrough, TaskList, Autolink, hashtagExtension]`
      (import the GFM bits from "@lezer/markdown"; hashtag.ts must NOT import markdown.ts → no cycle).
    - Build a standalone `tagParser = parser.configure(tagParserExtensions)` and export
      `inlineTagsFromBody(body: string): string[]` that parses the body and collects `Hashtag`
      nodes (lower-cased, de-duped, original-order-insensitive). Because it is the SAME parser the
      render uses, it is code-region/heading/escape-aware by construction — it will NOT pick up a
      `#tag` inside fenced/inline code or after a non-boundary char.
    - Refactor `markdown.ts` to import `tagParserExtensions` from "./hashtag" and pass it to
      `markdown({ base: commonmarkLanguage, extensions: tagParserExtensions })` — so the CM6 render
      and `inlineTagsFromBody` can never fork. Keep the existing explanatory comment.
    - Retire `parseInlineTags` from the materialize path. If no live (non-test) consumer remains,
      delete it and its tests; if kept, leave its scoped-contract comment. (Grep to decide.)
  </behavior>
  <action>
    ```ts
    // hashtag.ts (additions)
    import { parser, Table, Strikethrough, TaskList, Autolink } from "@lezer/markdown";

    export const tagParserExtensions = [Table, Strikethrough, TaskList, Autolink, hashtagExtension];
    const tagParser = parser.configure(tagParserExtensions);

    export function inlineTagsFromBody(body: string): string[] {
      const tree = tagParser.parse(body);
      const out = new Set<string>();
      const cur = tree.cursor();
      do { if (cur.name === "Hashtag") out.add(body.slice(cur.from + 1, cur.to).toLowerCase()); }
      while (cur.next());
      return [...out];
    }
    ```
  </action>
  <verify>
    `hashtag.test.ts`: `inlineTagsFromBody` returns `['deploy']` for `"see #deploy"`, `[]` for
    a fenced block containing `#x` and for inline `` `#code` `` (the code-region cases the old
    scanner got WRONG — this is the divergence Phase 21 pinned, now CLOSED), `['a/b']` for `"#a/b"`,
    `[]` for `"#1984"`/`"foo#bar"`/`"\\#esc"`, and de-dupes `"#deploy #deploy"` → `['deploy']`.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 2: tag reconciliation engine (pure) — web/src/lib/tag-reconcile.ts</name>
  <files>web/src/lib/tag-reconcile.ts, web/src/lib/tag-reconcile.test.ts</files>
  <read_first>
    - web/src/lib/tag-frontmatter.ts — readTags / setTags (byte-stable block rewrite; no normalization)
    - web/src/lib/cm/hashtag.ts — inlineTagsFromBody (Task 1)
  </read_first>
  <behavior>
    A pure, UI-free reconcile so it can be golden-tested without React. Export
    `reconcileFrontmatterTags(args: { lastSavedBody: string; currentBody: string; frontmatter: string }):
    { frontmatter: string; changed: boolean }`:
    - `prevInline = inlineTagsFromBody(lastSavedBody)`, `curInline = inlineTagsFromBody(currentBody)`.
    - `removedInline = prevInline − curInline` (case-insensitive set difference).
    - `reconciled = normalize( (readTags(frontmatter) − removedInline) ∪ curInline )` where
      `normalize` = trim, lower-case, case-insensitive de-dupe, stable order (keep frontmatter order
      then appended inline order — deterministic for byte-stability).
    - `changed = !sameSet(readTags(frontmatter), reconciled)` (case-insensitive).
    - Return `{ frontmatter: changed ? setTags(frontmatter, reconciled) : frontmatter, changed }`.
    NOTE: NOT a monotonic union (removals must work). The LLM-only tag (in frontmatter, never inline)
    is preserved because it is in `readTags` and not in `removedInline`.
  </behavior>
  <verify>
    `tag-reconcile.test.ts` — THE GOLDEN TEST (criterion 1/5) in one reconcile call:
    `lastSavedBody="#old #keepllm-source"`-style fixtures proving, in a single call: (a) adding
    `#deploy` to the body adds `deploy`; (b) removing a previously-inline `#old` removes `old`;
    (c) an `llm` tag present in frontmatter but never inline PERSISTS. Plus: unchanged set →
    `changed=false` and frontmatter returned BYTE-IDENTICAL (no setTags churn); case-collision
    (`#Deploy` body vs `deploy` frontmatter) → one tag.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 3: weave reconcile into runSaver (PageEditor.tsx) + integration test</name>
  <files>web/src/routes/PageEditor.tsx, web/src/routes/PageEditor.test.tsx</files>
  <read_first>
    - web/src/routes/PageEditor.tsx — runSaver (208–305): the `sentBody`/`sentFrontmatter` capture
      (228–229), the `savePage` PUT (232–236), the post-success advance of lastSavedBody/
      lastSavedFrontmatter/baseRevision (286–288). The refs at 121–135.
    - web/src/routes/PageEditor.conflict.test.tsx + PageEditor.test.tsx — the save-test harness/mocks (savePage/getPage)
  </read_first>
  <behavior>
    Insert the reconcile BETWEEN the `sentFrontmatter` capture and the `savePage` call:
    ```ts
    const sentBody = bodyRef.current;
    let sentFrontmatter = frontmatterRef.current;        // was const — now reassignable
    const rec = reconcileFrontmatterTags({
      lastSavedBody: lastSavedBody.current, currentBody: sentBody, frontmatter: sentFrontmatter,
    });
    sentFrontmatter = rec.frontmatter;
    if (rec.changed) { setFrontmatter(sentFrontmatter); frontmatterRef.current = sentFrontmatter; }
    await savePage(path, { body: sentBody, frontmatter: sentFrontmatter, base_revision: baseRevision.current });
    ```
    `lastSavedFrontmatter.current = sentFrontmatter` already runs on success (288) — now it stores the
    reconciled value, so the next loop's `frontmatterRef !== lastSavedFrontmatter` check is correct and
    does not re-fire. Reconcile uses `lastSavedBody.current` (the PRIOR saved body, advanced only at
    287) — exactly the diff window ED-02b specifies. Do not change conflict/409/lock logic.
  </behavior>
  <verify>
    `PageEditor.test.tsx` (mock savePage/getPage): typing a body containing `#deploy` and triggering
    a save calls `savePage` with a frontmatter whose `tags:` contains `deploy`; then removing
    `#deploy` and saving calls `savePage` with `tags:` NOT containing `deploy`; an LLM/frontmatter-only
    tag stays across both. A save with no tag change sends byte-identical frontmatter (no spurious
    rewrite → no 409 thrash). Reuse the existing save-test mocks.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 4: TagBadgeBar component (replace InlineTagEditor) + tests</name>
  <files>web/src/components/TagBadgeBar.tsx, web/src/components/TagBadgeBar.css, web/src/components/TagBadgeBar.test.tsx</files>
  <read_first>
    - web/src/components/InlineTagEditor.tsx — the props/shape it replaces (pagePath, tags, onTagsChange)
    - web/src/lib/cm/hashtag.ts — inlineTagsFromBody; web/src/lib/cm/theme.ts — `.cm-hashtag` pill tokens to echo
  </read_first>
  <behavior>
    `TagBadgeBar({ body, frontmatter, onRemoveTag }: { body: string; frontmatter: string;
    onRemoveTag: (tag: string) => void })`. Compute `inline = inlineTagsFromBody(body)`,
    `fm = readTags(frontmatter)`, `effective = union(inline, fm)` (display order: fm order then
    inline-only appended; de-duped case-insensitive). Render each as a small badge (echo the
    `.cm-hashtag` pill look). A tag is **inline-origin** iff it ∈ `inline` → render WITHOUT an ✕
    (a note/title explains it is managed by editing `#tag` in the body). A **non-inline** tag
    (`fm − inline`) renders WITH an ✕ button (`aria-label={`Remove tag ${tag}`}`) calling
    `onRemoveTag(tag)`. Read-only otherwise (no text input, no autocomplete — that surface is gone).
    All text via JSX text nodes (no innerHTML).
  </behavior>
  <verify>
    `TagBadgeBar.test.tsx`: given body `"#inline"` and frontmatter tags `[inline, llm]`, both badges
    render; only `llm` has a Remove button; clicking it calls `onRemoveTag("llm")`; `inline` has no
    Remove control. Empty state renders nothing/quiet.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 5: wire TagBadgeBar into PageEditor, delete orphaned InlineTagEditor, close Phase-21 divergence tests</name>
  <files>web/src/routes/PageEditor.tsx, web/src/components/InlineTagEditor.tsx, web/src/components/InlineTagEditor.css, web/src/components/InlineTagEditor.test.tsx, web/src/lib/cm/livePreview.test.ts</files>
  <read_first>
    - web/src/routes/PageEditor.tsx — the InlineTagEditor import (29) + usage (644–655) + onTagsChange byte-stable write pattern
    - web/src/lib/cm/livePreview.test.ts — the "render⇄scanner divergence" test (Phase 21) now resolved by inlineTagsFromBody
  </read_first>
  <behavior>
    - Replace the `<InlineTagEditor .../>` block (644–655) with `<TagBadgeBar body={body}
      frontmatter={frontmatter} onRemoveTag={(tag) => { const next = setTags(frontmatter,
      readTags(frontmatter).filter(t => t.toLowerCase() !== tag.toLowerCase())); setFrontmatter(next);
      frontmatterRef.current = next; setSaveState("idle"); }} />`. Remove the `InlineTagEditor` import.
    - Delete `InlineTagEditor.tsx` + `.css` + `.test.tsx` (re-grep first: it is imported only by
      PageEditor — confirm zero remaining consumers, then delete; the code-reviewer/minimalism gate verifies).
    - Update/retire the Phase-21 "render⇄scanner divergence" and `parseInlineTags` "KNOWN scope gap"
      tests: that gap is CLOSED for the materialize path now that materialization uses the Lezer
      `inlineTagsFromBody`. Either delete those specific divergence assertions or convert them to
      assert `inlineTagsFromBody` now agrees with the render (preferred — it documents the fix).
  </behavior>
  <verify>
    `npx vitest run` (full web) green; no orphan import of InlineTagEditor remains
    (`grep -r InlineTagEditor src` returns nothing). `npm run build` clean.
  </verify>
</task>

</tasks>

<success_criteria>
1. Save computes `removedInline = inline(lastSavedBody) − inline(currentBody)` and
   `tags = (priorFrontmatter − removedInline) ∪ inline(currentBody)` — verified by the golden
   reconcile test doing add + remove + LLM-tag-persists in one save (`tag-reconcile.test.ts`),
   and by the PageEditor save-integration test.
2. After saving a body with `#deploy`, the saved frontmatter `tags:` contains `deploy`; the page
   is found by tag search / graph shared-tag for `deploy` with NO indexer change (frontmatter is
   the only sink; structurally unchanged readers).
3. Deleting `#deploy` from the body and saving removes it from frontmatter; an LLM-applied tag
   never written inline persists and shows an ✕ in the badge bar; an inline-origin tag has no ✕.
4. The old `InlineTagEditor` chip field is gone (and the component deleted); the editor has no
   regression; round-trip stays byte-stable (reconcile is a no-op rewrite when the set is unchanged).
5. `inlineTagsFromBody` is code-region-aware (shares the render's parser) — the Phase-21
   render⇄scanner divergence is closed and re-pinned as agreement. `npx vitest run` (full web) and
   `npm run build` both pass.
</success_criteria>
