---
phase: 10-graph-ui
plan: 02
type: execute
wave: 2
depends_on: ["10-01"]
files_modified:
  - web/src/App.tsx
  - web/src/routes/AppShell.tsx
  - web/src/components/graph/GraphView.tsx
  - web/src/components/graph/GraphView.css
  - web/src/components/graph/GraphView.test.tsx
  - web/src/components/graph/EdgeToggles.tsx
  - web/src/components/graph/EdgeToggles.test.tsx
autonomous: true
requirements: [GRAPH-01, GRAPH-02, GRAPH-04, GRAPH-05]
user_setup: []

must_haves:
  truths:
    - "A nav entry labelled 'Graph' opens the lazy-loaded /app/graph route"
    - "The global graph renders all pages as nodes and links as edges, with pan/zoom"
    - "Clicking a page node navigates to /app/page/<id>"
    - "Node size reflects degree and orphan pages are visually distinct; the active page (if any) is accent-coloured"
    - "Edge-type toggle chips (Links / Backlinks / Shared tags) filter the edges client-side; Shared tags defaults OFF"
    - "Hovering a node highlights it + immediate neighbors + connecting edges and dims the rest"
  artifacts:
    - path: "web/src/components/graph/GraphView.tsx"
      provides: "the /app/graph full-pane graph component"
      contains: "GraphCanvas"
    - path: "web/src/components/graph/EdgeToggles.tsx"
      provides: "the Links/Backlinks/Shared tags toggle chip cluster"
      contains: "Shared tags"
    - path: "web/src/App.tsx"
      provides: "lazy /app/graph route inside AppShell"
      contains: "React.lazy"
    - path: "web/src/routes/AppShell.tsx"
      provides: "the 'Graph' nav entry in the navrail"
      contains: "/app/graph"
  key_links:
    - from: "web/src/App.tsx"
      to: "web/src/components/graph/GraphView.tsx"
      via: "React.lazy + Suspense route at /app/graph"
      pattern: "lazy\\(.*GraphView"
    - from: "web/src/components/graph/GraphView.tsx"
      to: "web/src/hooks/useGraph.ts"
      via: "useGraph() supplies the global payload"
      pattern: "useGraph"
    - from: "web/src/components/graph/GraphView.tsx"
      to: "web/src/components/graph/GraphCanvas.tsx"
      via: "renders GraphCanvas with filtered+classified data and click/hover handlers"
      pattern: "GraphCanvas"
---

<objective>
Ship the global graph view: a lazy-loaded `/app/graph` route reachable from a 'Graph' nav entry, rendering all pages as nodes and links as edges on the Canvas with pan/zoom (GRAPH-01), degree-sized nodes + orphan distinction + active-page accent (GRAPH-02), the edge-type toggle chips with shared-tag OFF by default (GRAPH-04), and hover-highlight of a node + its neighbors with the rest dimmed (GRAPH-05). Click a page node → open its page.

This plan consumes the Wave-1 core (10-01): `useGraph`, `GraphCanvas`, the pure helpers (`computeDegrees`/`isOrphan`, `filterEdges`, `neighborHighlight`), the `graphEdges` zustand slice, and the `--graph-node-orphan` token. It owns the route wiring (App.tsx), the nav entry (AppShell.tsx), and the GraphView + EdgeToggles components. It does NOT touch PageView (that is 10-03) — zero file overlap with the sibling Wave-2 plan.

Purpose: deliver the headline Obsidian-style global graph, lazy-loaded so the canvas library never bloats the initial editor bundle.
Output: lazy `/app/graph` route, 'Graph' nav entry, `GraphView` (canvas host + states), `EdgeToggles` chip cluster.
</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/10-graph-ui/10-CONTEXT.md
@.planning/phases/10-graph-ui/10-UI-SPEC.md
@.planning/phases/10-graph-ui/10-01-PLAN.md
</context>

<artifacts_this_phase_produces>
This plan (10-02) adds, on top of the 10-01 core:
- **Route:** a lazy-loaded `/app/graph` `<Route>` wrapped in `AppShell` (in `web/src/App.tsx`).
- **Nav entry:** a 'Graph' `navrow` button in the navrail (in `web/src/routes/AppShell.tsx`), active when on `/app/graph`.
- **GraphView component:** `web/src/components/graph/GraphView.tsx` + `.css` — the full-pane global graph (header, EdgeToggles, GraphCanvas, empty/loading/error states).
- **EdgeToggles component:** `web/src/components/graph/EdgeToggles.tsx` — the Links/Backlinks/Shared tags chip cluster bound to the `graphEdges` zustand slice.
(10-03 separately adds the LocalGraphPanel dock + PageView mount, consuming the same 10-01 core.)
</artifacts_this_phase_produces>

<tasks>

<task type="auto">
  <name>Task 1: EdgeToggles chip cluster (bound to the graphEdges slice)</name>
  <files>web/src/components/graph/EdgeToggles.tsx, web/src/components/graph/EdgeToggles.test.tsx</files>
  <read_first>
    web/src/stores/graphEdges.ts (the 10-01 zustand slice: `links`/`backlinks`/`sharedTags` + `toggle(kind)`)
    web/src/components/AgentPanel.css (`.agentpanel-suggestion` chip styling — the pattern EdgeToggles clones: ON = `--color-accent-soft` bg + `--color-accent-border`, OFF = `--color-border`)
    .planning/phases/10-graph-ui/10-UI-SPEC.md (Component/Chrome Inventory "Edge-toggle cluster"; Copywriting Contract: labels `Links` / `Backlinks` / `Shared tags`; `aria-pressed` reflects state; min-height `--hit-min-height`)
  </read_first>
  <action>
    Build `EdgeToggles` as a row of three small ghost toggle chips bound to the `graphEdges` zustand slice (per D — edge toggles are CLIENT state in zustand, not a backend concern; CONTEXT "Rendering library & edge toggles — LOCKED"). Labels exactly: `Links`, `Backlinks`, `Shared tags` (UI-SPEC Copywriting). Each chip is a `<button type="button">` with `aria-pressed` reflecting the toggle's boolean; clicking calls `toggle(kind)`. Clone the `.agentpanel-suggestion` chip styling for ON/OFF (ON uses `--color-accent-soft` + `--color-accent-border` + `--color-text`; OFF uses `--color-border`); min-height `--hit-min-height`. Token-only CSS (no hard-coded hex/px except the repo hairline-border convention if needed). Shared-tag chip reflects the slice default (OFF) — GRAPH-04.
  </action>
  <verify>
    <automated>cd web && npx tsc -b && npx vitest run src/components/graph/EdgeToggles.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - Three chips render with labels `Links`, `Backlinks`, `Shared tags`; `aria-pressed` matches the slice state.
    - On first render `Shared tags` is `aria-pressed="false"` (default OFF, GRAPH-04); `Links` and `Backlinks` are `aria-pressed="true"`.
    - Clicking a chip flips its `aria-pressed` (test asserts via the rendered button state after click).
    - No hard-coded color hex in EdgeToggles styling (token-only).
  </acceptance_criteria>
  <done>The edge-toggle cluster drives the zustand slice with correct defaults and accessible pressed state.</done>
</task>

<task type="auto">
  <name>Task 2: GraphView (canvas host, classification/filter/highlight wiring, states) + route + nav entry</name>
  <files>web/src/components/graph/GraphView.tsx, web/src/components/graph/GraphView.css, web/src/components/graph/GraphView.test.tsx, web/src/App.tsx, web/src/routes/AppShell.tsx</files>
  <read_first>
    web/src/components/graph/GraphCanvas.tsx (the 10-01 thin ForceGraph2D wrapper — props: data, onNodeClick, onNodeHover, draw callbacks, force params)
    web/src/hooks/useGraph.ts (the 10-01 `useGraph()` global hook)
    web/src/lib/graph/model.ts, filter.ts, highlight.ts (the 10-01 pure helpers: `computeDegrees`/`isOrphan`, `filterEdges`, `neighborHighlight`)
    web/src/stores/graphEdges.ts (the toggle state to read for `filterEdges`)
    web/src/App.tsx (lines 69-134: the `<Routes>` block; clone the `/trash` / `/profile` pattern — `<RequireAuth><AppShell><X/></AppShell></RequireAuth>`; this is where the lazy route goes)
    web/src/routes/AppShell.tsx (lines 636-720: the `.navrail-body` block with the Trash `navrow` button at ~695-702 — clone it for the Graph entry; `useLocation` is already imported for the active state)
    web/src/components/BacklinksPanel.tsx (the navigate-on-click pattern: `navigate('/app/page/' + path)`)
    .planning/phases/10-graph-ui/10-UI-SPEC.md (Interaction Contract GRAPH-01/02/04/05; Copywriting: nav label `Graph`, page title `Graph`, empty heading `No pages to graph yet` / body, loading `Building the graph…`, error `Couldn't load the graph. Refresh to try again.`; Component/Chrome Inventory rows for the route, nav entry, canvas host, states)
    web/src/components/LivePreviewEditor.test.tsx (how a canvas/imperative component is exercised under jsdom — render + assert DOM chrome, not pixels)
  </read_first>
  <action>
    Build `GraphView` as a full-pane component inside `.mainpane`: a header bar (title `Graph`, the `EdgeToggles` cluster) + the `GraphCanvas`. Fetch the global payload with `useGraph()` (per D — react-query for server state). Compute node classification with `computeDegrees`/`isOrphan` and filter edges with `filterEdges` over the `graphEdges` slice inside a `useMemo` (per D — edge filtering is client-only `useMemo`, never refetch; CONTEXT). Pass `data`, draw callbacks, and handlers to `GraphCanvas`:
    - GRAPH-01: pan/zoom is native to ForceGraph2D; `onNodeClick` for a PAGE node calls `navigate('/app/page/' + id)` (reuse the BacklinksPanel navigate pattern); tag nodes (`tag:` prefix) are non-navigable / no-op.
    - GRAPH-02: node radius scales with degree within a tuned min/max; orphan page nodes (degree 0, no tags) draw with `--graph-node-orphan` (dimmer fill + 1px `--color-border-strong` outline) at minimum radius — visible, never hidden; the active/current page node (if a `path` prop is supplied — for the global view it may be none) draws in `--color-accent`. Tag nodes draw in `--color-faint` with a distinct shape (e.g. diamond), NOT a rainbow.
    - GRAPH-04: the filtered edge set comes from `filterEdges` + the toggles; toggling never refetches.
    - GRAPH-05: maintain a hovered-node id in state; `onNodeHover` sets it; compute the highlight set with `neighborHighlight(hoverId, edges)` and in the draw callbacks emphasize focus+neighbors (`--color-text` / `--color-accent-2` for connecting edges) and dim everything else to low alpha; clearing hover restores. Dimming is instantaneous (respect `prefers-reduced-motion`).
    States (UI-SPEC Copywriting, quiet muted single lines like BacklinksPanel): loading `Building the graph…` (with the existing `.spinner`); error `Couldn't load the graph. Refresh to try again.` (generic, no infra vocabulary); empty (zero nodes) → centered `.agentpanel-empty`-style state with heading `No pages to graph yet` + the body copy.
    Add the lazy route in `App.tsx`: `const GraphView = React.lazy(() => import("./components/graph/GraphView"))` and a `<Route path="/app/graph" element={<RequireAuth><AppShell><Suspense fallback={…}><GraphView/></Suspense></AppShell></RequireAuth>} />` cloning the `/trash` pattern (per D — dedicated route + lazy-load so the canvas lib code-splits; CONTEXT "Global graph location — USER DECISION"). Import `Suspense`/`lazy` from react.
    Add the nav entry in `AppShell.tsx`: a `navrow` button in `.navrail-body` beside the Trash row (same `.navrow` + `.tree-icon` + `.tree-label` classes, a lucide `Network`/`Share2`/`Spline` icon), label `Graph`, `onClick={() => navigate("/app/graph")}`, with active styling (`--color-tree-active-bg`) when `location.pathname === "/app/graph"`.
    Stored-XSS guard: node labels are canvas-drawn text (or React text) — NEVER `dangerouslySetInnerHTML`. Token-only CSS for all chrome. GraphView mounts GraphCanvas only on the lazy route, so the umbrella library stays out of the initial bundle.
    The GraphView test runs under jsdom (canvas does not paint): assert the chrome renders (title `Graph`, the EdgeToggles chips, the empty/loading/error copy via mocked `useGraph`), and that the lazy route mounts. Do NOT assert canvas pixels — that is the human_verification note. If ForceGraph2D throws under jsdom on mount, guard the canvas mount or stub `react-force-graph-2d` in the test (vi.mock) so the surrounding chrome is still asserted.
  </action>
  <verify>
    <automated>cd web && npx tsc -b && npx vitest run src/components/graph/GraphView.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - `App.tsx` registers `/app/graph` via `React.lazy` + `Suspense` (code-split; grep for `lazy(` referencing GraphView).
    - `AppShell.tsx` renders a `Graph` navrow that navigates to `/app/graph` and shows active styling on that path.
    - GraphView renders title `Graph`, the EdgeToggles cluster, and — with a mocked empty `useGraph` — the `No pages to graph yet` empty state; with a mocked error, the exact error copy; loading shows `Building the graph…`.
    - The page-node click handler calls navigate to `/app/page/<id>` (asserted via a LocationProbe / router-state probe as in BacklinksPanel.test).
    - GraphView wires `filterEdges` over the `graphEdges` slice in a `useMemo` and computes `neighborHighlight` on hover (grep the source for `filterEdges`, `neighborHighlight`, `useMemo`).
    - No `dangerouslySetInnerHTML` in any new file; CSS is token-only (no hard-coded hex).
  </acceptance_criteria>
  <done>The global graph ships: a lazy `/app/graph` route + 'Graph' nav entry render the canvas with degree sizing, orphan distinction, client-side edge toggles (shared-tag OFF), hover-highlight, and click-to-open, with quiet empty/loading/error states.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → /api/v1/graph | global graph payload (page/tag ids + labels) rendered on the canvas |
| route navigation | a clicked node id becomes a `/app/page/<id>` route token |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-03 | Tampering (stored XSS) | node labels on canvas | mitigate | Labels are canvas-drawn text / React text — never `dangerouslySetInnerHTML`. |
| T-10-04 | Information disclosure | graph error state | mitigate | Error renders the generic `Couldn't load the graph. Refresh to try again.` copy (no git/sqlite/bleve/index tokens), mirroring Phase-9. |
| T-10-05 | DoS (bundle bloat) | lazy route | mitigate | GraphView is `React.lazy`-imported so `react-force-graph-2d` code-splits out of the initial editor bundle (CONTEXT bundle-discipline / research pitfall 6); the lockfile `three`-absence guard lives in 10-01. |
| T-10-SC | Tampering (supply chain) | new dependency | mitigate | Handled in 10-01 (legitimacy checkpoint + lockfile guard); no new dependency added in this plan. |
</threat_model>

<verification>
- `cd web && npx tsc -b` → exit 0.
- `cd web && npx vitest run` → full suite green incl. EdgeToggles + GraphView tests.
- `grep -c 'lazy(' web/src/App.tsx` shows the GraphView lazy import present.
- `grep -c '/app/graph' web/src/routes/AppShell.tsx` ≥ 1 (nav entry).
- A production SPA build (`cd web && npm run build`) emits a separate chunk for the graph route (manual/visual confirmation that the canvas lib is code-split).
</verification>

<success_criteria>
GRAPH-01 (global graph + pan/zoom + click-to-open), GRAPH-02 (degree sizing + orphan distinction + active accent), GRAPH-04 (edge toggles, shared-tag OFF default), and GRAPH-05 (hover-highlight) are delivered through a lazy-loaded `/app/graph` route + 'Graph' nav entry. Chrome, states, navigation, and toggle/highlight wiring are unit-tested; the canvas itself is verified by hand.

**human_verification (visual — jsdom cannot assert canvas pixels):**
1. Open `/app/graph` from the nav entry: all pages appear as nodes with link edges; pan (drag) and zoom (wheel) feel smooth; the force layout settles then idles (no perpetual animation).
2. Larger-degree nodes are visibly bigger; orphan pages are visible and dimmer/outlined; labels appear above the zoom threshold and on hover.
3. Toggle `Shared tags` ON → tag nodes + tag edges appear (distinct shape, not rainbow); it is OFF on first load. Toggle `Links`/`Backlinks` and confirm edges show/hide without a refetch.
4. Hover a node → it + its immediate neighbors + connecting edges stay bright while everything else dims; leaving restores.
5. Click a page node → the app navigates to that page.
</success_criteria>

<output>
Create `.planning/phases/10-graph-ui/10-02-SUMMARY.md` when done.
</output>
