---
phase: 9
slug: graph-endpoints-backlinks-panel
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-24
---

# Phase 9 — UI Design Contract

> Visual and interaction contract for the **"Referenced by" (backlinks) panel** — the only user-facing UI in Phase 9. The graph endpoints (`/api/v1/graph`, `/api/v1/graph/local`) are backend-only; the graph visualization UI is Phase 10 and is explicitly OUT OF SCOPE here.
>
> This is a subsequent phase on a shipped app (v0.9.9) with an established, hand-rolled token design system (`web/src/styles/tokens.css`). This contract REUSES existing tokens and component patterns — it introduces no new design language. Generated by gsd-ui-researcher, verified by gsd-ui-checker.

---

## Scope

**In scope (this contract covers only these):**
- The collapsible panel container appended to the BOTTOM of the page read view (`web/src/routes/PageView.tsx`), below the rendered Markdown body — LOCKED by `09-CONTEXT.md`.
- The `Referenced by (N)` section header with an expand/collapse chevron (collapsed + expanded states).
- The list of backlink entries (page title/path, click-to-navigate via the existing page route, hover + keyboard focus).
- The quiet empty state ("No backlinks yet").
- Loading and error states for the react-query fetch of the backlinks endpoint.

**Out of scope (do NOT spec — Phase 10 / later):**
- Graph visualization / canvas / force-directed layout, edge-type toggles, tag nodes, hover-highlight, local-graph panel UI.
- Backlink context snippets (the linking sentence) — deferred to v2 (LINK-F1).
- Right-side dockable panel layout — explicitly rejected for Phase 9 in favor of the below-page collapsible.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (hand-rolled token system — `web/src/styles/tokens.css`) |
| Preset | not applicable |
| Component library | none (token-only CSS + lucide-react icons; no Radix/Base UI/shadcn) |
| Icon library | `lucide-react` (already a dependency) |
| Font | `--font-family-ui` (system-ui stack) |
| Theme | Obsidian-style dark (`color-scheme: dark`) — established, do not alter |

**Reuse mandate:** Every value below already exists in `tokens.css`. The backlinks panel MUST reference `var(--…)` tokens — never hard-coded hex/px — consistent with the existing component authoring rule. No new tokens are introduced by this phase.

---

## Spacing Scale

Declared values (existing 8-point scale from `tokens.css`, multiples of 4):

| Token | Value | Usage in backlinks panel |
|-------|-------|--------------------------|
| `--space-xs` | 4px | Gap between stacked list rows (`.navtree` gap), icon-to-label gap inside the caret |
| `--space-sm` | 8px | Row padding + icon/label gap inside each entry (matches `.navrow`) |
| `--space-md` | 16px | Inner padding of the section / gap below the Markdown body before the panel |
| `--space-lg` | 24px | Top separation between the rendered body and the panel block (section break above the panel) |
| `--space-xl` | 32px | (available; not required for this panel) |
| `--space-2xl` | 48px | (available; not required for this panel) |
| `--space-3xl` | 64px | (available; not required for this panel) |

Additional fixed dimensions reused (not new): `--hit-min-height: 40px` for every interactive row and the collapse toggle (accessibility hit target); `--radius-sm: 6px` for row corners; `--prose-max-width: 720px` so the panel aligns to the page body column.

Exceptions: none.

---

## Typography

Existing scale from `tokens.css` (exactly 4 sizes, exactly 2 weights). The panel uses three of the four roles:

| Role | Size | Weight | Line Height | Usage in backlinks panel |
|------|------|--------|-------------|--------------------------|
| Section header | `--font-size-label` (14px) | `--font-weight-semibold` (600) | `--line-height-label` (1.4) | The `Referenced by (N)` toggle label — matches `.recentlist-label` / section-label treatment |
| Entry / body | `--font-size-body` (16px) | `--font-weight-regular` (400) | `--line-height-body` (1.5) | Each backlink entry title (matches `.navrow` / `.tree-label`) |
| Empty / status | `--font-size-label` (14px) | `--font-weight-regular` (400) | `--line-height-label` (1.4) | "No backlinks yet", "Loading…", error text — muted |

Display (`--font-size-display`, 28px) and Heading (`--font-size-heading`, 20px) are NOT used by this panel (the page `<h1>` already owns the heading scale).

---

## Color

Existing dark palette from `tokens.css`. The 60/30/10 split is already established app-wide; the panel sits inside the read view and inherits it:

| Role | Value | Usage in backlinks panel |
|------|-------|--------------------------|
| Dominant (60%) | `--color-bg` (#0b0d12) | Panel background — panel sits flush on the main page canvas (no raised card) to stay quiet/native |
| Secondary (30%) | `--color-surface` (#11151f) | Not used as a fill here; the panel is intentionally flat. Separator uses `--color-border` |
| Accent (10%) | `--color-accent` (#8b5cf6) | Reserved ONLY for: the `:focus-visible` ring on the toggle and on each entry (inherited 2px accent ring), and the active/hover link affordance is the hover wash — NOT a colored entry |
| Muted text | `--color-text-muted` (#8d96a8) | Section header label, the `(N)` count, empty/loading/error copy, the chevron icon |
| Primary text | `--color-text` (#eef1f7) | Backlink entry titles |
| Hover | `--color-hover` (rgba 255,255,255,0.06) | Row hover wash on each entry (matches `.navrow-page:hover`) |
| Border | `--color-border` (rgba 255,255,255,0.075) | The top separator line dividing the Markdown body from the panel |
| Destructive | `--color-destructive` (#fb7185) | Not used — this phase has NO destructive actions |

**Accent reserved for:** the keyboard `:focus-visible` outline (global rule, 2px accent, 2px offset) on the collapse toggle and on each backlink entry button. No backlink entry is rendered in accent color; navigation affordance is conveyed by hover wash + pointer cursor, matching every other nav row in the app. Accent is NOT used for "all interactive elements."

---

## Component & Interaction Contract

### Container — collapsible section (LOCKED placement)
- Rendered at the BOTTOM of `PageView.tsx`, AFTER the `LivePreviewEditor` read surface (and after the empty-body `<p>` branch), still inside the `.pageview` article. Additive only — must not disturb the CM6 read rendering.
- Aligned to the page body column (`--prose-max-width: 720px`, same horizontal alignment as the rendered Markdown).
- Separated from the body above by a single top border (`1px solid var(--color-border)`) plus `--space-lg` top margin.
- The panel is only rendered for an existing, loaded page (i.e. inside the success branch; not on the 404/empty-error branches).

### Header / toggle (collapsed ↔ expanded)
- A single full-width `<button type="button">` acting as the disclosure control.
- Pattern mirrors the existing tree caret (`web/src/components/LeftTree.tsx` `.tree-caret`): a `lucide-react` chevron — `ChevronRight` when collapsed, `ChevronDown` when expanded (size 16, `aria-hidden`), followed by the label `Referenced by (N)`.
- `aria-expanded={open}` on the button; `aria-label` not required since the visible label is descriptive. The count `N` is the number of backlinks.
- Default state: **expanded** when N ≥ 1 (the backlinks are the value); the user can collapse. Toggle persistence is at the planner's discretion (a zustand boolean is the established pattern) but is NOT required for Phase 9.
- Min hit height `--hit-min-height` (40px); hover wash `--color-hover`; `:focus-visible` accent ring (global).

### Entry list
- A `<ul className="navtree">` (reuses existing list geometry: `--space-xs` gap, no bullets).
- Each entry is a `<li>` containing a full-width `<button type="button" className="navrow navrow-page">` — the SAME row component used by `RecentList` and the left tree, so it is visually identical to existing navigation rows.
- Each row: a small `lucide-react` icon (`FileText` size 16, `className="tree-icon"`, `aria-hidden`) + `<span className="tree-label">{title}</span>`. Title is the linking page's title (fallback to its path), per LINK-02.
- Click → navigate to the linking page via the EXISTING page route (`navigate(\`/app/page/\${linkingPath}\`)`), identical to `RecentList`.
- Hover: `--color-hover` wash (inherited from `.navrow-page:hover`). Keyboard focus: global accent `:focus-visible` ring. Rows are fully keyboard-operable (real `<button>`s).

### Loading state
- While the backlinks query is `isLoading`: render a single quiet muted line "Loading backlinks…" at `--font-size-label`, `--color-text-muted`. Do NOT block or replace the page body; the panel region simply shows the loading line. No spinner is required (matches the app's existing text-status convention, e.g. `.pageview-status` "Loading…").

### Error state
- On query error: render a single quiet muted line "Couldn't load backlinks. Refresh to try again." at `--font-size-label`, `--color-text-muted`. Non-blocking — the page body remains fully readable. No retry button (refresh is the recovery path), consistent with the panel's quiet, additive intent.

### Empty state (LOCKED: quiet)
- When the page has zero backlinks: show the header `Referenced by (0)` followed by a single muted line "No backlinks yet" at `--font-size-label`, `--color-text-muted`. Quiet and unobtrusive per `09-CONTEXT.md` — never a prominent empty-state card or illustration.

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Primary CTA | (none — this is a read-only panel; the only action is navigating an entry, which has no button label, it is the page title itself) |
| Section header | `Referenced by (N)` — where N is the live backlink count (e.g. `Referenced by (3)`) |
| Empty state heading | `Referenced by (0)` (the header itself carries the empty signal) |
| Empty state body | `No backlinks yet` |
| Loading state | `Loading backlinks…` |
| Error state | `Couldn't load backlinks. Refresh to try again.` |
| Destructive confirmation | not applicable — this phase has no destructive actions |

**Tone:** quiet, factual, Obsidian-native — matches existing muted helper copy (e.g. RecentList "Pages you open will show up here for quick access."). No exclamation marks, no marketing voice.

---

## Accessibility

- Disclosure toggle: real `<button>` with `aria-expanded`; chevron icons `aria-hidden`; visible text label is the accessible name.
- Each backlink entry: real `<button>` (keyboard focusable + Enter/Space activatable); icon `aria-hidden`; title text is the accessible name.
- All interactive elements meet `--hit-min-height` (40px) and use the global accent `:focus-visible` ring (2px, 2px offset).
- Count `(N)` is part of the visible label, so screen readers announce it with the header.

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| none (no shadcn / no third-party registry) | none | not applicable — no registry in use; UI is hand-rolled token CSS + existing `lucide-react` icons |

No third-party registries are declared or used in this phase. shadcn is intentionally NOT introduced: this is a subsequent phase on a shipped app with an established hand-rolled token system, and the scope guard forbids inventing a new design language.

---

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