---
phase: 21-inline-tag-parser-badge-render
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/widgets.ts, web/src/lib/cm/livePreview.ts, web/src/lib/cm/theme.ts, web/src/lib/cm/hashtag.test.ts, web/src/lib/cm/widgets.test.ts, web/src/lib/cm/livePreview.test.ts]
autonomous: true
requirements: [ED-02a]

must_haves:
  truths:
    - "A @lezer/markdown inline parser defines a Hashtag node composed into markdownExtension"
    - "Tag grammar: starts only after start-of-section or whitespace; body [A-Za-z0-9_/-]+ with >=1 non-digit; nested #a/b allowed"
    - "Negative corpus excluded: fenced code, inline code, ATX heading marker, /#frag, #1984, \\#esc, foo#bar"
    - "Positive corpus badges: #abc, #fff (hex-like), #deploy, #a/b (nested)"
    - "#tag renders as a badge widget in live preview AND read (cleanRender) mode"
    - "Active editing line and source mode show the raw #tag (reveal via existing isActive)"
    - "HashtagWidget DOM is createElement + textContent only (no innerHTML); class cm-hashtag"
    - "Decoration-only: zero document transactions; doc.toString() keeps the literal #deploy (byte-stable)"
    - "Hashtag widget is NOT added to atomicRanges (inline text, no cursor trap)"
    - ".cm-hashtag pill styling added to theme.ts using design tokens; layout-neutral"
---

<objective>
Add a `@lezer/markdown` `Hashtag` inline parser + a `HashtagWidget` badge decoration so body
`#tag` tokens render as pills on both CM6 surfaces (live editor + read mode) — decoration-only
and byte-stable. The negative corpus (which `#` is NOT a tag) is as load-bearing as the positive.
No frontmatter writes (Phase 22), no navigation (display-only badge).
</objective>

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

<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md §ED-02a
@.planning/phases/21-inline-tag-parser-badge-render/21-CONTEXT.md
</context>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: Hashtag inline parser (new web/src/lib/cm/hashtag.ts) + unit tests</name>
  <files>web/src/lib/cm/hashtag.ts, web/src/lib/cm/hashtag.test.ts</files>
  <read_first>
    - web/node_modules/@lezer/markdown/dist/index.d.ts — InlineParser, InlineContext (char/slice/elt/addElement/offset/end), MarkdownConfig.defineNodes (AUTHORITATIVE API for v1.6.4; do not recall — read it)
    - web/src/lib/cm/markdown.ts — how GFM extensions compose (the array Hashtag joins)
  </read_first>
  <behavior>
    Export `hashtagExtension: MarkdownConfig` and a pure helper `parseInlineTags(text: string): string[]`
    (reused by Phase 22 — return the de-duped, lower-cased tag names found in a body string).
    Grammar (CONTEXT D-02), the negative corpus is the authority:
    - Fire only when `next === 35` (`#`).
    - **Start boundary:** let `before = pos > cx.offset ? cx.char(pos - 1) : -1`; require
      `before === -1` (section start) OR `before` is whitespace. This single rule excludes
      `foo#bar`, `/#frag`, AND `\#esc` (the `#` there is preceded by a non-space).
    - **Body:** scan `[A-Za-z0-9_/-]` from `pos+1`; track `hasNonDigit` (any of `A-Za-z_-`).
      Stop at the first non-body char or `cx.end`. Treat `/` as an interior separator: do not
      let a tag be only `#` + slashes/digits; trim a trailing `/` off `end` if present.
    - **Reject** when: length is just `#` (end === pos+1), or `!hasNonDigit` (all-digit like
      `#1984`), leaving `end` unmoved → `return -1`.
    - On match: `return cx.addElement(cx.elt("Hashtag", pos, end))`.
    - `defineNodes: ["Hashtag"]`; `parseInline: [{ name: "Hashtag", parse, before: "Link" }]`.
    Code/heading exclusion is structural (inline parsers don't run inside InlineCode/FencedCode;
    the ATX leading `#` is a block HeaderMark) — no extra logic, but it is corpus-pinned in Task 5.
  </behavior>
  <action>
    1. Create `web/src/lib/cm/hashtag.ts`:
    ```ts
    import type { MarkdownConfig, InlineContext } from "@lezer/markdown";

    const HASH = 35; // '#'
    const isWs = (c: number) => c === 32 || c === 9 || c === 10 || c === 13 || c === 12;
    const isBody = (c: number) =>
      (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ||
      c === 95 /*_*/ || c === 45 /*-*/ || c === 47 /*/*/;
    const isNonDigit = (c: number) =>
      (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 45;

    function parseHashtag(cx: InlineContext, next: number, pos: number): number {
      if (next !== HASH) return -1;
      const before = pos > cx.offset ? cx.char(pos - 1) : -1;
      if (before !== -1 && !isWs(before)) return -1; // mid-word / after '/' / after '\'
      let end = pos + 1;
      let hasNonDigit = false;
      while (end < cx.end) {
        const c = cx.char(end);
        if (!isBody(c)) break;
        if (isNonDigit(c)) hasNonDigit = true;
        end++;
      }
      while (end > pos + 1 && cx.char(end - 1) === 47) end--; // trim trailing '/'
      if (end <= pos + 1 || !hasNonDigit) return -1;
      return cx.addElement(cx.elt("Hashtag", pos, end));
    }

    export const hashtagExtension: MarkdownConfig = {
      defineNodes: ["Hashtag"],
      parseInline: [{ name: "Hashtag", parse: parseHashtag, before: "Link" }],
    };

    // parseInlineTags — pure extraction reused by Phase 22 (no CM dependency).
    // Returns de-duped, lower-cased tag names ('#deploy' -> 'deploy'). Mirrors the
    // parser's boundary + body rules so client materialization can't drift from render.
    export function parseInlineTags(text: string): string[] { /* same scan over a string */ }
    ```
    2. Implement `parseInlineTags` by scanning the raw string with the SAME boundary/body rules
       (start-of-string or whitespace before `#`; body `[A-Za-z0-9_/-]`, >=1 non-digit; trailing
       `/` trimmed), pushing `text.slice(start+1, end).toLowerCase()` into a `Set`. NOTE: this
       string scan does not exclude fenced/inline-code regions — that is acceptable for Phase 21
       (the function is unused in render; Phase 22 will reconcile its scope). Mark with a comment.
  </action>
  <verify>
    `web/src/lib/cm/hashtag.test.ts` (vitest, pure — no EditorView): assert `parseInlineTags`
    returns `['deploy']` for `"see #deploy"`, `['a/b']` for `"#a/b"`, `['abc','fff']` for
    `"#abc #fff"`, and `[]` for each of `"#1984"`, `"foo#bar"`, `"a/#frag"`, `"\\#esc"`, `"#"`.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 2: HashtagWidget (web/src/lib/cm/widgets.ts) + widget DOM test</name>
  <files>web/src/lib/cm/widgets.ts, web/src/lib/cm/widgets.test.ts</files>
  <read_first>
    - web/src/lib/cm/widgets.ts — BulletWidget / ImageWidget (the createElement+textContent template, eq, ignoreEvent)
    - web/src/lib/cm/widgets.test.ts — how widget DOM is asserted (tagName / textContent / className)
  </read_first>
  <behavior>
    Add `HashtagWidget extends WidgetType`. Constructor `(readonly tag: string)` where `tag` is the
    rendered label WITHOUT the leading `#` (e.g. `"deploy"`). `toDOM`: `createElement("span")`,
    `className = "cm-hashtag"`, `textContent = "#" + this.tag` (Obsidian-parity display; the `#` is
    a literal string, the tag is the only dynamic part and goes through textContent — never
    innerHTML). `eq(other)` compares `this.tag === other.tag`. `ignoreEvent()` returns `false`
    (a click places the cursor → active-line reveal).
  </behavior>
  <verify>
    In `widgets.test.ts`: a `HashtagWidget("deploy").toDOM()` is a `SPAN`, `className === "cm-hashtag"`,
    `textContent === "#deploy"`, and asserts there is NO innerHTML assignment by checking the node has
    a single text child (mirror the existing widget DOM assertions; never assert on an HTML string).
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 3: Hashtag render case in livePreview.ts (buildDecorations)</name>
  <files>web/src/lib/cm/livePreview.ts</files>
  <read_first>
    - web/src/lib/cm/livePreview.ts — the buildDecorations switch; the `Image` case (inline replace
      widget with active-line reveal via rangeActive/isActive); the import block from "./widgets"
  </read_first>
  <behavior>
    Import `HashtagWidget`. Add a `case "Hashtag":` to the `enter` switch. Emit
    `Decoration.replace({ widget: new HashtagWidget(tag) })` over `[node.from, node.to)` UNLESS
    `isActive(node.from)` (reveal raw `#tag` on the active editing line). Derive `tag` from the doc
    slice: `view.state.doc.sliceString(node.from + 1, node.to)` (strip the leading `#`). Do NOT push
    the widget range into `widgetRanges` (hashtags are inline text — NOT atomic; keep the cursor
    free, Pitfall 1). `return false` is unnecessary (Hashtag has no children to descend), but the
    case must not fall through to other handling.
    Because read mode keeps `activeLines` empty, the badge shows in clean render automatically; in
    edit mode it reveals to raw on the caret line — single code path, both surfaces.
  </behavior>
  <verify>Covered by the Task 5 corpus (decorationsFor harness) — widget present off-line, raw revealed on-line, present in clean mode.</verify>
</task>

<task type="implement" tdd="false">
  <name>Task 4: .cm-hashtag pill styling (web/src/lib/cm/theme.ts)</name>
  <files>web/src/lib/cm/theme.ts</files>
  <read_first>
    - web/src/lib/cm/theme.ts — the `.cm-inline-code` rule (code-bg + radius-sm + xs padding) as the pill template; the design-token CSS vars in use
  </read_first>
  <behavior>
    Add a `.cm-hashtag` rule: subtle pill — `var(--color-accent)` text on a faint accent/code-bg,
    `borderRadius: var(--radius-sm)`, xs horizontal padding, inherit font-size and NOT alter
    line-height (layout-neutral reveal, Pitfall 2). `cursor: text` (it is editor content, not a
    button — no navigation in Phase 21). Match the existing theme object's structure/formatting.
  </behavior>
  <verify>`npm run build` (web) succeeds; visual confirmation deferred to phase UAT.</verify>
</task>

<task type="implement" tdd="true">
  <name>Task 5: Hashtag corpus fixtures (web/src/lib/cm/livePreview.test.ts)</name>
  <files>web/src/lib/cm/livePreview.test.ts</files>
  <read_first>
    - web/src/lib/cm/livePreview.test.ts — the `decorationsFor(doc, selectionAt?, {clean})` harness and FlatDeco assertions; how the existing image/widget cases are (or would be) asserted
  </read_first>
  <behavior>
    Add a `describe("Hashtag badges (ED-02a)")` block using `decorationsFor`. Assert a Hashtag
    widget decoration (a `FlatDeco` whose `widget instanceof HashtagWidget`) IS produced for the
    positive corpus and is ABSENT for the negative corpus. Pin reveal + byte-stability.
    POSITIVE (widget present; clean mode, no selection): `#deploy`, `#abc`, `#fff`, `#a/b`.
    NEGATIVE (NO Hashtag widget): `` `#code` `` (inline code), a fenced block containing `#x`,
    `# Heading` (ATX marker), `see /#frag`, `#1984`, `\#esc`, `foo#bar`.
    REVEAL: with `#deploy` and the selection placed on its line (no clean), assert the Hashtag
    widget is dropped (raw shown). BYTE-STABLE: after building decorations, assert the
    `EditorState.doc.toString()` still equals the input (still contains the literal `#deploy`).
    Import `HashtagWidget` from "./widgets" for the `instanceof` check.
  </behavior>
  <verify>`npx vitest run web/src/lib/cm/` is green; the negative corpus all assert widget-absent.</verify>
</task>

</tasks>

<success_criteria>
1. Typing `#deploy` shows a `#deploy` badge in live preview and read mode; the saved `.md` still
   contains the literal `#deploy` (byte-stable — corpus `doc.toString()` assertion + Go
   `TestGoldenRoundTrip` untouched because no transaction is issued).
2. Every negative-corpus case (`` `#code` ``, fenced `#x`, `# Heading`, `/#frag`, `#1984`, `\#esc`,
   `foo#bar`) produces NO Hashtag widget; every positive case (`#abc`, `#fff`, `#deploy`, `#a/b`)
   produces one — all fixture-pinned in `livePreview.test.ts` + the pure `hashtag.test.ts`.
3. Source mode and the active editing line show the raw `#tag`; the badge DOM is `createElement` +
   `textContent` only (`widgets.test.ts`), and the widget is not atomic.
4. `npm run build` (web) and `npx vitest run web/src/lib/cm/` both pass; no editor regression in the
   existing livePreview/widgets/mode suites.
</success_criteria>
