---
phase: 11
slug: per-page-llm-tag-suggestion
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-24
---

# Phase 11 — UI Design Contract

> Visual and interaction contract for **Per-Page LLM Tag Suggestion** (TAG-01..04): the dedicated **"Suggest tags"** trigger control mounted near the page's tags/frontmatter area, and the focused **per-tag approval list** (checkbox rows, existing-vs-new distinction, new-default-unchecked, Apply/Cancel, count, and the loading / empty / error / stale-revision states). Generated by gsd-ui-researcher, verified by gsd-ui-checker.
>
> **Subsequent phase on a shipped app.** This contract LOCKS to the existing hand-rolled token system (`web/src/styles/tokens.css` + `web/src/styles/controls.css`). It introduces NO new design language and NO new dependency. The approval interaction language is inherited verbatim from the existing agent **trust-gate** convention in `DiffReviewDialog.tsx` (the primary write action is accent but NEVER auto-focused; the safe default is DOM-first + focused; backdrop/Esc cancels without writing; a stale page revision removes the apply path and offers Re-run). Status/empty/error copy follows the Phase-9 `BacklinksPanel` quiet-muted-line convention and the hidden-Git invariant.
>
> **Scope guard:** This spec covers ONLY the per-page suggest→approve UI (trigger + approval list + states). The bulk/untagged sweep and batch review queue are **Phase 12 (TAG-05/06) — out of scope**. Backend mechanics (`okf.SetTags`, the `SuggestTags` mode, endpoint shapes, 409 floor) are owned by the phase plan, not this contract; they appear here only where they drive a visible state.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (hand-rolled token CSS; no shadcn / no Tailwind — confirmed: no `components.json`, no `tailwind.config.*`, no `postcss.config.*`) |
| Preset | not applicable |
| Component library | none (token-only CSS-per-component; the `controls.css` `.btn`/`.input`/`.field` primitives + the `Dialog.css`/`DiffReviewDialog.css` modal shell) |
| Icon library | `lucide-react` (already used across the app; reuse — e.g. `Tags` or `Sparkles` for the trigger affordance, `Check` on the Apply button mirroring `DiffReviewDialog`, `AlertTriangle` for the stale/error banner mirroring `DiffReviewDialog`, `Sparkles` for the loading affordance) |
| Font | `--font-family-ui` (system-ui stack) for all chrome; `--font-family-mono` not used in this phase |
| New runtime dependency | **none.** No new npm or Go package. The approval surface is built from existing token CSS + existing component patterns (`DiffReviewDialog`, `AgentPanel` suggestion chips, `RoleBadge`, `controls.css`). |

**Rendering / state:** Suggestions are fetched + applied via `@tanstack/react-query` mutations (mirror the existing agent `proposePatch`/`applyPatch` mutation pattern in `handlers_agent.go` clients). The approval list is local component state (the per-tag checked set + the captured base revision). **No `dangerouslySetInnerHTML`** anywhere — tag names render as React text children only (the locked stored-XSS guard, identical to `BacklinksPanel`'s `{b.title}` rule). The trigger and approval surface are **editor-gated** (render only when `me.role` is `editor` or `admin`, mirroring `PageView`'s `canEdit`).

**Mount point:** The **"Suggest tags"** trigger mounts in the page/editor view near the tags/frontmatter area — specifically the `.pageeditor-frontmatter` block in `PageEditor.tsx` (which today renders only Title + Description fields; this phase adds the tags affordance there). It is **NOT** folded into the agent `PromptBar` (locked CONTEXT decision). The approval list opens as a focused surface anchored to that control (a dialog/popover — Claude's discretion on dialog-vs-inline-popover within the interaction contract below).

---

## Spacing Scale

Declared values (must be multiples of 4) — **reused verbatim from `tokens.css`**:

| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| `--space-xs` | 4px | Checkbox↔label gap, tag-name↔"new"-badge gap, icon↔label gap on the Apply button |
| `--space-sm` | 8px | Approval-row vertical padding, footer button gap, select-all↔count gap |
| `--space-md` | 16px | Approval surface body padding (mirrors `.dialog-body` / `.agentpanel-body`), trigger-area padding |
| `--space-lg` | 24px | Empty/loading/error state padding inside the surface (mirrors `.diff-empty` `padding`) |
| `--space-xl` | 32px | Surface max-height viewport inset (mirrors `.diff-dialog` `max-height: calc(100vh - var(--space-xl) * 2)`) |
| `--space-2xl` | 48px | Not expected; available if an empty-state needs the `.agentpanel-empty` vertical offset |

Fixed interactive sizes (reused): `--hit-min-height` (40px — every checkbox row and footer button meets this), `--hit-min-icon` (44px — icon-only trigger variant if used). Radius: `--radius-sm` (6px) for the surface, rows, and the "new" badge (matches `.role-badge` / `.agentpanel-suggestion`).

Exceptions: none. All chrome uses the 4px scale; there is no canvas/numeric geometry in this phase.

---

## Typography

**Reused verbatim from `tokens.css`** (exactly 4 sizes, exactly 2 weights):

| Role | Size | Weight | Line Height | Usage in this phase |
|------|------|--------|-------------|---------------------|
| Display | `--font-size-display` 28px | `--font-weight-semibold` 600 | `--line-height-display` 1.2 | not used |
| Heading | `--font-size-heading` 20px | `--font-weight-semibold` 600 | `--line-height-heading` 1.2 | Approval surface title ("Suggested tags") — mirrors `.dialog-title` / `.agentpanel-title` |
| Body | `--font-size-body` 16px | `--font-weight-regular` 400 | `--line-height-body` 1.5 | Tag-name text in each approval row; default button text (`.btn`) |
| Label | `--font-size-label` 14px | 400 / 600 | `--line-height-label` 1.4 | Trigger button label, the "new" badge text, the approved count line, the select-all/none control, and ALL status lines (loading / empty / error / stale) — mirrors `.agentpanel-meta` / `.backlinks-status` / `.diff-stale-body` |

The stale banner heading uses `--font-size-label` at `--font-weight-semibold` 600 (exactly mirrors `.diff-stale-heading`). No other font sizes or weights are introduced.

---

## Color

**60 / 30 / 10 split — reused from the established dark Obsidian palette in `tokens.css`:**

| Role | Value (token) | Usage |
|------|---------------|-------|
| Dominant (60%) | `--color-bg` `#0b0d12` | Approval surface body background (matches `.dialog` / `.diff-region` background); checkbox-row default background |
| Secondary (30%) | `--color-surface` `#11151f` | Approval surface header band (mirrors `.dialog`/`.agentpanel-header`); the "new" badge fill (mirrors `.role-badge` `background: var(--color-surface)`) |
| Accent (10%) | `--color-accent` `#8b5cf6` | See reserved list below |
| Destructive | `--color-destructive` `#fb7185` | Not used — there are NO destructive actions in this phase (Cancel discards a draft; it never deletes content). Reject-style framing is unnecessary because nothing is written until Apply. |

**Accent (`--color-accent`) reserved for — explicit list (never "all interactive elements"):**
1. The **Apply** button — the single primary write CTA of this surface (`.btn .btn-primary`, accent gradient), mirroring the `DiffReviewDialog` Approve button. **Apply is NEVER auto-focused** (trust-gate inversion — see Interaction Contract).
2. The **checked state** of an approval-row checkbox (the accent check/fill of the native-styled control or the `accent-color: var(--color-accent)` on the `<input type="checkbox">`).
3. The **"Suggest tags" trigger on hover/active** — reuses the `.agentpanel-suggestion:hover` treatment exactly: `--color-accent-soft` background + `--color-accent-border` border + `--color-text`.
4. `:focus-visible` rings on the trigger, every checkbox row, the select-all control, and the footer buttons (global `2px solid var(--color-accent)` from `tokens.css`).

Accent is NOT used for the "new" badge, ordinary row text, the count line, status/empty/error text, or surface borders.

### Semantic / secondary color usage

| Element | Color (token) | Notes |
|---------|---------------|-------|
| "new" (invented) badge | text `--color-text-muted` `#8d96a8` on `--color-surface` fill + `--color-border` outline (= `.role-badge` recipe) | Distinguishes invented tags from existing-vocab tags **with a word ("new"), not by color alone** (color is never the sole signal — mirrors the `DiffReviewDialog` risk-note discipline). New tags ALSO default unchecked, a second non-color signal. |
| Existing-vocab tag row | default row text `--color-text` `#eef1f7` | No badge; may default checked (Claude's discretion per CONTEXT — only "new" MUST default unchecked). |
| Status / empty / loading / error lines | `--color-text-muted` `#8d96a8` at `--font-size-label` | Quiet single lines, identical to `.backlinks-status`. |
| Stale-revision banner | warning icon `--color-warning` `#facc15` + heading `--color-text` + body `--color-text-muted` | Exact mirror of `.diff-stale` (warning, **never accent**, color paired with words + icon). |

No new color token is introduced this phase.

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Trigger control label | `Suggest tags` |
| Trigger aria-label (icon-only variant, if used) | `Suggest tags for this page` |
| Trigger loading label (in-flight) | `Suggesting tags…` (button shows the `.spinner`, mirrors `.agentpanel-loading`; button disabled while in-flight) |
| Approval surface title | `Suggested tags` |
| Approval surface sub-line (optional, muted) | `Pick the tags to add. New tags are off by default.` |
| Approved count line | `{n} selected` (e.g. `2 selected`; live-updates as rows toggle; muted Label) |
| Select-all / select-none control | `Select all` / `Clear all` (a single text toggle or a paired control — Claude's discretion; Label size, ghost styling) |
| "new" badge text | `new` (lowercase, on each invented tag row) |
| Primary CTA (write) | `Apply tags` (accent `.btn-primary`, with the `Check` icon like `Approve & save`; NEVER auto-focused) |
| Primary CTA busy label | `Applying…` (mirrors `Saving…` on the Approve button) |
| Cancel / dismiss | `Cancel` (`.btn-secondary`; DOM-first and the deliberate initial-focus target — the safe default) |
| Empty state (model returned no tags) | `No tag suggestions for this page.` (quiet muted single line, mirrors `.diff-empty` / `.backlinks-status`) |
| Empty-state secondary line (optional) | `Try editing the page to add more content, then suggest again.` |
| Loading state (fetching suggestions) | `Suggesting tags…` (muted, with the `.spinner`, mirrors `.agentpanel-loading`) |
| Error state (suggest failed) | `Couldn't suggest tags. Try again.` (generic, hidden-Git-safe — no git / sqlite / bleve / LLM-provider / index vocabulary; mirrors the `BacklinksPanel` error copy) |
| Error state (apply failed, non-stale) | `Couldn't apply the tags. Try again.` (same hidden-Git discipline) |
| Stale-revision state heading | `This page changed since the tags were suggested.` (mirrors `.diff-stale-heading` wording) |
| Stale-revision state body | `Someone edited the page while you were reviewing. Re-run to get fresh suggestions.` (mirrors `.diff-stale-body`) |
| Stale-revision actions | `Close` (`.btn-secondary`) + `Re-run` (`.btn-primary`) — the Apply path is REMOVED in this state (exact mirror of `DiffReviewDialog` stale footer) |
| Destructive confirmation | None — there are NO destructive actions. Cancel discards an unapplied draft selection (nothing is written until Apply); no content is ever deleted by this surface. |

> Copy discipline: every status / empty / error / loading line is a quiet muted single line (`--color-text-muted` + `--font-size-label`), matching the Phase-9 `BacklinksPanel` and Phase-10 contracts. Error copy carries **zero** git / sqlite / bleve / index / model-provider vocabulary (the hidden-Git invariant). All apostrophes use the existing typographic convention (`&rsquo;` / `&amp;`) as in `DiffReviewDialog`.

---

## Component / Chrome Inventory (executor reference)

| Component | Pattern to clone | Token notes |
|-----------|------------------|-------------|
| `SuggestTagsButton` (the trigger) | Clone `.agentpanel-suggestion` chip styling (ghost button → accent-soft on hover/active) OR a `.btn .btn-ghost` with a `Tags`/`Sparkles` lucide icon; mounts in the `.pageeditor-frontmatter` area | Min-height `--hit-min-height`; label at `--font-size-label`; disabled + `.spinner` while in-flight; editor-gated (render only for editor/admin) |
| `TagSuggestList` (the approval surface) | Clone the **modal shell** from `Dialog.css` / `DiffReviewDialog.css` (`.dialog-backdrop` / `.dialog` / `.dialog-title` / `.dialog-footer`) with the **trust-gate focus inversion** copied from `DiffReviewDialog` (safe action DOM-first + focused; primary action NOT auto-focused; Esc + backdrop = Cancel) | Header `--color-surface` + `--space-md`; body `--color-bg`; viewport-bounded with internal scroll on the row list (mirror `.diff-region` `max-height` + `overflow:auto`) |
| Approval row | A `<label>` wrapping `<input type="checkbox">` + tag name + optional "new" badge; full row is the hit target at `--hit-min-height`; hover `--color-hover` | `accent-color: var(--color-accent)` on the checkbox; tag name `--font-size-body`; row gap `--space-sm` |
| "new" badge | Clone `.role-badge` exactly (`--color-surface` fill, `--color-border` outline, `--font-size-label`, `--radius-sm`, muted text) | Word `new`; color is a secondary signal only (unchecked-by-default is the primary one) |
| Select-all / count header strip | A small row above the list: select-all control (left) + `{n} selected` count (right), both `--font-size-label` muted | Reuses `.btn-ghost` for the toggle; count is plain muted text |
| Footer (review state) | Clone `.diff-footer` (`justify-content: space-between`): `Cancel` (`.btn-secondary`, DOM-first + focused) on the left, `Apply tags` (`.btn-primary` + `Check` icon) on the right — positionally separated, never two same-weight adjacent buttons | `Apply tags` shows `Applying…` + disabled while the mutation is in flight |
| Footer (stale state) | Clone the `DiffReviewDialog` stale footer: `.diff-stale` warning banner (`AlertTriangle` + heading + body) + actions `Close` / `Re-run`; the Apply path is removed | Warning `--color-warning`, never accent |
| Empty / loading / error states | Clone `.diff-empty` (centered muted line inside the surface) for empty; `.agentpanel-loading` (spinner + muted line) for loading; the `BacklinksPanel` error line for errors | `--color-text-muted`, `--font-size-label` |

---

## Interaction Contract (maps to TAG-01..04)

| # | Requirement | Contract |
|---|-------------|----------|
| TAG-01 | On-demand suggestion | The **"Suggest tags"** trigger sits near the tags/frontmatter area (the `.pageeditor-frontmatter` block), editor-gated. Clicking it fires the suggest mutation (capturing the page's base revision) and shows the in-flight `Suggesting tags…` state on the button; on success it opens the `TagSuggestList` approval surface. The trigger is the only entry point — it is NOT in the `PromptBar`. |
| TAG-02 | Per-tag approve + new-vs-existing + new-default-unchecked | The approval surface is a per-tag checkbox list. **Existing-vocab** tags carry no badge (and MAY default checked — Claude's discretion). **New (invented)** tags carry the `new` badge AND **default UNCHECKED** (the user opts IN to new vocabulary — locked). Toggling a row updates the `{n} selected` count live. Only checked tags are sent to Apply. Nothing is written until Apply. |
| TAG-03 | Byte-stable apply + single-writer commit + 409 stale floor | **Apply tags** is the single accent primary action and is **NEVER auto-focused** (trust-gate inversion from `DiffReviewDialog`); **Cancel** is DOM-first and the deliberate initial-focus target so a reflexive Enter on open cannot write. Esc and backdrop-click invoke Cancel (never Apply). Apply sends the checked tags + the captured base revision; on a **409 stale revision** the surface switches to the **stale state** (Apply path removed; warning banner + `Close` / `Re-run`) rather than clobbering — exact mirror of the agent stale gate. Apply shows `Applying…` + disabled while in flight. (The byte-stable single-`tags`-line write + commit is backend-owned; the UI's only contract is to surface the 409 as the stale state and never offer a clobbering apply.) |
| TAG-04 | Vocab-bias + cap + normalize | The cap (max 5) and normalization (lowercase/trim/dedupe) and existing-vocabulary bias are produced server-side; the UI **reflects** them: it never shows more than the capped set, renders normalized tag strings as-is, and the existing-vs-new flag (server-provided) drives the badge + default-checked decision. The UI does not re-normalize or invent tags client-side, and the server re-validates on apply (the UI never assumes its checked list is authoritative). |

**Accessibility / motion:** Full focus trap + Esc + restore-focus-on-close, copied from `Dialog`/`DiffReviewDialog`. Every row and button meets `--hit-min-height` (40px). `:focus-visible` rings are the global accent ring. The "new" distinction is conveyed by a word + the unchecked default, not color alone. Under `prefers-reduced-motion: reduce`, the trigger/loading spinner stops animating (mirror `.agentpanel-loading` reduced-motion rule). Checkbox labels are real `<label>`s bound to their `<input>`; the count line uses `aria-live="polite"` so screen readers hear selection changes.

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none (project does not use shadcn) | not applicable |
| third-party registries | none | not applicable |

No shadcn registry is in use and **no new dependency is introduced** this phase — the surface is composed entirely from existing token CSS and existing component patterns. Registry vetting gate: not applicable.

---

## Reused Tokens (explicit inventory for the checker)

Spacing: `--space-xs`, `--space-sm`, `--space-md`, `--space-lg`, `--space-xl` · sizes `--hit-min-height`, `--hit-min-icon` · radius `--radius-sm`.
Typography: `--font-size-heading`, `--font-size-body`, `--font-size-label`; `--font-weight-regular`, `--font-weight-semibold`; `--line-height-heading`, `--line-height-body`, `--line-height-label`; `--font-family-ui`.
Color: `--color-bg`, `--color-surface`, `--color-text`, `--color-text-muted`, `--color-border`, `--color-hover`, `--color-accent`, `--color-accent-fg`, `--color-accent-soft`, `--color-accent-border`, `--color-warning`.
**New tokens added this phase: none.**

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
