---
phase: 10-graph-ui
plan: 03
type: execute
wave: 2
depends_on: ["10-01"]
files_modified:
  - web/src/routes/PageView.tsx
  - web/src/stores/localGraphPanel.ts
  - web/src/stores/localGraphPanel.test.ts
  - web/src/components/graph/LocalGraphPanel.tsx
  - web/src/components/graph/LocalGraphPanel.css
  - web/src/components/graph/LocalGraphPanel.test.tsx
  - web/src/components/graph/DepthControl.tsx
  - web/src/components/graph/DepthControl.test.tsx
autonomous: true
requirements: [GRAPH-03, GRAPH-04, GRAPH-05]
user_setup: []

must_haves:
  truths:
    - "A right-side collapsible 'Local graph' panel shows the current page + its direct neighbors"
    - "The panel auto-updates when the active page route changes"
    - "A depth control defaults to 1 hop and clamps to range 1-3, re-fetching /graph/local on change"
    - "The current page node is accent-coloured; orphan/tag nodes follow the same visual language as the global view"
    - "Edge-type toggles and hover-highlight behave the same as the global graph"
    - "The panel open/collapse state is persisted (mirrors the AgentPanel dock)"
  artifacts:
    - path: "web/src/components/graph/LocalGraphPanel.tsx"
      provides: "the right-side collapsible local-graph dock"
      contains: "useLocalGraph"
    - path: "web/src/components/graph/DepthControl.tsx"
      provides: "the depth (1-3, default 1) control"
      contains: "Depth"
    - path: "web/src/stores/localGraphPanel.ts"
      provides: "persisted open/collapse + depth zustand slice"
      contains: "persist"
    - path: "web/src/routes/PageView.tsx"
      provides: "LocalGraphPanel mounted in the page read view"
      contains: "LocalGraphPanel"
  key_links:
    - from: "web/src/components/graph/LocalGraphPanel.tsx"
      to: "web/src/hooks/useGraph.ts"
      via: "useLocalGraph(path, depth) keyed on path+depth so it auto-updates"
      pattern: "useLocalGraph"
    - from: "web/src/routes/PageView.tsx"
      to: "web/src/components/graph/LocalGraphPanel.tsx"
      via: "mounts <LocalGraphPanel path={path}/> in the success branch"
      pattern: "LocalGraphPanel"
    - from: "web/src/components/graph/LocalGraphPanel.tsx"
      to: "web/src/components/graph/GraphCanvas.tsx"
      via: "renders the same GraphCanvas with the neighborhood payload"
      pattern: "GraphCanvas"
---

<objective>
Ship the per-page local graph: a right-side collapsible 'Local graph' panel (mirroring the AgentPanel dock) that shows the current page plus its direct neighbors from `/api/v1/graph/local`, auto-updates when the active page changes, and offers a depth control (default 1 hop, range 1-3) — GRAPH-03. The same edge-type toggles (GRAPH-04) and hover-highlight (GRAPH-05) behaviors as the global view apply locally, and the current page node is accent-coloured.

This plan consumes the Wave-1 core (10-01): `useLocalGraph`, `GraphCanvas`, the pure helpers, the `graphEdges` slice, and the `--graph-node-orphan` token. It owns the LocalGraphPanel dock, its persist slice, the DepthControl, and the PageView mount. It does NOT touch App.tsx or AppShell.tsx (those are 10-02) — zero file overlap with the sibling Wave-2 plan, so both run in parallel.

Purpose: bring the graph to the page the user is reading — a docked neighborhood view that follows the active page.
Output: `LocalGraphPanel` (collapsible dock + canvas + states), `DepthControl`, the `localGraphPanel` persist slice, and the PageView mount.
</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
@.planning/phases/09-graph-endpoints-backlinks-panel/09-02-SUMMARY.md
</context>

<artifacts_this_phase_produces>
This plan (10-03) adds, on top of the 10-01 core:
- **LocalGraphPanel:** `web/src/components/graph/LocalGraphPanel.tsx` + `.css` — the right-side collapsible 'Local graph' dock cloned from `AgentPanel` (header + body + collapse toggle), hosting `GraphCanvas` for the neighborhood payload, with the EdgeToggles + DepthControl and quiet empty/loading/error states.
- **DepthControl:** `web/src/components/graph/DepthControl.tsx` — the 1-3 (default 1) depth selector that re-fetches `/graph/local`.
- **Persist slice:** `web/src/stores/localGraphPanel.ts` — open/collapse + depth, persisted (mirrors `agentPanel.ts`).
- **PageView mount:** `<LocalGraphPanel path={path}/>` added to `web/src/routes/PageView.tsx` (additive, beside the BacklinksPanel mount).
(10-02 separately adds the global `/app/graph` route + nav entry + GraphView, consuming the same 10-01 core. The EdgeToggles component is created in 10-02; this plan imports and reuses it.)
</artifacts_this_phase_produces>

<dependency_note>
Both 10-02 and 10-03 depend only on 10-01 and touch disjoint files, so they run in the same wave (Wave 2). The `EdgeToggles` component is authored in 10-02. To keep this plan independently executable if run before/parallel to 10-02, the executor reads the `graphEdges` zustand slice directly (from 10-01) — if `EdgeToggles` already exists, import it; otherwise render the same three chips inline bound to the slice. Either way the toggle state is the single shared `graphEdges` slice, so global and local views stay in sync. The shared `GraphCanvas` and all pure helpers come from 10-01, so there is no code-ownership conflict with 10-02.
</dependency_note>

<tasks>

<task type="auto">
  <name>Task 1: localGraphPanel persist slice + DepthControl</name>
  <files>web/src/stores/localGraphPanel.ts, web/src/stores/localGraphPanel.test.ts, web/src/components/graph/DepthControl.tsx, web/src/components/graph/DepthControl.test.tsx</files>
  <read_first>
    web/src/stores/agentPanel.ts (the zustand+persist dock-state convention; persisted key naming `okf.agent.panelOpen`)
    .planning/phases/10-graph-ui/10-UI-SPEC.md (Copywriting: depth label `Depth` with value e.g. `Depth: 1`, default 1, range 1-3 clamped to endpoint `[1,3]`; Component/Chrome Inventory "Depth control" — a small stepper or `<select className="select">`)
    .planning/phases/09-graph-endpoints-backlinks-panel/09-01-SUMMARY.md (the endpoint clamps depth to [1,3]; default 1)
  </read_first>
  <action>
    Build `localGraphPanel.ts` as a zustand+persist slice mirroring `agentPanel.ts` (per D — right-side collapsible panel; CONTEXT "Local graph panel — USER DECISION"): `{ open: boolean, depth: number, setOpen, toggle, setDepth }`. Defaults: `open: false` (collapsed by default so it doesn't crowd the editor — CONTEXT "collapsible/toggleable so it doesn't crowd the editor"), `depth: 1` (GRAPH-03 default 1 hop). `setDepth` clamps to the integer range [1,3] (matching the endpoint clamp). Persist under key `okf.graph.localPanel`.
    Build `DepthControl.tsx`: a small control (a `<select className="select">` with options 1/2/3, OR ± stepper buttons using `.btn .btn-ghost .icon-btn`) showing label `Depth` with the current value; changing it calls `setDepth` (which clamps). Token-only CSS, `--hit-min-height`. The value is the single source for the `useLocalGraph` depth arg.
    Write tests: the slice defaults `open === false`, `depth === 1`; `setDepth(5)` clamps to 3; `setDepth(0)` clamps to 1; `toggle()` flips open. DepthControl renders the three options/steps, shows the current depth, and calls setDepth on change (assert via the slice or a spy).
  </action>
  <verify>
    <automated>cd web && npx tsc -b && npx vitest run src/stores/localGraphPanel.test.ts src/components/graph/DepthControl.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - `localGraphPanel` slice defaults `open=false`, `depth=1`; `setDepth` clamps out-of-range values into [1,3]; persisted under `okf.graph.localPanel`.
    - `DepthControl` shows `Depth` + the current value, offers 1/2/3, and updates depth on change.
    - No hard-coded color hex in DepthControl CSS (token-only).
  </acceptance_criteria>
  <done>The persisted panel/depth state and the depth control exist and are unit-tested with correct defaults and clamping.</done>
</task>

<task type="auto">
  <name>Task 2: LocalGraphPanel dock (canvas + toggles + depth + states) and PageView mount</name>
  <files>web/src/components/graph/LocalGraphPanel.tsx, web/src/components/graph/LocalGraphPanel.css, web/src/components/graph/LocalGraphPanel.test.tsx, web/src/routes/PageView.tsx</files>
  <read_first>
    web/src/components/AgentPanel.tsx (the dock structure to clone: `<aside className="agentpanel">`, header with title + collapse button, body, footer-ish controls)
    web/src/components/AgentPanel.css (the `.agentpanel` mirror: fixed width, `border-left`, header/body padding, the `@media (max-width:1280px)` floating-overlay rule, `.agentpanel-collapse` icon button at `--hit-min-icon`, `.agentpanel-empty` state)
    web/src/stores/localGraphPanel.ts (the 10-03 panel/depth slice)
    web/src/stores/graphEdges.ts (the shared 10-01 toggle slice; read directly)
    web/src/components/graph/EdgeToggles.tsx (authored in 10-02 — import if present; otherwise render the same three chips inline bound to graphEdges per the dependency_note)
    web/src/components/graph/DepthControl.tsx (the 10-03 depth control)
    web/src/hooks/useGraph.ts (the 10-01 `useLocalGraph(path, depth)` hook — queryKey `["graph","local",path,depth]`, gated on non-empty path)
    web/src/components/graph/GraphCanvas.tsx (the 10-01 thin ForceGraph2D wrapper)
    web/src/lib/graph/model.ts, filter.ts, highlight.ts (the 10-01 pure helpers — same classification/filter/highlight wiring as GraphView)
    web/src/routes/PageView.tsx (lines 84-130: the success-branch JSX where `<BacklinksPanel path={path}/>` mounts at line ~130 — add `<LocalGraphPanel path={path}/>` additively; the panel renders only on success, never on the 404/error early-returns)
    web/src/components/BacklinksPanel.test.tsx (the MemoryRouter + QueryClientProvider + LocationProbe harness + mocked api to assert chrome and click-navigate under jsdom)
    .planning/phases/10-graph-ui/10-UI-SPEC.md (Component/Chrome Inventory "Local-graph panel" + "Local-panel collapse toggle"; Interaction Contract GRAPH-03/04/05; Copywriting: title `Local graph`, collapse aria `Hide local graph`/`Show local graph`, empty `This page has no links yet`, loading `Loading local graph…`, error `Couldn't load the local graph. Refresh to try again.`)
  </read_first>
  <action>
    Build `LocalGraphPanel({ path })` as a right-side collapsible dock cloning the `AgentPanel` structure + tokens (per D — right-side collapsible panel mirroring the AgentPanel dock; CONTEXT). Header: title `Local graph` + a collapse icon button (`PanelRightClose`/`PanelRightOpen` lucide) driving `localGraphPanel.toggle`, aria `Hide local graph`/`Show local graph`. When collapsed (`open === false`) render only the affordance to reopen (or nothing in the page flow per the AgentPanel `if (!open) return null` pattern plus a show toggle) — collapsed by default so it never crowds the editor. Persisted open/collapse + depth via the `localGraphPanel` slice.
    When open: render the EdgeToggles cluster + the DepthControl in the header/controls area, then `GraphCanvas` for the neighborhood. Fetch with `useLocalGraph(path, depth)` where `depth` is from the slice (per D — react-query keyed `[path, depth]`); the query auto-updates when the active page route changes (path is the PageView route param) and when depth changes (GRAPH-03 auto-update + depth control). Apply the SAME wiring as GraphView via the shared 10-01 helpers: `computeDegrees`/`isOrphan` for sizing+orphan, `filterEdges` over the shared `graphEdges` slice in a `useMemo` (GRAPH-04 — toggles never refetch), `neighborHighlight` on hover (GRAPH-05 — focus+neighbors bright, dim the rest). The current/seed page node draws in `--color-accent` (GRAPH-02 active-page accent); tag nodes in `--color-faint` distinct shape; orphan handling identical to the global view. Node click navigates to `/app/page/<id>` (reuse the BacklinksPanel navigate pattern); tag nodes no-op.
    States (quiet muted single lines like BacklinksPanel): loading `Loading local graph…`; error `Couldn't load the local graph. Refresh to try again.` (generic, no infra vocabulary); empty (seed only, no neighbors) `This page has no links yet`.
    Mount in `PageView.tsx`: add `<LocalGraphPanel path={path} />` in the success branch immediately after/near the existing `<BacklinksPanel path={path} />` (additive; the CM6 read surface and the 404/error early-returns are untouched). It honors its own persisted collapse state, so a reader who never opens it pays no canvas cost.
    Stored-XSS guard: node labels canvas-drawn / React text — NEVER `dangerouslySetInnerHTML`. Token-only CSS; the floating-overlay `@media (max-width:1280px)` behavior mirrors `.agentpanel`. Respect `prefers-reduced-motion` (instant dimming).
    The LocalGraphPanel test runs under jsdom (no canvas paint): mock `useLocalGraph` to assert the chrome (title `Local graph`, EdgeToggles chips, DepthControl, empty/loading/error copy), assert the collapse toggle flips `aria-pressed`/state, and assert changing depth re-keys the query (depth value reflected). If ForceGraph2D throws under jsdom, `vi.mock("react-force-graph-2d")` so the surrounding chrome is still asserted. Update `PageView.test.tsx` only if the new mount requires the api mock to include the local-graph fn (mirror how 09-02 added `getBacklinks: vi.fn()`); keep existing PageView tests green.
  </action>
  <verify>
    <automated>cd web && npx tsc -b && npx vitest run src/components/graph/LocalGraphPanel.test.tsx src/routes/PageView.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - LocalGraphPanel renders title `Local graph`, a collapse toggle with aria `Hide local graph`/`Show local graph`, the EdgeToggles cluster, and the DepthControl when open.
    - With a mocked empty neighborhood it shows `This page has no links yet`; with a mocked error, `Couldn't load the local graph. Refresh to try again.`; loading shows `Loading local graph…`.
    - `useLocalGraph` is called with the route `path` and the slice `depth`; the query re-keys when depth changes (assert depth value drives the hook arg).
    - The panel default state is collapsed (`open=false`) and persists via `localGraphPanel`.
    - `PageView.tsx` mounts `<LocalGraphPanel path={path}/>` additively in the success branch; existing PageView tests still pass.
    - No `dangerouslySetInnerHTML` in any new file; CSS is token-only (no hard-coded hex).
  </acceptance_criteria>
  <done>The per-page local graph ships: a persisted, collapsible right-side 'Local graph' dock that auto-updates with the active page, offers a 1-3 depth control, applies the shared edge toggles + hover-highlight, accents the current page, and navigates on node click — mounted additively in PageView.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → /api/v1/graph/local | neighborhood payload (page/tag ids + labels) rendered on the canvas |
| route param → query | the active page `path` becomes the `?path=` arg of the local endpoint |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-10-06 | Tampering (stored XSS) | neighbor node labels | mitigate | Labels canvas-drawn / React text — never `dangerouslySetInnerHTML`. |
| T-10-07 | Information disclosure | local-graph error state | mitigate | Error renders generic `Couldn't load the local graph. Refresh to try again.` (no git/sqlite/bleve/index tokens), mirroring Phase-9. |
| T-10-08 | Tampering (injection) | `path` + `depth` query args | mitigate | `path` URL-encoded via `encodeURIComponent` (in the 10-01 `getLocalGraph` fn); `depth` is an integer clamped [1,3] client-side and re-clamped server-side (Phase-9). |
| 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. localGraphPanel slice, DepthControl, LocalGraphPanel, and PageView tests.
- `grep -c 'LocalGraphPanel' web/src/routes/PageView.tsx` ≥ 1 (mounted).
- `grep -c 'useLocalGraph' web/src/components/graph/LocalGraphPanel.tsx` ≥ 1 (auto-updating fetch).
</verification>

<success_criteria>
GRAPH-03 (per-page local panel + auto-update + depth control) is delivered, with GRAPH-04 (shared edge toggles) and GRAPH-05 (hover-highlight) applied locally and the current page accent-coloured. The dock chrome, states, persisted collapse, depth clamping, and the PageView mount are unit-tested; the canvas neighborhood is verified by hand.

**human_verification (visual — jsdom cannot assert canvas pixels):**
1. Open a page that has links; reveal the 'Local graph' panel (it is collapsed by default). It shows the current page (accent) plus its direct neighbors.
2. Navigate to a different page → the local graph auto-updates to the new page's neighborhood without a manual refresh.
3. Change `Depth` to 2 then 3 → more hops appear; it clamps at 3 and defaults to 1.
4. Toggle `Shared tags` / `Links` / `Backlinks` → edges show/hide locally, matching the global view (shared `graphEdges` slice).
5. Hover a node → it + neighbors + connecting edges stay bright, the rest dims; click a node → navigates to that page.
6. Collapse the panel → it reclaims editor width; reopen after reload → collapse state persisted.
</success_criteria>

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