---
phase: 11-per-page-llm-tag-suggestion
plan: 03
type: execute
wave: 3
depends_on: ["11-02"]
files_modified:
  - web/src/api/client.ts
  - web/src/components/TagSuggest.tsx
  - web/src/components/TagSuggest.css
  - web/src/components/TagSuggest.test.tsx
  - web/src/routes/PageEditor.tsx
autonomous: true
requirements: [TAG-01, TAG-02]
must_haves:
  truths:
    - "An editor sees a dedicated 'Suggest tags' trigger near the page tags/frontmatter area; clicking it fetches suggestions and opens a per-tag approval list"
    - "Each suggested tag is a checkbox row; existing-vocab vs new (invented) tags are visually distinguished by a 'new' word-badge AND new tags default UNCHECKED; only checked tags are applied"
    - "Apply is the single accent action and is NEVER auto-focused (Cancel is DOM-first + focused); Esc/backdrop cancel without writing; a 409 stale response shows the stale state (no clobbering apply, Re-run offered)"
    - "Loading / empty / error / stale states each render as a quiet muted line with hidden-Git-safe copy and no dangerouslySetInnerHTML"
  artifacts:
    - path: "web/src/components/TagSuggest.tsx"
      provides: "The Suggest-tags trigger + per-tag approval surface (checkbox rows, new badge, new-default-unchecked, Apply/Cancel, loading/empty/error/stale states), wired to react-query mutations"
      contains: "Suggest tags"
    - path: "web/src/api/client.ts"
      provides: "suggestTags + applyTags client fns mirroring the agent mode clients (CSRF-bearing apply, 409 surfaced via err.status)"
      contains: "export async function applyTags"
    - path: "web/src/components/TagSuggest.test.tsx"
      provides: "vitest coverage: new-default-unchecked, Apply-not-autofocused/Cancel-focused, only-checked-applied, 409 stale state, loading/empty/error states"
      contains: "TagSuggest"
  key_links:
    - from: "web/src/components/TagSuggest.tsx"
      to: "web/src/api/client.ts"
      via: "react-query mutations call suggestTags (read) + applyTags (CSRF write), surfacing the 409 stale state"
      pattern: "applyTags|suggestTags"
    - from: "web/src/routes/PageEditor.tsx"
      to: "web/src/components/TagSuggest.tsx"
      via: "the trigger mounts in the .pageeditor-frontmatter block (editor-gated)"
      pattern: "<TagSuggest"
---

<objective>
Build the frontend per-page tag-suggestion approval UI (TAG-01 trigger + TAG-02 per-tag approval): a dedicated "Suggest tags" trigger mounted near the page's tags/frontmatter area, and a focused per-tag approval list (checkbox rows, existing-vs-new "new" badge, new-default-unchecked, Apply/Cancel, and the loading / empty / error / stale-revision states), wired to react-query mutations over the Wave-2 endpoints.

Purpose: This is the trust surface the whole phase exists to deliver — a human approves each tag before anything is written. It consumes the Wave-2 backend (suggest-tags + apply-tags) and inherits the existing agent trust-gate interaction language verbatim from DiffReviewDialog (the primary write action is accent but NEVER auto-focused; the safe default is DOM-first + focused; Esc/backdrop cancel without writing; a stale revision removes the apply path and offers Re-run). It introduces NO new dependency and reuses the established token CSS + component patterns per 11-UI-SPEC.md.

Output: suggestTags + applyTags client fns in web/src/api/client.ts; a TagSuggest component (trigger + approval surface) with its token CSS; the trigger mounted in PageEditor's frontmatter block; a vitest test covering the locked interaction guarantees.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-per-page-llm-tag-suggestion/11-CONTEXT.md

# The UI design contract this plan implements EXACTLY (copy, tokens, interaction, states):
@.planning/phases/11-per-page-llm-tag-suggestion/11-UI-SPEC.md

# The Wave-2 endpoints this UI calls (shapes: suggestions+base_revision+existing flags; apply editor+CSRF, 409 on stale):
@.planning/phases/11-per-page-llm-tag-suggestion/11-02-PLAN.md

# The trust-gate interaction language to inherit VERBATIM (accent-never-autofocused, safe-default-focused, Esc/backdrop cancel, stale state + Re-run):
@web/src/components/DiffReviewDialog.tsx
@web/src/components/DiffReviewDialog.css

# The api client patterns to mirror (mutate() CSRF helper, agent mode clients, 409 via err.status, hidden-Git error copy):
@web/src/api/client.ts

# The mount point + editor-gating + frontmatter form + react-query usage:
@web/src/routes/PageEditor.tsx
@web/src/routes/PageEditor.css

# The quiet muted status-line + new-badge conventions to clone:
@web/src/components/BacklinksPanel.tsx
@web/src/components/RoleBadge.tsx
@web/src/components/RoleBadge.css

# The vitest + react-query test harness to mirror (QueryClientProvider wrap, vi.mock the api client, render/screen/waitFor):
@web/src/components/BacklinksPanel.test.tsx
@web/src/components/DiffReviewDialog.test.tsx
</context>

<artifacts_this_phase_produces>
This plan (Wave 3) completes the phase:
- `suggestTags(pagePath)` + `applyTags({page_path, tags, base_revision})` client fns (the latter CSRF-bearing via mutate(), surfacing the 409 stale via err.status — exactly like applyPatch).
- `TagSuggest` component: the "Suggest tags" trigger + the per-tag approval surface and all its states, wired to react-query mutations.
- The trigger mounted in PageEditor's `.pageeditor-frontmatter` block, editor-gated.
Consumes from 11-02: POST /agent/suggest-tags (suggestions + base_revision + per-tag existing flag) and POST /agent/apply-tags (editor+CSRF write, 409 on stale).
</artifacts_this_phase_produces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: suggestTags + applyTags api client fns</name>
  <files>web/src/api/client.ts</files>
  <read_first>
    - web/src/api/client.ts — the agent client section (lines ~665-924): proposePatch/rewrite/draft (the read/proposal mode-client shape) and especially applyPatch (lines ~913-924) — a CSRF-bearing mutate() POST whose 409 surfaces via err.status (set by mutate(), lines ~50-85) so the caller renders the stale state. Mirror applyPatch for applyTags and rewrite/proposePatch for suggestTags. Reuse the existing mutate() helper (it ensures the CSRF token + attaches err.status) — do NOT hand-roll a second fetch wrapper.
    - The 11-02 endpoint shapes: POST /agent/suggest-tags returns { suggestions: [{tag, existing}], base_revision } (mirror the field names the Wave-2 handler emits — read 11-02-PLAN's response struct); POST /agent/apply-tags takes { page_path, tags: string[], base_revision } and returns 204 (a 409 means stale).
  </read_first>
  <behavior>
    - suggestTags(pagePath): resolves to the typed { suggestions: {tag, existing}[]; base_revision } on 200; rejects with the server's message on a fail-closed status (503/502/422).
    - applyTags({page_path, tags, base_revision}): resolves on 204; on a 409 the thrown error carries err.status === 409 (so the component can switch to the stale state); on other failures it rejects with the server's generic message.
  </behavior>
  <action>
    Add two functions + their types to the agent section of web/src/api/client.ts (TAG-01/TAG-02 client layer):

    - A `TagSuggestion` interface `{ tag: string; existing: boolean }` and a `SuggestTagsResult` interface `{ suggestions: TagSuggestion[]; base_revision: string }` mirroring the Wave-2 suggest-tags response field names exactly.
    - `export async function suggestTags(pagePath: string): Promise<SuggestTagsResult>` — a mutate<SuggestTagsResult>("/api/v1/agent/suggest-tags", { page_path: pagePath }) call (it is a POST with a body, so it goes through mutate() like proposePatch/rewrite — fail-closed errors surface the server message).
    - `export async function applyTags(payload: { page_path: string; tags: string[]; base_revision: string }): Promise<void>` — a mutate<void>("/api/v1/agent/apply-tags", payload). Document (mirroring applyPatch's comment) that base_revision is the token suggestTags captured; a stale value surfaces as err.status === 409 so the approval surface shows its stale state and never overwrites a concurrent edit; the server re-validates+normalizes the tags (the client list is never trusted).

    Do NOT change the mutate() helper or the CSRF machinery — both fns reuse them as-is.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace/web && npx tsc -b --noEmit && npx vitest run src/api/client.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `suggestTags` and `applyTags` exist, are typed (TagSuggestion / SuggestTagsResult), and reuse the existing mutate() CSRF helper.
    - applyTags is a CSRF-bearing POST whose 409 surfaces via err.status (the same path applyPatch uses) so the caller can render the stale state.
    - `npx tsc -b` passes (no type errors); the existing client tests still pass.
  </acceptance_criteria>
  <done>The suggest/apply client fns mirror the agent mode clients: suggestTags reads, applyTags writes through the CSRF mutate() with the 409 stale signal preserved.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: TagSuggest component (trigger + per-tag approval surface + all states) and PageEditor mount</name>
  <files>web/src/components/TagSuggest.tsx, web/src/components/TagSuggest.css, web/src/components/TagSuggest.test.tsx, web/src/routes/PageEditor.tsx</files>
  <read_first>
    - .planning/phases/11-per-page-llm-tag-suggestion/11-UI-SPEC.md — the binding contract: the Copywriting Contract (every label/status line verbatim), the Component/Chrome Inventory (clone .agentpanel-suggestion for the trigger; the Dialog/DiffReviewDialog modal shell for the surface; .role-badge for the "new" badge; .diff-footer for the footer; .diff-empty/.agentpanel-loading/BacklinksPanel for the states), the Color list (accent ONLY on Apply + checked checkbox + trigger hover + focus rings; warning never accent for stale), and the Interaction Contract rows TAG-01..04. Use the Reused-Tokens inventory — add NO new token.
    - web/src/components/DiffReviewDialog.tsx — inherit the trust-gate interaction VERBATIM: the focus-inversion (lines ~89-119: safe action DOM-first + the deliberate initial-focus target via a ref; the primary accent action NEVER auto-focused), the Esc + backdrop-cancel + focus-trap effect (lines ~114-148), and the stale state (lines ~291-331: warning banner, Approve path REMOVED, Close + Re-run). The approval surface MUST reproduce this contract: Cancel is DOM-first + focused; Apply is accent + NOT auto-focused; Esc/backdrop = Cancel (never Apply); a 409 → the stale state (no apply, Re-run offered).
    - web/src/components/RoleBadge.tsx + RoleBadge.css — clone exactly for the "new" badge (surface fill, border outline, muted text, --radius-sm).
    - web/src/components/BacklinksPanel.tsx — the quiet muted status-line convention + the {text} React-child render (NO dangerouslySetInnerHTML) + the hidden-Git-safe error copy.
    - web/src/routes/PageEditor.tsx — the mount point (the `.pageeditor-frontmatter` block, lines ~615-640, currently Title + Description), the `path` param (line ~52), the react-query usage (useQuery/useQueryClient, lines ~3, 63-68), and `readOnly`. Note PageEditor is Edit-mode only, but per the UI-SPEC the trigger is explicitly editor-gated — gate it (render only for editor/admin) by reading the cached `["me"]` query (the role lives there; AppShell computes canEdit = role === "editor" || "admin") OR by the existing readOnly/edit context already present. After Apply succeeds, invalidate the page query (queryKey ["page", path]) so the freshly written tags are reflected.
    - web/src/components/BacklinksPanel.test.tsx + DiffReviewDialog.test.tsx — the vitest harness to mirror: wrap in QueryClientProvider (retry:false), vi.mock("../api/client"), render/screen/waitFor, and (from DiffReviewDialog.test) how the focus target + Esc/backdrop-cancel + stale footer are asserted.
  </read_first>
  <behavior>
    - new-default-unchecked + existing distinction (TAG-02): given suggestions [{tag:"release",existing:true},{tag:"q3-launch",existing:false}], the surface renders both as checkbox rows; the new (existing:false) row carries the word-badge "new" AND is UNCHECKED on open; the existing row carries no badge (default-checked is acceptable). The "{n} selected" count reflects the initially-checked set and updates live as rows toggle.
    - only-checked-applied (TAG-02): toggling rows then clicking Apply calls applyTags with EXACTLY the checked tags + the captured base_revision — never the full suggestion list, never an unchecked tag.
    - Apply-not-autofocused / Cancel-focused (TAG-03): on open, document.activeElement is the Cancel control (the safe DOM-first default), NOT Apply; Esc and a backdrop click invoke Cancel and call applyTags ZERO times (nothing is written until Apply).
    - 409 stale (TAG-03): when applyTags rejects with err.status === 409, the surface switches to the stale state — the Apply path is removed, a warning banner + Close/Re-run are shown (Re-run re-fires suggestTags). It does NOT retry the apply.
    - states: loading (suggestTags in flight) shows the quiet "Suggesting tags…" line with the spinner; empty (suggestions: []) shows "No tag suggestions for this page."; a suggest error shows "Couldn&rsquo;t suggest tags. Try again."; an apply non-stale error shows "Couldn&rsquo;t apply the tags. Try again." — all quiet muted single lines, all hidden-Git-safe.
    - editor-gating: the trigger does not render for a reader (role !== editor/admin).
  </behavior>
  <action>
    Create web/src/components/TagSuggest.tsx implementing the trigger + the per-tag approval surface per 11-UI-SPEC.md (TAG-01 trigger, TAG-02 approval), and TagSuggest.css cloning the referenced token CSS (NO new token, NO new dependency).

    Trigger ("Suggest tags"): a button cloning the .agentpanel-suggestion chip styling (or a .btn .btn-ghost with a lucide Tags/Sparkles icon), min-height --hit-min-height, label at --font-size-label, disabled + a spinner showing "Suggesting tags…" while the suggest mutation is in flight, mounted in the `.pageeditor-frontmatter` area, editor-gated (render only for editor/admin — read the role from the cached ["me"] query, mirroring AppShell's canEdit). Clicking it fires the suggest mutation (capturing the returned base_revision into local state) and, on success, opens the approval surface.

    Approval surface (TagSuggestList): clone the Dialog/DiffReviewDialog modal shell (.dialog-backdrop/.dialog/.dialog-title/.dialog-footer) and REPRODUCE the trust-gate contract verbatim — Cancel is DOM-first + the deliberate initial-focus target (a ref focused on open); Apply is the single accent .btn-primary (with the Check icon, like Approve & save) and is NEVER auto-focused; Esc and backdrop-click invoke Cancel (never Apply); a focus trap + restore-focus-on-close (copy the effect from DiffReviewDialog). Body: a per-tag checkbox list — each row is a `<label>` wrapping `<input type="checkbox">` + the tag name (rendered as a React text child — NO dangerouslySetInnerHTML) + the "new" badge (clone .role-badge) for existing:false rows. New tags default UNCHECKED; existing tags may default checked. A small header strip with Select all / Clear all and a live `{n} selected` count (aria-live="polite"). Footer (review state): Cancel (.btn-secondary, left, focused) + Apply tags (.btn-primary + Check, right), Apply showing "Applying…" + disabled while the mutation is in flight. Footer (stale state): clone the DiffReviewDialog stale footer — .diff-stale warning banner (AlertTriangle + heading + body, warning color, NEVER accent) + Close / Re-run, with the Apply path removed.

    States: loading → "Suggesting tags…" with the .spinner (clone .agentpanel-loading); empty → "No tag suggestions for this page." (clone .diff-empty); suggest error → "Couldn&rsquo;t suggest tags. Try again."; apply non-stale error → "Couldn&rsquo;t apply the tags. Try again." All quiet muted single lines at --color-text-muted + --font-size-label, all hidden-Git-safe (no git/sqlite/bleve/index/model-provider vocabulary). Use the exact copy strings from the UI-SPEC Copywriting Contract.

    Wiring: use @tanstack/react-query mutations (useMutation) for suggestTags and applyTags (mirror the existing agent mutation usage). On Apply, send ONLY the checked tags + the captured base_revision via applyTags; on success close the surface and invalidate the ["page", path] query so the new tags render; on a rejected applyTags with err.status === 409 switch to the stale state; on any other apply error show the apply-error line. Apply prefers reduced motion (the spinner stops under prefers-reduced-motion, mirroring .agentpanel-loading).

    Mount: in web/src/routes/PageEditor.tsx, render `<TagSuggest pagePath={path} />` inside the `.pageeditor-frontmatter` block (near Title/Description), passing the page path. Do NOT change the byte-stable save/autosave path — TagSuggest writes through its own apply-tags mutation (the server owns the byte-stable okf.SetTags write); after a successful apply it invalidates the page query so the editor reloads the updated frontmatter rather than racing the autosave.

    Create web/src/components/TagSuggest.test.tsx (vitest) mirroring BacklinksPanel.test.tsx + DiffReviewDialog.test.tsx: wrap in QueryClientProvider (retry:false), vi.mock("../api/client") to stub suggestTags/applyTags, and assert every <behavior> case — new-default-unchecked + the "new" badge on the invented tag, the live count, only-checked-applied (applyTags called with exactly the checked tags + base_revision), Apply-not-autofocused / Cancel-focused on open, Esc/backdrop cancel calling applyTags zero times, the 409 → stale state (Apply removed, Re-run present), and the loading/empty/suggest-error/apply-error lines. Also assert the trigger does not render for a reader role.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace/web && npx tsc -b --noEmit && npx vitest run src/components/TagSuggest.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - The "Suggest tags" trigger renders editor-gated in the `.pageeditor-frontmatter` block; clicking it fetches suggestions and opens the approval surface; while in flight it is disabled and shows "Suggesting tags…".
    - Each suggestion is a checkbox row; existing:false tags carry the "new" word-badge AND default UNCHECKED; the "{n} selected" count is live (aria-live).
    - Apply calls applyTags with EXACTLY the checked tags + the captured base_revision (never unchecked tags, never the full list); Apply is accent and NOT auto-focused; Cancel is DOM-first + focused; Esc/backdrop cancel and write nothing.
    - A 409 from applyTags switches to the stale state (Apply path removed; warning banner + Close/Re-run; Re-run re-runs suggestTags) — no clobbering retry.
    - loading / empty / suggest-error / apply-error each render the exact quiet muted hidden-Git-safe copy from the UI-SPEC; no dangerouslySetInnerHTML anywhere; no new npm dependency; no new CSS token.
    - `npx tsc -b` passes and TagSuggest.test.tsx passes.
  </acceptance_criteria>
  <done>The TagSuggest trigger + per-tag approval surface implements the locked UI contract (new-default-unchecked, Apply-not-autofocused, only-checked-applied, 409 stale state, quiet states), mounted editor-gated in the page editor, fully covered by vitest.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser DOM → applyTags request | the user-checked tag set + captured base_revision cross to the server (which re-validates — the client is never authoritative) |
| server tag strings → DOM render | suggested/applied tag strings render into the approval list |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-07 | Tampering | client-checked tag set | mitigate | The UI sends only the checked tags + base_revision; the server (Wave 2) re-validates+normalizes and 409s on stale — the client list is never authoritative. The UI never assumes its list is the source of truth. |
| T-11-08 | Information Disclosure (stored XSS) | tag name render | mitigate | Tag names render as React text children only — NO dangerouslySetInnerHTML (the locked stored-XSS guard, identical to BacklinksPanel). |
| T-11-09 | Tampering | reflexive Enter on open | mitigate | Apply is NEVER auto-focused; Cancel is the DOM-first focused default; Esc/backdrop = Cancel — a reflexive Enter cannot write (trust-gate inversion from DiffReviewDialog). |
| T-11-10 | Elevation of Privilege | reader sees the write trigger | mitigate | The trigger is editor-gated (renders only for editor/admin); the apply endpoint is independently editor+CSRF-gated server-side (Wave 2) — the UI gate is convenience, the server gate is the boundary. |
| T-11-SC | Tampering | npm installs | accept | No new npm dependency is added (UI-SPEC: "no new dependency") — no install task, so the supply-chain checkpoint is not triggered. |
</threat_model>

<verification>
- `cd web && npx tsc -b --noEmit` is clean (no type errors across the new client fns + component).
- `cd web && npx vitest run src/components/TagSuggest.test.tsx src/api/client.test.ts` passes (the locked interaction guarantees + the client fns).
- Manual spot-check against 11-UI-SPEC.md: copy strings, accent reserved to Apply/checked/trigger-hover/focus-ring only, the "new" distinction is word + unchecked-default (never color alone), and the stale state mirrors DiffReviewDialog.
- No new npm dependency (package.json unchanged) and no new CSS token (tokens.css unchanged).
</verification>

<success_criteria>
- TAG-01 + TAG-02 frontend is complete: an editor-gated "Suggest tags" trigger near the page tags opens a per-tag approval list where existing-vs-new is distinguished (word-badge + unchecked-default for new), only checked tags apply, Apply is accent-but-never-autofocused with Cancel the safe focused default, and the loading/empty/error/stale states all render the locked quiet hidden-Git-safe copy.
- The UI consumes the Wave-2 endpoints (suggest read + apply editor+CSRF write), surfaces the 409 stale state without clobbering, and re-renders the page on a successful apply.
- No new dependency; no new CSS token; no dangerouslySetInnerHTML; the existing byte-stable save path is untouched (the server owns the okf.SetTags write).
</success_criteria>

<output>
Create `.planning/phases/11-per-page-llm-tag-suggestion/11-03-SUMMARY.md` when done.
</output>
