---
phase: 10-graph-ui
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - web/package.json
  - web/package-lock.json
  - web/src/api/client.ts
  - web/src/hooks/useGraph.ts
  - web/src/hooks/useGraph.test.ts
  - web/src/stores/graphEdges.ts
  - web/src/stores/graphEdges.test.ts
  - web/src/lib/graph/model.ts
  - web/src/lib/graph/model.test.ts
  - web/src/lib/graph/filter.ts
  - web/src/lib/graph/filter.test.ts
  - web/src/lib/graph/highlight.ts
  - web/src/lib/graph/highlight.test.ts
  - web/src/components/graph/GraphCanvas.tsx
  - web/src/styles/tokens.css
autonomous: false
requirements: [GRAPH-01, GRAPH-02, GRAPH-03, GRAPH-04, GRAPH-05]
user_setup: []

must_haves:
  truths:
    - "react-force-graph-2d (Canvas, no three.js) is installed and `three` is absent from the lockfile"
    - "getGraph() and getLocalGraph(path, depth) fetch /api/v1/graph and /graph/local and return the typed-edge payload"
    - "The zustand edge-toggle slice defaults Links ON, Backlinks ON, Shared tags OFF"
    - "A pure helper classifies node degree + orphan status and a pure helper filters edges by the active toggles"
    - "A pure helper computes the hover-neighbor highlight set (focused node + immediate neighbors + connecting edges)"
    - "GraphCanvas mounts a ForceGraph2D instance in a ref and destroys it on unmount (no leak)"
  artifacts:
    - path: "web/src/api/client.ts"
      provides: "getGraph / getLocalGraph fns + GraphNode/GraphEdge/GraphData types"
      contains: "export async function getGraph"
    - path: "web/src/hooks/useGraph.ts"
      provides: "useGraph() + useLocalGraph(path, depth) react-query hooks"
      contains: "useQuery"
    - path: "web/src/stores/graphEdges.ts"
      provides: "zustand edge-toggle slice (links/backlinks/sharedTags booleans, persisted)"
      contains: "sharedTags: false"
    - path: "web/src/lib/graph/model.ts"
      provides: "pure degree + orphan classification helper"
      contains: "export function"
    - path: "web/src/lib/graph/filter.ts"
      provides: "pure edge-filter helper over the toggle state"
      contains: "export function"
    - path: "web/src/lib/graph/highlight.ts"
      provides: "pure hover-neighbor highlight-set helper"
      contains: "export function"
    - path: "web/src/components/graph/GraphCanvas.tsx"
      provides: "thin ForceGraph2D ref-lifecycle wrapper (prop-driven)"
      contains: "react-force-graph-2d"
  key_links:
    - from: "web/src/hooks/useGraph.ts"
      to: "web/src/api/client.ts"
      via: "useGraph calls getGraph; useLocalGraph calls getLocalGraph"
      pattern: "getGraph|getLocalGraph"
    - from: "web/src/components/graph/GraphCanvas.tsx"
      to: "react-force-graph-2d"
      via: "imports ForceGraph2D and owns the instance in a ref"
      pattern: "react-force-graph-2d"
---

<objective>
Build the testable, non-visual core of the Graph UI: install the single new frontend dependency (`react-force-graph-2d`, Canvas-only, no three.js), add the api client fns + react-query hooks for the Phase-9 `/graph` and `/graph/local` endpoints, add the zustand edge-toggle slice, write the PURE helpers (degree/orphan classification, edge filtering, hover-neighbor highlight set), add the one new canvas color token, and ship the thin ForceGraph2D ref-lifecycle wrapper that the two Wave-2 UI plans consume.

This plan front-loads everything that vitest CAN assert so the canvas components in Wave 2 stay thin. It contributes to all five GRAPH requirements (GRAPH-01..05) by supplying the data plumbing, edge-toggle state (GRAPH-04), node classification (GRAPH-02), highlight logic (GRAPH-05), and the canvas host (GRAPH-01) the UI plans wire up.

Purpose: a single source of truth for graph data fetching, edge-toggle state, and the pure layout/classification/highlight logic — so the canvas stays a thin renderer and the logic is unit-tested.
Output: new dep + lockfile guard, `getGraph`/`getLocalGraph` + types in client.ts, `useGraph`/`useLocalGraph` hooks, `graphEdges` zustand slice, three pure helper modules with tests, `GraphCanvas.tsx` wrapper, and the `--graph-node-orphan` token.
</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/09-graph-endpoints-backlinks-panel/09-01-SUMMARY.md
</context>

<artifacts_this_phase_produces>
This plan (10-01) produces the testable core consumed by 10-02 (GraphView) and 10-03 (LocalGraphPanel):
- **New dependency:** `react-force-graph-2d` (~v1.29.x, the `-2d` Canvas subpackage ONLY; NEVER the umbrella `react-force-graph`).
- **api fns + types:** `getGraph()`, `getLocalGraph(path, depth)`, `GraphNode`/`GraphEdge`/`GraphData` in `web/src/api/client.ts`.
- **hooks:** `useGraph()`, `useLocalGraph(path, depth)` in `web/src/hooks/useGraph.ts`.
- **zustand slice:** `graphEdges` (links/backlinks/sharedTags toggles) in `web/src/stores/graphEdges.ts`.
- **pure helpers:** `web/src/lib/graph/model.ts` (degree + orphan), `filter.ts` (edge filter), `highlight.ts` (hover neighbor set).
- **canvas wrapper:** `web/src/components/graph/GraphCanvas.tsx` (thin ForceGraph2D ref-lifecycle host).
- **token:** `--graph-node-orphan` added to `web/src/styles/tokens.css`.
(10-02 adds the `/app/graph` route + nav entry + GraphView; 10-03 adds the LocalGraphPanel dock + PageView mount.)
</artifacts_this_phase_produces>

<tasks>

<task type="checkpoint:human-verify" gate="blocking-human">
  <name>Task 1: Verify react-force-graph-2d package legitimacy before install</name>
  <files>web/package.json (read-only at this step — no write until approval)</files>
  <read_first>
    .planning/phases/10-graph-ui/10-UI-SPEC.md (Design System table: "react-force-graph-2d ~v1.29.x — the 2D Canvas subpackage ONLY")
    .planning/phases/10-graph-ui/10-CONTEXT.md (Rendering library & edge toggles — LOCKED)
  </read_first>
  <action>Do NOT run any install yet. Present the package-legitimacy evidence for the ONE new frontend dependency and pause for human approval. State the verified facts for `react-force-graph-2d@~1.29.x`: it is the Canvas-only `-2d` subpackage (not the umbrella `react-force-graph`), peerDependencies `{ react: "*" }`, runtime deps `force-graph` / `prop-types` / `react-kapsule`, and NO three.js anywhere in the tree. Hold here until the reviewer types approval; only then does Task 2 install it.</action>
  <what-built>Package legitimacy gate for the ONE new frontend dependency this phase. Research already vetted `react-force-graph-2d@1.29.1`: peerDependencies `{ react: "*" }`, runtime deps `force-graph` / `prop-types` / `react-kapsule`, and NO three.js anywhere. This checkpoint confirms before any `npm install` runs.</what-built>
  <how-to-verify>
    1. Open https://www.npmjs.com/package/react-force-graph-2d and confirm it exists, is actively published (latest ~v1.29.x), and is the `-2d` Canvas subpackage (NOT the umbrella `react-force-graph`).
    2. Confirm in the npm "Dependencies" tab that `three` does NOT appear (deps should be `force-graph`, `prop-types`, `react-kapsule`).
    3. Confirm `peerDependencies` lists `react: "*"` (compatible with React 19).
  </how-to-verify>
  <resume-signal>Type "approved" to proceed with install, or describe a concern.</resume-signal>
  <acceptance_criteria>
    - The package is confirmed legitimate on npmjs.com and is the Canvas-only `-2d` subpackage.
    - `three` is confirmed absent from its dependency tree.
  </acceptance_criteria>
  <verify>
    <human-check>Reviewer confirms npmjs.com listing matches the research vetting (no three.js, react peer dep).</human-check>
  </verify>
  <done>Reviewer has approved the package; install may proceed in Task 2.</done>
</task>

<task type="auto">
  <name>Task 2: Install react-force-graph-2d, add the orphan token, add the lockfile three-absence guard</name>
  <files>web/package.json, web/package-lock.json, web/src/styles/tokens.css</files>
  <read_first>
    web/package.json (existing deps — confirm only this one is added)
    web/src/styles/tokens.css (existing token block; the `--color-*` palette and the `--graph-*` insertion point)
    .planning/phases/10-graph-ui/10-UI-SPEC.md (Color § "New tokens": `--graph-node-orphan` = `rgba(141, 150, 168, 0.45)`)
  </read_first>
  <action>
    From `web/`, install ONLY `react-force-graph-2d` (the Canvas-only `-2d` subpackage) per D (CONTEXT "Rendering library & edge toggles — LOCKED" / UI-SPEC Design System). Do NOT install the umbrella `react-force-graph` and do NOT install `three`. Run `npm install react-force-graph-2d@^1.29.1` so package.json + package-lock.json both update.
    Add exactly one new design token to `web/src/styles/tokens.css` in the color section: `--graph-node-orphan` with value `rgba(141, 150, 168, 0.45)` (the existing `--color-text-muted` #8d96a8 at ~45% alpha — an on-palette alpha variant, not a new hue, per UI-SPEC Color § "New tokens"). Tag nodes reuse the existing `--color-faint` (no new token). Add a brief comment that this is the orphan-node canvas fill (GRAPH-02).
    Do NOT add any other token, dependency, or backend change — the Phase-9 endpoints are read as-is (no backend change this phase).
  </action>
  <verify>
    <automated>cd web && grep -q '"react-force-graph-2d"' package.json && ! grep -q '"three"' package-lock.json && grep -q -- '--graph-node-orphan' src/styles/tokens.css && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - `react-force-graph-2d` appears in `web/package.json` dependencies; no `react-force-graph` (umbrella) and no `three` entry added.
    - `grep -v '^#' web/package-lock.json | grep -c '"three"'` returns 0 (three never entered the lockfile).
    - `--graph-node-orphan: rgba(141, 150, 168, 0.45)` is present in `tokens.css`; no other new token added.
  </acceptance_criteria>
  <done>The new dep is installed (Canvas-only), the lockfile contains no `three`, and the single orphan token exists.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: api fns + types, react-query hooks, zustand edge-toggle slice, and the three PURE helpers (with tests)</name>
  <files>web/src/api/client.ts, web/src/hooks/useGraph.ts, web/src/hooks/useGraph.test.ts, web/src/stores/graphEdges.ts, web/src/stores/graphEdges.test.ts, web/src/lib/graph/model.ts, web/src/lib/graph/model.test.ts, web/src/lib/graph/filter.ts, web/src/lib/graph/filter.test.ts, web/src/lib/graph/highlight.ts, web/src/lib/graph/highlight.test.ts, web/src/components/graph/GraphCanvas.tsx</files>
  <read_first>
    web/src/api/client.ts (the `getBacklinks` + `Backlink` pattern at lines ~224-244, `getTree` at ~215-222; clone the GET shape: `fetch(url, { credentials: "same-origin" })`, generic error copy on `!res.ok`, no CSRF on GET)
    web/src/hooks/useBacklinks.ts (the react-query read-hook convention: queryKey, `enabled`, `staleTime: 30_000`, no debounce)
    web/src/stores/agentPanel.ts (the zustand+persist slice convention; persisted key `okf.agent.panelOpen`)
    web/src/components/LivePreviewEditor.tsx (lines ~84-196: the ref-lifecycle precedent — own the imperative instance in a `useRef`, create once in a `useEffect`, ALWAYS `destroy()` in cleanup for React 19 StrictMode double-mount)
    .planning/phases/09-graph-endpoints-backlinks-panel/09-01-SUMMARY.md ("Next Plan Readiness" — the exact payload: `{nodes:[{id,label,type}], edges:[{source,target,type}]}`, type ∈ {page,tag}/{link,tag}; `/graph/local?path=&depth=` depth 1, clamped ≤3; tag node ids namespaced `tag:<name>`)
    .planning/phases/10-graph-ui/10-UI-SPEC.md (Interaction Contract GRAPH-02/04/05 — degree sizing, orphan = no links AND no tags, edge filter is client-only useMemo never refetch, hover = focused + immediate neighbors + connecting edges, dim rest)
    web/src/test/setup.ts (jsdom limits — canvas does not render; keep GraphCanvas thin and DO NOT unit-test pixels)
  </read_first>
  <behavior>
    - model.ts `computeDegrees(nodes, edges)`: returns a map node-id → degree (count of edges touching the node as source or target). Test: a 3-node chain A-B-C gives B degree 2, A and C degree 1.
    - model.ts `isOrphan(nodeId, degrees, nodeType)`: a PAGE node with degree 0 is an orphan; a tag node is never an orphan; a page with ≥1 edge is not an orphan. Test each case.
    - filter.ts `filterEdges(edges, toggles)`: keeps `link`-type edges when `links` is true; keeps `tag`-type (shared-tag/membership) edges when `sharedTags` is true; drops them when off. Backlinks are derived from link-edge direction (Phase-9 carries direction, no separate backlink type) — when `backlinks` is false, hide reverse-direction link edges per the UI-SPEC GRAPH-04 contract. Test: with `{links:true, backlinks:true, sharedTags:false}` only link edges survive; flipping sharedTags on adds tag edges; the default toggle set produces no tag edges.
    - highlight.ts `neighborHighlight(focusId, edges)`: returns `{nodes:Set, edges:Set}` = the focus node + every node sharing an edge with it + those connecting edges. Test: focusing B in A-B-C returns nodes {A,B,C} and both edges; focusing an isolated node returns just itself and no edges; null/undefined focus returns empty sets.
    - graphEdges slice: initial state `{links:true, backlinks:true, sharedTags:false}` (GRAPH-04 shared-tag OFF default) with a `toggle(kind)` action; persisted (mirror agentPanel persist). Test: default `sharedTags === false`; `toggle("sharedTags")` flips it; `toggle("links")` flips links.
    - useGraph/useLocalGraph: thin react-query wrappers. Test (mock the api fns): `useLocalGraph` is gated `enabled: path !== ""`, queryKey is `["graph","local",path,depth]` so it auto-refetches when path OR depth changes; `useGraph` queryKey is `["graph","global"]`.
  </behavior>
  <action>
    Write the tests FIRST for model.ts, filter.ts, highlight.ts, graphEdges.ts, and useGraph.ts per the behavior block (RED), then implement to green.
    In `web/src/api/client.ts`: add `GraphNode { id: string; label: string; type: "page" | "tag" }`, `GraphEdge { source: string; target: string; type: "link" | "tag" }`, `GraphData { nodes: GraphNode[]; edges: GraphEdge[] }`. Add `getGraph(): Promise<GraphData>` → GET `/api/v1/graph`, and `getLocalGraph(path, depth): Promise<GraphData>` → GET `/api/v1/graph/local?path=${encodeURIComponent(path)}&depth=${depth}`. Clone the `getBacklinks` GET shape exactly (credentials same-origin, no CSRF, throw generic "Couldn't load the graph." copy on `!res.ok` — hidden-Git safe, no index/git/sqlite vocabulary per the UI-SPEC error contract).
    In `web/src/hooks/useGraph.ts`: `useGraph()` (queryKey `["graph","global"]`, staleTime 30_000) and `useLocalGraph(path, depth)` (queryKey `["graph","local",path,depth]`, `enabled: path !== ""`, staleTime 30_000), mirroring `useBacklinks`.
    In `web/src/stores/graphEdges.ts`: a zustand+persist slice mirroring `agentPanel.ts` — `{ links: true, backlinks: true, sharedTags: false, toggle: (kind) => ... }`, persisted under key `okf.graph.edges`.
    In `web/src/lib/graph/model.ts`, `filter.ts`, `highlight.ts`: the three pure functions per the behavior block. No React, no DOM, no canvas — pure data in/out.
    In `web/src/components/graph/GraphCanvas.tsx`: a THIN prop-driven wrapper around `ForceGraph2D` from `react-force-graph-2d`. Props: `data` (nodes+links shaped for the lib), `onNodeClick`, `onNodeHover`, and draw-callback props (nodeCanvasObject / link styling) supplied by the parent — GraphCanvas does NOT compute classification/filtering/highlight itself (those come from the pure helpers via the parent). Own the ForceGraph2D ref with the LivePreviewEditor discipline: create once, expose the ref, and clean up on unmount (call the lib's cleanup / null the ref) so React 19 StrictMode double-mount does not leak an instance. Force-tuning params (charge, link distance, cooldownTicks, label-on-zoom threshold) are accepted as props with sensible defaults at Claude's discretion (CONTEXT "Claude's Discretion"); a short tuning pass is expected. Read canvas draw colors from CSS vars via `getComputedStyle` (so tokens drive the canvas). Do NOT unit-test the canvas rendering (jsdom can't); its correctness is covered by the human_verification note + the pure helpers it consumes.
    Stored-XSS guard: node labels are drawn as canvas text or rendered as React text — NEVER via `dangerouslySetInnerHTML`.
  </action>
  <verify>
    <automated>cd web && npx tsc -b && npx vitest run src/lib/graph/model.test.ts src/lib/graph/filter.test.ts src/lib/graph/highlight.test.ts src/stores/graphEdges.test.ts src/hooks/useGraph.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `npx tsc -b` exits 0 with the new files.
    - All five test files pass: model (degree + orphan), filter (toggle-driven edge filtering, shared-tag default off), highlight (neighbor set), graphEdges (default sharedTags false), useGraph (queryKey includes path+depth, local gated on non-empty path).
    - `getGraph`/`getLocalGraph` exist in client.ts and throw the generic non-infrastructure error copy on failure.
    - `GraphCanvas.tsx` imports from `react-force-graph-2d` (not the umbrella) and destroys/cleans its instance on unmount.
    - No `dangerouslySetInnerHTML` anywhere in the new files.
  </acceptance_criteria>
  <done>Data fns/hooks, the edge-toggle slice, and the three pure helpers are implemented and unit-tested green; GraphCanvas is a thin, leak-free ForceGraph2D wrapper ready for both UI plans.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → /api/v1/graph(/local) | authed read responses (page/tag ids + labels) rendered into the SPA |
| npm registry → build | the new `react-force-graph-2d` dependency enters the supply chain |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-01 | Tampering (stored XSS) | node labels drawn from server payload | mitigate | Labels rendered as canvas text or React text children only — never `dangerouslySetInnerHTML`; the payload carries ids+labels only (Phase-9 lean payload, no bodies). |
| T-10-02 | Information disclosure | graph error copy | mitigate | `getGraph`/`getLocalGraph` throw generic "Couldn't load the graph." copy on `!res.ok` (no git/sqlite/bleve/index vocabulary), mirroring the Phase-9 BacklinksPanel contract. |
| T-10-SC | Tampering (supply chain) | `react-force-graph-2d` install | mitigate | Blocking human legitimacy checkpoint (Task 1) verifies the package on npmjs.com; lockfile `three`-absence assertion (Task 2) proves the umbrella/three.js path was not pulled. |
</threat_model>

<verification>
- `cd web && npx tsc -b` → exit 0.
- `cd web && npx vitest run` → full suite green (existing 295+ tests plus the new graph helper/hook/store tests).
- `grep -v '^#' web/package-lock.json | grep -c '"three"'` → 0.
- `grep -c '"react-force-graph-2d"' web/package.json` → ≥1.
- `grep -c -- '--graph-node-orphan' web/src/styles/tokens.css` → ≥1.
</verification>

<success_criteria>
The new Canvas-only dependency is installed with `three` provably absent from the lockfile; the graph api fns + hooks fetch the Phase-9 endpoints; the zustand edge-toggle slice defaults shared-tag OFF; the three pure helpers (degree/orphan, edge filter, hover-neighbor set) pass unit tests; and `GraphCanvas` is a thin, leak-free ForceGraph2D wrapper. This is the tested foundation both Wave-2 UI plans build on.

**human_verification:** none required in this plan — every deliverable here is unit-testable. The genuinely visual bits (force-layout feel, pan/zoom, canvas appearance, hover dimming) are verified in 10-02 and 10-03 where the canvas is actually mounted.
</success_criteria>

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