---
phase: 10
slug: graph-ui
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-24
---

# Phase 10 — UI Design Contract

> Visual and interaction contract for the Obsidian-style Graph UI: the global graph view (`/app/graph`), the right-side collapsible local-graph panel, edge-type toggles, node/edge canvas visuals, and hover-highlight. 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. Two graph-only canvas color tokens are proposed as minimal, justified additions mapped to the existing palette (see Color § "New tokens").

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (hand-rolled token CSS; no shadcn / no Tailwind — confirmed: no `components.json`, no `tailwind.config.*`) |
| Preset | not applicable |
| Component library | none (token-only CSS modules per component; `controls.css` `.btn`/`.input`/`.select`/`.field` primitives) |
| Icon library | `lucide-react` (already used across AppShell; reuse — e.g. `Share2` / `Spline` / `Network` for the nav graph icon, `PanelRightClose`/`PanelRightOpen` for the local-panel toggle, `ChevronDown`/`ChevronRight` for the depth/collapse affordances) |
| Font | `--font-family-ui` (system-ui stack) for all chrome; `--font-family-mono` not used here. Canvas node labels are drawn in the UI font family at a token-derived px. |
| New runtime dependency | `react-force-graph-2d` (~v1.29.x) — the **2D Canvas subpackage ONLY**. Do NOT install the umbrella `react-force-graph` (drags three.js → breaks single-binary embed). CI/lockfile check: `three` MUST be absent from `web/package-lock.json`. |

**Rendering surface:** The graph is a `<canvas>` driven by an imperative `ForceGraph2D` instance. Wrap it with the same ref-lifecycle discipline as `LivePreviewEditor` (own the instance in a `useEffect`/ref, clean up on unmount). The graph route component is `React.lazy` + `Suspense` so the canvas library never enters the initial editor bundle.

**State:** `@tanstack/react-query` for `/api/v1/graph` and `/api/v1/graph/local` fetches (mirror `getBacklinks`/`useBacklinks`); a new `zustand` slice for the three edge-type toggle booleans + local-panel open/collapse (mirror `agentPanel.ts` persist pattern). Edge filtering is `useMemo` over the payload `edges` array — the UI never recomputes adjacency.

---

## Spacing Scale

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

| Token | Value | Usage in this phase |
|-------|-------|---------------------|
| `--space-xs` | 4px | Icon↔label gaps in toggle chips, depth-control row gap |
| `--space-sm` | 8px | Toggle-cluster internal gaps, compact control padding |
| `--space-md` | 16px | Graph route header padding, panel header/body padding (matches `.agentpanel-header`/`.agentpanel-body`) |
| `--space-lg` | 24px | Section padding, empty-state top spacing |
| `--space-xl` | 32px | Layout gaps |
| `--space-2xl` | 48px | Empty-state vertical offset (matches `.agentpanel-empty` `padding-top`) |
| `--space-3xl` | 64px | Page-level spacing |

Fixed layout dimensions (reused): `--topbar-height` (56px), `--navrail-width`, `--agentpanel-width` (360px — the local-graph panel reuses this width token / its own peer `--localgraph-width` if a distinct default is wanted), `--hit-min-height` (40px interactive rows), `--hit-min-icon` (44px icon-only toggle buttons).

Exceptions: Canvas-internal geometry (node radii, edge stroke widths, hover-ring offsets) is drawn in **device pixels**, not CSS spacing tokens — the 4px scale governs DOM chrome only. Canvas geometry is specified numerically in the Canvas Visual Language section below and is at Claude's discretion within the stated ranges.

---

## 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 (no display-scale text in graph chrome) |
| Heading | `--font-size-heading` 20px | `--font-weight-semibold` 600 | `--line-height-heading` 1.2 | Graph route page title ("Graph"), local-panel title ("Local graph") — mirrors `.agentpanel-title` |
| Body | `--font-size-body` 16px | `--font-weight-regular` 400 | `--line-height-body` 1.5 | Buttons (`.btn`), default control text |
| Label | `--font-size-label` 14px | 400 / 600 | `--line-height-label` 1.4 | Toggle chip labels, depth-control label, node/edge counts, empty/loading/error status lines (mirrors `.agentpanel-meta`/`.backlinks-status`) |

**Canvas node labels:** drawn at a px derived from `--font-size-label` (14px) in `--font-family-ui`, scaled inversely with zoom so labels stay legible. Labels appear above a zoom threshold (Claude's discretion per CONTEXT — e.g. reveal when `globalScale` exceeds a tuned value) and on hover/focus regardless of zoom. Label color = `--color-text` (focused) / `--color-text-muted` (de-emphasized).

---

## Color

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

| Role | Value (token) | Usage |
|------|---------------|-------|
| Dominant (60%) | `--color-bg` `#0b0d12` | Graph route main pane, canvas backdrop, local-panel body background (matches `.agentpanel` `background: var(--color-bg)`) |
| Secondary (30%) | `--color-surface` `#11151f` | Route header bar, panel header, toggle-cluster surface, control fills (matches `.agentpanel-header`) |
| Accent (10%) | `--color-accent` `#8b5cf6` | See reserved list below |
| Destructive | `--color-destructive` `#fb7185` | Not used this phase (no destructive actions in the graph UI) |

**Accent (`--color-accent`) reserved for — explicit list (never "all interactive elements"):**
1. The **active/current page node** fill on the canvas (the one node representing the page the user came from / is centered on in the local graph).
2. The **ON state** of an edge-type toggle chip (active toggle uses `--color-accent-soft` background + `--color-accent-border` border + `--color-text`, mirroring `.agentpanel-suggestion:hover`).
3. The nav entry's **active/selected** indication when on `/app/graph` (mirrors existing nav active treatment `--color-tree-active-bg`).
4. `:focus-visible` rings on all chrome controls (global `2px solid var(--color-accent)` from `tokens.css`).

Accent is NOT used for ordinary page nodes, edges, hover dimming, or panel chrome borders.

### Canvas visual language → token mapping

| Canvas element | Color (token) | Notes |
|----------------|---------------|-------|
| Page node (default) | `--color-text-muted` `#8d96a8` | Uniform muted fill for all ordinary page nodes (CONTEXT: "one muted token color") |
| Active/current page node | `--color-accent` `#8b5cf6` | The single accented node |
| Orphan page node (no links, no tags) | `--graph-node-orphan` (NEW — see below) | Visually distinct: dimmer fill + 1px outline; lower opacity than a connected node |
| Tag node (bipartite `tag:` nodes) | `--graph-node-tag` (NEW — see below) | Distinct from page nodes (different shade, optionally a square/diamond vs. circle) — NOT a rainbow palette (tag-group coloring is deferred v2 GRAPH-F1) |
| Link edge (page→page) | `--color-border-strong` `rgba(255,255,255,0.13)` | Default visible edge stroke |
| Tag/shared-tag edge (page→tag) | `--color-border` `rgba(255,255,255,0.075)` | Fainter than link edges; OFF by default |
| Hover/focus — focused node + neighbors | `--color-text` / `--color-accent-2` `#a78bfa` for connecting edges | Emphasized |
| Hover/focus — dimmed (non-neighbors) | reduced alpha of the element's base color (≈0.15–0.25) | "Dim the rest" Obsidian behavior |

### New tokens (minimal, justified — mapped to existing palette)

Two graph-only canvas tokens are added to `tokens.css`. They are derived from existing palette values, not a new hue family:

| New token | Value | Justification |
|-----------|-------|---------------|
| `--graph-node-orphan` | `rgba(141, 150, 168, 0.45)` (= `--color-text-muted` at ~45% alpha) | Orphan distinction (GRAPH-02) needs a *dimmer* page-node fill than the default muted color; expressed as the existing muted color at reduced alpha so it stays on-palette. Paired with a 1px `--color-border-strong` outline drawn on canvas. |
| `--graph-node-tag` | `--color-faint` `#626b7d` (reuse existing) — OR a new `rgba(167, 139, 250, 0.55)` (= `--color-accent-2` at ~55%) if a tag must read as accent-adjacent | Tag nodes must be distinct from page nodes without a rainbow. **Default: reuse the existing `--color-faint` `#626b7d`** (no new token needed) and differentiate tag nodes additionally by shape (e.g. diamond). Only add a token if shape alone proves insufficient in the tuning spike. |

> Net: aim for **zero or one** genuinely new color token. `--graph-node-orphan` is the only firmly-needed addition (alpha variant of an existing color). Tag nodes should reuse `--color-faint` + shape. The checker should treat alpha-variants of existing palette colors as on-palette, not as a new design language.

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Nav entry label | `Graph` |
| Graph route page title | `Graph` |
| Local panel title | `Local graph` |
| Primary CTA | None. The graph UI has no primary write/submit CTA — its primary affordance is **click a node to open its page** (navigation, not a button). The edge toggles and depth control are secondary controls, not CTAs. |
| Edge-toggle chip labels | `Links` (page links, default ON) · `Backlinks` (default ON) · `Shared tags` (default **OFF**) |
| Depth control label | `Depth` (with value, e.g. `Depth: 1`); default 1 hop, range 1–3 (clamped to the endpoint's `[1,3]`) |
| Local panel collapse toggle (aria) | `Hide local graph` / `Show local graph` (mirrors AppShell's `Hide assistant` / `Show assistant`) |
| Global graph empty state heading | `No pages to graph yet` |
| Global graph empty state body | `Create and link a few pages, then come back to see how your workspace connects.` |
| Local graph empty state (no neighbors) | `This page has no links yet` (muted single line, mirrors `.backlinks-status` "No backlinks yet") |
| Loading state (global) | `Building the graph…` (muted, with the existing `.spinner`) |
| Loading state (local) | `Loading local graph…` (muted single line) |
| Error state (global) | `Couldn't load the graph. Refresh to try again.` (generic, hidden-Git-safe — no infra vocabulary; mirrors the BacklinksPanel error copy) |
| Error state (local) | `Couldn't load the local graph. Refresh to try again.` |
| Destructive confirmation | None — there are NO destructive actions in this phase (the graph is read-only navigation + view toggles). |

> Copy discipline: all status/empty/error lines are quiet, muted single lines using `--color-text-muted` + `--font-size-label`, matching the Phase-9 BacklinksPanel contract. Error copy carries zero git/sqlite/bleve/index vocabulary (hidden-Git invariant from Phase 9).

---

## Component / Chrome Inventory (executor reference)

| Component | Pattern to clone | Token notes |
|-----------|------------------|-------------|
| `/app/graph` route + `GraphView.tsx` | New `<Route>` in `App.tsx` wrapped in `AppShell` (like `/trash`, `/profile`); `React.lazy` the GraphView so the canvas lib code-splits | Full-pane component inside `.mainpane`; header bar uses `--color-surface` + `--space-md` like `.agentpanel-header` |
| Nav entry (left rail) | Add a `navrow` button in `.navrail-body` beside the Trash row (same `.navrow` + `.tree-icon` + `.tree-label` classes, lucide icon) | Active state when `location.pathname === "/app/graph"` → `--color-tree-active-bg` |
| Edge-toggle cluster | Row of small ghost chips, clone `.agentpanel-suggestion` styling | ON = `--color-accent-soft` bg + `--color-accent-border`; OFF = `--color-border`; `aria-pressed` reflects state; min-height `--hit-min-height` |
| Local-graph panel | Right-side collapsible dock cloned from `AgentPanel` (`.agentpanel` mirror: fixed width, `border-left`, header + body + collapse button); persisted open/collapse via zustand+persist like `agentPanel.ts` | Mounts in the page/editor view alongside the AgentPanel area; respects the `max-width: 1280px` floating-overlay rule like `.agentpanel` |
| Local-panel collapse toggle | Clone `.agentpanel-collapse` (icon button, `--hit-min-icon`) + the topbar `Assistant` toggle pattern | `PanelRightOpen`/`PanelRightClose` lucide icons; `aria-pressed` |
| Depth control | A small stepper or `<select className="select">` (1/2/3) OR ±buttons using `.btn .btn-ghost .icon-btn` | Default 1; clamps to endpoint `[1,3]`; auto-refetches `/graph/local?path=&depth=` on change |
| Canvas host | `ForceGraph2D` in a ref'd `useEffect` (LivePreviewEditor precedent); cleanup on unmount | `backgroundColor` = `--color-bg`; reads CSS vars via `getComputedStyle` for canvas draw colors |
| Empty / loading / error states | Clone `.agentpanel-empty` (centered, muted, optional glow orb) for the global empty state; `.backlinks-status` single-line for local/loading/error | `--color-text-muted`, `--font-size-label` |

---

## Interaction Contract (maps to GRAPH-01..05)

| # | Requirement | Contract |
|---|-------------|----------|
| GRAPH-01 | Global graph + pan/zoom + click-to-open | `/app/graph` renders all pages as nodes + link edges; canvas pan (drag) + zoom (wheel/pinch) are native to ForceGraph2D; **click a node → `navigate('/app/page/<id>')`** (reuse the BacklinksPanel navigate pattern; tag nodes are non-navigable or no-op). Force simulation settles then idles (cooldown) — no perpetual animation. |
| GRAPH-02 | Degree sizing + orphan distinction | Node radius scales with degree (connection count) within a tuned min/max range; orphan pages (no links, no tags) render with `--graph-node-orphan` (dimmer fill + outline) and minimum radius — visible, never hidden. |
| GRAPH-03 | Local panel + auto-update + depth | Right-side collapsible panel shows the current page + direct neighbors from `/graph/local?path=<active>&depth=<d>`; **auto-updates when the active page route changes** (react-query keyed on `[path, depth]`); depth control defaults to 1, range 1–3. |
| GRAPH-04 | Edge-type toggles | Three zustand booleans (Links ON, Backlinks ON, Shared tags **OFF**) filter the `edges` array via `useMemo` before handing to the graph. Toggling is client-only — never refetches. "Backlinks" reveals reverse-direction link edges (payload direction carries derivability per Phase 9). |
| GRAPH-05 | Hover-highlight | Hovering a node emphasizes that node + its immediate neighbors + connecting edges (`--color-text` / `--color-accent-2`) and **dims all other nodes/edges** to a low alpha. Leaving restores. Same emphasis applies on the local panel. Keyboard/`prefers-reduced-motion`: dimming is instantaneous (no required animation). |

---

## 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; the only new dependency is the npm package `react-force-graph-2d` (vetted in research SUMMARY: `peerDependencies: { react: "*" }`, no three.js, small dep tree). Registry vetting gate: not applicable. **Bundle gate (carry into plan):** assert `three` is absent from `web/package-lock.json` after install, and lazy-load the graph route.

---

## 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
