---
author: architect
responsible: architect
phase: 36
phase_type: implementation
status: Ready for execution
hard_bar: false
security_relevant: true
design_fork: false
wave: 1
depends_on: [32]
files_modified:
  - web/src/components/MarkdownProse.tsx
  - web/src/lib/cm/widgets.ts
  - web/src/lib/cm/theme.ts
  - internal/search/doc.go
  - internal/search/rebuild.go
  - internal/search/query.go
  - internal/server/handlers_search.go
  - web/src/api/client.ts
  - web/src/components/search/SearchResultRow.tsx
requirements: [EP-01, EP-02, EP-04]
autonomous: true
---

# Phase 36: Rendering Polish - Plan

**Goal:** Three independent read-surface wins: `#tag` badge in MarkdownProse, copy-code button, description in search cards. **qa MUST verify each independently.**

**Requirements:** EP-01, EP-02, EP-04

## must_haves

```yaml
truths:
  - id: EP-01-1
    statement: "A remark inline transform renders body #tag as a safe badge span in MarkdownProse (HistoryPanel + AgentAnswer), matching the CM6 badge + recognized-#tag set; rehype-raw OFF / rehype-sanitize ON preserved; AgentAnswer stored-XSS test still passes; false-# cases stay literal"
    verification: "cd web && npx vitest run src/components/AgentAnswer.test.tsx; grep 'remarkHashtag\|hashtag' web/src/components/MarkdownProse.tsx"
  - id: EP-02-1
    statement: "A copy-to-clipboard button on the read-mode CodeBlockWidget copies this.code with brief confirmation; built createElement-only (no innerHTML); does not perturb the async highlight swap or destroyed/isConnected teardown"
    verification: "grep 'clipboard\|copy\|Copy' web/src/lib/cm/widgets.ts; cd web && npx vitest run src/lib/cm/widgets.test.ts"
  - id: EP-04-1
    statement: "The search backend indexes + returns the frontmatter description as an additive optional field on search.Result; SearchResultRow renders it for page results; absent description falls back to today's card; no relevance regression"
    verification: "grep 'Description' internal/search/query.go; grep 'description' web/src/components/search/SearchResultRow.tsx"
```

## Tasks

### Task 1: EP-01 — `#tag` badge in MarkdownProse (Wave 1)

<read_first>
- web/src/components/MarkdownProse.tsx (the ReactMarkdown setup :77-84, remarkPlugins :78)
- web/src/lib/cm/hashtag.ts:44-68 (the recognition predicates: isWs/isBody/isNonDigit/scanBody)
- web/src/lib/cm/widgets.ts:335-359 (HashtagWidget — the visual to match: .cm-hashtag class)
- web/src/components/AgentAnswer.tsx (uses MarkdownProse; the stored-XSS test reference)
- web/src/components/AgentAnswer.test.tsx (the XSS test that must still pass)
</read_first>

<action>
**1. Create a remark hashtag plugin** — a small module (either in `MarkdownProse.tsx` or a separate `web/src/lib/remark-hashtag.ts`) that:
- Is a remark plugin (operates on mdast — the Markdown AST before hast).
- Walks text nodes in the mdast tree. For each text node, finds `#tag` patterns using the same boundary rules as `hashtag.ts`:
  - `#` must be at a word-start boundary (preceded by whitespace, start of line, or certain punctuation — NOT inside a word).
  - The tag body is `[A-Za-z0-9_/-]`, with ≥1 non-digit, no spaces.
  - Excludes: text inside `code` nodes (inline code), `\#` escapes (the `#` is preceded by `\`), and ATX heading `#` (the heading marker itself — but this is handled by the mdast structure: heading text is separate from the marker).
- Replaces matched `#tag` text with an mdast `span` node (or a hast element after remark-to-hast) with className `cm-hashtag` and text content `#tag`.
- The plugin is added to `remarkPlugins` in `MarkdownProse.tsx:78`:
  ```typescript
  remarkPlugins={[remarkGfm, remarkHashtag]}
  ```

**2. Verify the XSS guard holds:**
- `rehype-raw` stays OFF (not in the plugins list).
- `rehype-sanitize` stays ON (`:79`).
- The badge is a typed mdast/hast element (span), not raw HTML — rehype-sanitize keeps it (span is in the default schema).
- Run `cd web && npx vitest run src/components/AgentAnswer.test.tsx` — the stored-XSS test must still pass.

**3. Verify false-`#` cases stay literal:**
- `#tag` in inline code (`` `#tag` ``) → stays literal (the text is in a `code` node, not walked).
- `\#tag` → stays literal (escaped `#`).
- `# heading` → the `#` is a heading marker, not a tag.
- `http://example.com/#fragment` → the `#` is in a URL, not a tag (the URL is in a `link` node).
</action>

<acceptance_criteria>
- `grep 'remarkHashtag' web/src/components/MarkdownProse.tsx` returns a match (the plugin is wired in)
- `grep 'rehype-raw' web/src/components/MarkdownProse.tsx` returns NO match (rehype-raw stays OFF)
- `grep 'rehypeSanitize\|rehype-sanitize' web/src/components/MarkdownProse.tsx` returns a match (rehype-sanitize stays ON)
- `cd web && npx vitest run src/components/AgentAnswer.test.tsx` exits 0 (XSS test passes)
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 2: EP-02 — Copy-code button (Wave 1)

<read_first>
- web/src/lib/cm/widgets.ts:121-217 (CodeBlockWidget — toDOM :135-147, highlight :154-206, teardown guards :169)
</read_first>

<action>
**1. Add a copy button to `CodeBlockWidget.toDOM`** (`widgets.ts:135`):
- After creating `pre` + `code`, add a copy button:
  ```typescript
  const btn = document.createElement("button");
  btn.className = "cm-copy-btn";
  btn.textContent = "Copy";
  btn.addEventListener("click", (e) => {
    e.stopPropagation();
    navigator.clipboard.writeText(this.code).then(() => {
      btn.textContent = "Copied";
      setTimeout(() => { btn.textContent = "Copy"; }, 1500);
    }).catch(() => {
      // Graceful fallback: no-op or brief "Failed" state
      btn.textContent = "Failed";
      setTimeout(() => { btn.textContent = "Copy"; }, 1500);
    });
  });
  pre.appendChild(btn);  // or position it relative to pre
  ```
- The button is built with `createElement` + explicit attributes — NO `innerHTML`.
- The button does NOT interfere with the async highlight swap (`highlight()` operates on `code`, not `pre`'s other children) or the `destroyed`/`isConnected` teardown (those guard `codeEl`, not the button).
- Add CSS for `.cm-copy-btn` in `theme.ts` or `MarkdownProse.css` (position it top-right of the code block, small, subtle).

**2. Verify the existing widget tests still pass** — `cd web && npx vitest run src/lib/cm/widgets.test.ts`.
</action>

<acceptance_criteria>
- `grep 'clipboard\|copy\|Copy' web/src/lib/cm/widgets.ts` returns a match (copy button exists)
- `grep 'innerHTML' web/src/lib/cm/widgets.ts` in the CodeBlockWidget section returns NO match (no innerHTML)
- `cd web && npx vitest run src/lib/cm/widgets.test.ts` exits 0 (existing tests pass)
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

### Task 3: EP-04 — Description in search (Wave 1)

<read_first>
- internal/search/doc.go (pageDoc :18-26 — add description)
- internal/search/rebuild.go (indexPage — where pageDoc is called; read the description from frontmatter)
- internal/search/query.go:14-21 (Result struct — add Description field)
- internal/server/handlers_search.go (the search handler — pass description through)
- web/src/components/search/SearchResultRow.tsx (render description for page results)
- web/src/api/client.ts (search result type — add description)
</read_first>

<action>
**1. Add `description` to `pageDoc`** in `internal/search/doc.go:18`:
```go
func pageDoc(pagePath, title, body, description string, tags []string) map[string]interface{} {
	return map[string]interface{}{
		"type":        TypePage,
		"title":       title,
		"body":        body,
		"tags":        tags,
		"page_path":   pagePath,
		"description": description,  // NEW — stored, not necessarily indexed for search
	}
}
```

**2. Update `rebuild.go`** — where `pageDoc` is called, extract the description from the frontmatter (`okf.Parse` → `doc.Frontmatter` → the `description` field). Pass it to `pageDoc`.

**3. Add `Description` to `Result`** in `internal/search/query.go:14`:
```go
type Result struct {
	Kind        string `json:"kind"`
	Title       string `json:"title"`
	Path        string `json:"path"`
	Snippet     string `json:"snippet"`
	Anchor      string `json:"anchor,omitempty"`
	PageTitle   string `json:"page_title,omitempty"`
	Description string `json:"description,omitempty"`  // NEW — page results only
}
```

**4. Populate `Description` in the query results** — when building page results from Bleve hits, read the `description` field from the hit document and set it on the Result.

**5. Update the frontend** — add `description?: string` to the search result type in `client.ts`; render it in `SearchResultRow.tsx` for page results (distinct from the snippet, above or below it, styled as a secondary line). Absent description → no change (falls back to today's card).

**6. Write a test** — seed a page with a frontmatter description, rebuild the index, query → verify the result includes the description.
</action>

<acceptance_criteria>
- `grep 'Description' internal/search/query.go` returns a match (Result has Description field)
- `grep 'description' internal/search/doc.go` returns a match (pageDoc includes description)
- `grep 'description' web/src/components/search/SearchResultRow.tsx` returns a match (renders description)
- `go test ./internal/search/ -count=1` exits 0 (search tests pass)
- `go build ./...` exits 0
- `cd web && npx tsc --noEmit` exits 0
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** Three independent features, each verified independently (red-team MIN-05):
  1. EP-01: `#tag` badges in MarkdownProse (HistoryPanel + AgentAnswer); XSS test passes; false-`#` cases literal.
  2. EP-02: copy button on CodeBlockWidget; no innerHTML; existing widget tests pass.
  3. EP-04: description in search cards; additive; no relevance regression.
- **Security (EP-01):** rehype-raw OFF, rehype-sanitize ON, badge is a React element not raw HTML, AgentAnswer XSS test passes.
- **Shared file:** `widgets.ts` is touched by both Phase 32 (HashtagWidget color) and this phase (CodeBlockWidget copy button). Different widget classes, but sequence 32 first per the roadmap.