---
phase: 09-graph-endpoints-backlinks-panel
plan: 02
type: execute
wave: 2
depends_on: [09-01]
files_modified:
  - web/src/api/client.ts
  - web/src/hooks/useBacklinks.ts
  - web/src/components/BacklinksPanel.tsx
  - web/src/components/BacklinksPanel.css
  - web/src/components/BacklinksPanel.test.tsx
  - web/src/routes/PageView.tsx
autonomous: true
requirements: [LINK-02]

must_haves:
  truths:
    - "A user viewing a page sees a collapsible 'Referenced by (N)' panel at the BOTTOM of the page read view listing every page that links to the current page"
    - "Clicking a backlink entry navigates to that linking page via the existing /app/page/:path route"
    - "The panel shows a quiet muted 'No backlinks yet' under a 'Referenced by (0)' header when the page has no backlinks"
    - "While the backlinks query loads, a quiet muted 'Loading backlinks…' line shows and the page body remains fully readable"
    - "On a backlinks fetch error, a quiet muted 'Couldn't load backlinks. Refresh to try again.' line shows and the page body remains fully readable"
    - "The panel is additive — it does not disturb the existing read-only LivePreviewEditor (CM6) rendering"
  artifacts:
    - path: "web/src/components/BacklinksPanel.tsx"
      provides: "the collapsible Referenced by (N) panel: header toggle + navtree list of click-to-navigate entries + empty/loading/error states"
      contains: "Referenced by"
    - path: "web/src/hooks/useBacklinks.ts"
      provides: "react-query hook fetching the backlinks endpoint for a page path"
      contains: "useQuery"
    - path: "web/src/api/client.ts"
      provides: "getBacklinks api fn + Backlink type"
      contains: "getBacklinks"
    - path: "web/src/components/BacklinksPanel.test.tsx"
      provides: "vitest coverage for populated list + click-navigate + empty + loading + error states"
      contains: "No backlinks yet"
  key_links:
    - from: "web/src/components/BacklinksPanel.tsx"
      to: "web/src/hooks/useBacklinks.ts"
      via: "panel consumes useBacklinks(path)"
      pattern: "useBacklinks\\("
    - from: "web/src/hooks/useBacklinks.ts"
      to: "web/src/api/client.ts"
      via: "hook calls getBacklinks(path)"
      pattern: "getBacklinks"
    - from: "web/src/routes/PageView.tsx"
      to: "web/src/components/BacklinksPanel.tsx"
      via: "PageView renders <BacklinksPanel path={path}/> at the bottom of the success branch"
      pattern: "<BacklinksPanel"
    - from: "web/src/api/client.ts"
      to: "internal/server/handlers_graph.go"
      via: "getBacklinks fetches GET /api/v1/graph/backlinks?path="
      pattern: "/api/v1/graph/backlinks"
---

<objective>
Ship the first user-visible v1.0 output: an Obsidian-style collapsible "Referenced by (N)" backlinks panel at the bottom of the page read view (LINK-02). It reuses the existing token system and nav-row patterns so it feels native to the shipped read surface, and it consumes the backlinks endpoint built in plan 09-01.

Purpose: LINK-02 — let a user see every page that links to the page they are viewing and click through to it. This is the only UI in Phase 9 (graph visualization is Phase 10).

Output: a `getBacklinks` api fn + `Backlink` type, a `useBacklinks` react-query hook, the `BacklinksPanel` component + its CSS (token-only, no new dependency), the panel mounted in PageView's success branch, and vitest coverage for the populated/empty/loading/error states + click-to-navigate.
</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/09-graph-endpoints-backlinks-panel/09-CONTEXT.md
@.planning/phases/09-graph-endpoints-backlinks-panel/09-UI-SPEC.md
</context>

<artifacts_this_phase_produces>
- `getBacklinks(path: string): Promise<Backlink[]>` + `interface Backlink { path: string; title: string }` in web/src/api/client.ts (matches the 09-01 BacklinkEntry JSON: `{path, title}`).
- `useBacklinks(path: string)` react-query hook (web/src/hooks/useBacklinks.ts), queryKey `["backlinks", path]`, gated on non-empty path.
- `BacklinksPanel` component (web/src/components/BacklinksPanel.tsx) + BacklinksPanel.css.
- `<BacklinksPanel path={path} />` mounted at the bottom of PageView's success branch.
</artifacts_this_phase_produces>

<design_decisions>
These resolve the UI-SPEC "planner's discretion" items; executor MUST follow them.

- Endpoint URL: `GET /api/v1/graph/backlinks?path=<page>` (the query-param endpoint built in plan 09-01). path is URL-encoded into the query string.
- Default expanded state: expanded when N >= 1 (the backlinks are the value); collapsible by the user. Toggle persistence is NOT implemented this phase (UI-SPEC says not required) — a local useState boolean, defaulting to open.
- The panel renders inside PageView's success branch (after the LivePreviewEditor / empty-body branch), still inside the `.pageview` article. It renders for BOTH the empty-body and non-empty-body cases (a page with no body can still have backlinks). It does NOT render on the 404 / error branches (those return early before the article).
- Empty/loading/error states are quiet muted single lines per the UI-SPEC copy contract (exact strings below). No spinner, no retry button, no empty-state card.
</design_decisions>

<read_first>
Before writing code, read these to mirror exact patterns, class names, and copy:

- .planning/phases/09-graph-endpoints-backlinks-panel/09-UI-SPEC.md — the FULL design contract. Copy strings, token usage, component structure, accessibility, and states are LOCKED there. Follow it exactly (header "Referenced by (N)", empty "No backlinks yet", loading "Loading backlinks…", error "Couldn't load backlinks. Refresh to try again.").
- .planning/phases/09-graph-endpoints-backlinks-panel/09-01-SUMMARY.md — confirms the backlinks endpoint URL and the `{path, title}` entry JSON shape (read once 09-01 is done).
- web/src/components/RecentList.tsx — the EXACT row pattern to reuse: `<ul className="navtree">`, `<li>`, `<button type="button" className="navrow navrow-page" onClick={() => navigate(\`/app/page/\${path}\`)}>`, a lucide icon `className="tree-icon"` aria-hidden, `<span className="tree-label">{title}</span>`. Use the SAME classes so entries are visually identical to existing nav rows. Use FileText (not Clock) as the entry icon per the UI-SPEC.
- web/src/components/RecentList.css — the muted empty-copy + label treatment (`.recentlist-label`, `.recentlist-empty`) to mirror for the section header + empty/status lines (font-size-label, color-text-muted).
- web/src/components/LeftTree.tsx — the `.tree-caret` chevron pattern: lucide ChevronRight (collapsed) / ChevronDown (expanded), size 16, aria-hidden. Mirror this for the disclosure toggle.
- web/src/hooks/useSearch.ts — the react-query hook convention: useQuery<T>({ queryKey: ["x", key], queryFn, enabled: key !== "" }). useBacklinks mirrors this (without the debounce — backlinks fetch on page load, not on keystroke). queryKey ["backlinks", path], enabled path !== "".
- web/src/api/client.ts lines 215-238 — getTree/getPage GET fn pattern (fetch, credentials same-origin, res.ok check, typed JSON). getBacklinks mirrors getTree's shape. NO CSRF (it is a GET).
- web/src/routes/PageView.tsx — where to mount the panel: inside the returned `<article className="pageview">` success branch, after the body-render block (after the `body.trim() === ""` ternary), before the dialogs. Render `<BacklinksPanel path={path} />`. Do NOT touch the LivePreviewEditor block (additive only).
- web/src/routes/PageView.test.tsx lines 1-45 — the test harness + `vi.mock("../api/client", ...)` pattern. BacklinksPanel.test.tsx reuses this: mock getBacklinks, wrap in QueryClientProvider + MemoryRouter, assert the rendered states. Add getBacklinks to the PageView test mock if PageView.test.tsx breaks (PageView now renders BacklinksPanel, which calls getBacklinks) — provide a default `getBacklinks: vi.fn().mockResolvedValue([])` in the PageView test mock so the existing PageView tests still pass.
- web/src/styles/tokens.css — the tokens the CSS must reference (var(--space-lg) top margin, 1px solid var(--color-border) top separator, --prose-max-width column, --hit-min-height rows, --radius-sm, --color-hover, --color-text-muted, --font-size-label). NEVER hard-code hex/px.
- web/src/components/LeftTree.css / RecentList.css — confirm .navtree / .navrow / .navrow-page / .tree-icon / .tree-label already exist (they do); BacklinksPanel.css only needs the NEW container/header/status styles, reusing the existing nav classes for rows.
</read_first>

<tasks>

<task type="auto">
  <name>Task 1: getBacklinks api fn + useBacklinks react-query hook</name>
  <files>web/src/api/client.ts, web/src/hooks/useBacklinks.ts</files>
  <action>
In web/src/api/client.ts add an exported `interface Backlink { path: string; title: string }` (matching the 09-01 BacklinkEntry JSON `{path, title}`) and an exported `getBacklinks(path: string): Promise<Backlink[]>` GET fn mirroring getTree (lines 215-222): `const res = await fetch(\`/api/v1/graph/backlinks?path=\${encodeURIComponent(path)}\`, { credentials: "same-origin" });` then on `!res.ok` throw a generic Error (copy: "Couldn't load backlinks."), else `return (await res.json()) as Backlink[]`. No CSRF (it is a GET). Place it near getTree/getPage in the page-reads section.

Create web/src/hooks/useBacklinks.ts mirroring useSearch.ts's useQuery convention (no debounce): export `useBacklinks(path: string)` returning `useQuery<Backlink[]>({ queryKey: ["backlinks", path], queryFn: () => getBacklinks(path), enabled: path !== "", staleTime: 30_000 })`. Import getBacklinks + the Backlink type from ../api/client.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace/web &amp;&amp; npx tsc -b</automated>
  </verify>
  <acceptance_criteria>
    - web/src/api/client.ts exports `getBacklinks` and the `Backlink` type; getBacklinks fetches GET /api/v1/graph/backlinks?path=<encoded> with credentials same-origin and no CSRF.
    - web/src/hooks/useBacklinks.ts exports useBacklinks(path) using useQuery with queryKey ["backlinks", path], gated on a non-empty path.
    - npx tsc -b passes (no type errors).
  </acceptance_criteria>
  <done>The api client has a typed getBacklinks GET fn hitting the 09-01 backlinks endpoint, and useBacklinks wraps it in react-query following the codebase hook convention; the project type-checks.</done>
</task>

<task type="auto">
  <name>Task 2: BacklinksPanel component + CSS + mount in PageView, with vitest coverage</name>
  <files>web/src/components/BacklinksPanel.tsx, web/src/components/BacklinksPanel.css, web/src/components/BacklinksPanel.test.tsx, web/src/routes/PageView.tsx</files>
  <action>
Create web/src/components/BacklinksPanel.tsx (props: `{ path: string }`). Use useBacklinks(path) and useNavigate(). Local `const [open, setOpen] = useState(true)` (default expanded; the user can collapse — toggle persistence not required this phase). Structure per the UI-SPEC:
- Outer container `<section className="backlinks-panel">` aligned to the body column.
- A full-width `<button type="button" className="backlinks-toggle" aria-expanded={open} onClick={() => setOpen(o => !o)}>`: a lucide chevron (ChevronDown when open, ChevronRight when collapsed, size 16, aria-hidden, className "tree-caret") then the label `Referenced by ({count})` where count = data?.length ?? 0. The visible label is the accessible name (no extra aria-label).
- When open, render the body region:
  - isLoading => `<p className="backlinks-status">Loading backlinks…</p>` (muted).
  - isError => `<p className="backlinks-status">Couldn't load backlinks. Refresh to try again.</p>` (muted).
  - data with length 0 => `<p className="backlinks-status">No backlinks yet</p>` (muted).
  - data with entries => `<ul className="navtree">` of `<li key={b.path}>` each a `<button type="button" className="navrow navrow-page" onClick={() => navigate(\`/app/page/\${b.path}\`)}>` with `<FileText size={16} aria-hidden className="tree-icon" />` + `<span className="tree-label">{b.title}</span>`. Use the SAME nav classes as RecentList so rows are visually identical.
Import "./BacklinksPanel.css". Icons from lucide-react (ChevronDown, ChevronRight, FileText) — already a dependency, NO new package.

Create web/src/components/BacklinksPanel.css with ONLY the new container/header/status styles, referencing tokens (no hard-coded hex/px). Required rules: `.backlinks-panel` { max-width: var(--prose-max-width); margin-top: var(--space-lg); padding-top: var(--space-md); border-top: 1px solid var(--color-border); } ; `.backlinks-toggle` { full-width flex row, gap var(--space-sm), min-height: var(--hit-min-height), background: none, border: none, cursor: pointer, color: var(--color-text-muted), font-size: var(--font-size-label), font-weight: var(--font-weight-semibold), border-radius: var(--radius-sm); } with `.backlinks-toggle:hover { background: var(--color-hover); }` ; `.backlinks-status` { color: var(--color-text-muted); font-size: var(--font-size-label); padding: var(--space-sm); margin: 0; }. Reuse existing .navtree/.navrow/.navrow-page/.tree-icon/.tree-label (already defined in LeftTree.css/RecentList.css) for the rows — do not redefine them. Rely on the global :focus-visible accent ring (do not add a custom focus style).

Mount in web/src/routes/PageView.tsx: inside the returned `<article className="pageview">` success branch, immediately AFTER the body-render ternary block (the `body.trim() === "" ? ... : <LivePreviewEditor .../>` block) and BEFORE the `<Dialog ...>` block, add `<BacklinksPanel path={path} />`. Add the import at the top. Do NOT modify the LivePreviewEditor block or the early-return 404/error branches (additive only).

Create web/src/components/BacklinksPanel.test.tsx mirroring PageView.test.tsx's harness (vi.mock("../api/client") with getBacklinks; QueryClientProvider with retry:false + MemoryRouter). Cover:
- Populated: getBacklinks resolves [{path:"notes/a.md",title:"Note A"},{path:"notes/b.md",title:"Note B"}] => header text "Referenced by (2)" appears and both titles render; clicking "Note A" navigates to /app/page/notes/a.md (assert via a route spy / a MemoryRouter location probe, matching how other component tests assert navigation).
- Empty: getBacklinks resolves [] => "Referenced by (0)" header and "No backlinks yet" muted line.
- Loading: getBacklinks returns a never-resolving promise => "Loading backlinks…" line.
- Error: getBacklinks rejects => "Couldn't load backlinks. Refresh to try again." line.
- Collapse: clicking the toggle hides the list/status region (aria-expanded flips to false).

Also update web/src/routes/PageView.test.tsx's api mock to include `getBacklinks: vi.fn().mockResolvedValue([])` so the existing PageView tests (which now mount BacklinksPanel) still pass.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace/web &amp;&amp; npx tsc -b &amp;&amp; npx vitest run src/components/BacklinksPanel.test.tsx src/routes/PageView.test.tsx</automated>
  </verify>
  <acceptance_criteria>
    - BacklinksPanel.tsx renders the "Referenced by (N)" toggle (aria-expanded), a navtree of click-to-navigate navrow-page entries (FileText + tree-label), and the three quiet muted states with the EXACT UI-SPEC copy.
    - BacklinksPanel.css references only var(--…) tokens (no hard-coded hex/px) and reuses existing nav classes for rows; grep for '#' hex or 'px' literals in BacklinksPanel.css returns none (token-only).
    - PageView.tsx mounts <BacklinksPanel path={path}/> in the success branch only, with the LivePreviewEditor block unchanged.
    - npx tsc -b passes and npx vitest run on BacklinksPanel.test.tsx + PageView.test.tsx passes (populated/empty/loading/error/collapse + click-navigate covered; existing PageView tests still green).
    - No new frontend dependency added (package.json unchanged; icons come from existing lucide-react).
  </acceptance_criteria>
  <done>The collapsible "Referenced by (N)" panel renders natively at the bottom of the page read view, lists click-to-navigate backlink entries reusing existing nav rows, shows quiet empty/loading/error states per the UI-SPEC, does not disturb the CM6 read surface, adds no new dependency, and is proven by vitest.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser -> backlinks endpoint | The SPA fetches GET /api/v1/graph/backlinks?path=; path is user-controlled (the current page) and URL-encoded. |
| backlink title -> DOM | Linking-page titles render into the panel; they must go through React text rendering (no innerHTML). |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-09-06 | Tampering / XSS | backlink title rendered into the panel | mitigate | Titles render as React text children (`<span>{title}</span>`), never dangerouslySetInnerHTML — the locked stored-XSS guard. No raw HTML path is introduced. |
| T-09-07 | Information Disclosure | error copy leaking server internals | mitigate | The fetch error path shows only the generic "Couldn't load backlinks…" copy; the server already returns generic hidden-Git-safe copy (plan 09-01). |
| T-09-08 | Tampering | path query param injection | mitigate | path is URL-encoded via encodeURIComponent into the query string; the server parameterizes the SQL (plan 09-01). |
| T-09-SC | Tampering | npm install of a new package | mitigate | NO new frontend dependency is added (icons reuse existing lucide-react; the force-graph lib is Phase 10). package.json is unchanged, so no package-legitimacy checkpoint is required; a grep that package.json dependencies are unchanged is part of acceptance. |
</threat_model>

<verification>
- npx tsc -b passes (frontend type-checks).
- npx vitest run on BacklinksPanel.test.tsx + PageView.test.tsx passes (all states + click-navigate; existing PageView tests still green).
- BacklinksPanel.css contains no hard-coded hex/px (token-only) and reuses existing nav classes.
- package.json dependencies are unchanged (no new frontend dependency this phase).
- The LivePreviewEditor read block in PageView.tsx is unchanged (panel is additive).
</verification>

<success_criteria>
- LINK-02: a user viewing a page sees a "Referenced by (N)" panel listing every page linking to it, and clicking an entry navigates to that page.
- The empty/loading/error states render the exact quiet UI-SPEC copy and never block the page body.
- The panel feels native (reuses tokens + nav-row classes), adds no new dependency, and does not disturb the CM6 read rendering.
</success_criteria>

<output>
Create `.planning/phases/09-graph-endpoints-backlinks-panel/09-02-SUMMARY.md` when done, listing the BacklinksPanel component, the getBacklinks api fn + Backlink type, the useBacklinks hook, and confirming LINK-02 is satisfied (panel works + click-navigates, no new frontend dependency).
</output>
