Просмотр исходного кода

feat(ui): the Symbol view — callers, gutter-ported source, line-anchored callee rail (CG-44)

The core screen of `codegraph ui`: who calls a symbol on the left, its
verbatim body in the middle with a port on every line that has an outgoing
edge, and what it calls on the right — each callee row placed beside the line
that makes the call, with a hairline connector between them.

The callee rail is the part that is not a list. A row wants to sit at the
centre of its first call-site line and is pushed down only when that would
collide with the row above, so the rail keeps source order; the connector
still runs to the real line, so the displacement is visible rather than
silent. Positions come from measuring the laid-out DOM, so they are
recomputed on resize, on font load and whenever a fold opens.

Honesty is carried in the drawing, not in a footnote: a filled port means the
resolver matched something on that line and a hollow one means it only
guessed; uncertain connectors are dashed and their targets fold away behind
their count; synthesized edges are dashed differently and tagged with the
mechanism that made them; references that leave the index are text with a
soft underline rather than links to nowhere, and they are counted. Long
bodies keep their head plus a window round every call site — windowed on
graph edges only, since a function calling `console.log` two hundred times
would otherwise window round every line and buy nothing. Containers over 80
lines show a members outline with per-member fan-in/fan-out instead of 700
lines of braces.

Two small additions to the read-only API this needed:

* `/api/node` gives every outline member its own fanIn/fanOut (two batched
  queries for the whole outline). A class's own fan-out is nearly always
  zero because its methods do the calling, so without these the outline
  cannot say which member carries weight.
* `/api/stats` gains `blastScale` — the denominator the blast bar is drawn
  against, so one symbol's radius reads as wide or narrow *for this repo*.
  It is measured across the index's 24 most-depended-on symbols (found with
  a new `getTopDependedOn`, distinct dependents rather than edges), memoised
  against the index stamp, and reported as sampled; a symbol wider than the
  sample becomes the scale instead of overflowing the track.

Verified against a real index in a real browser: parity with the prototype on
`CodeGraph.sync` (259 lines, 27 callee rows, no overlaps), `GraphTraverser`
(20-member outline), a 773-line function (26 windows, 78 connectors), light
and dark, hover linking in both directions, keyboard-only navigation, and
reflow on resize and on fold toggles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 неделя назад
Родитель
Сommit
5cecaabfc2

+ 49 - 0
__tests__/ui-server-api.test.ts

@@ -289,6 +289,33 @@ describe('GET /api/stats', () => {
     // The thresholds travel with the data so the viewer's copy cannot drift.
     // The thresholds travel with the data so the viewer's copy cannot drift.
     expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 });
     expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 });
   });
   });
+
+  it('reports a blast-radius scale the widest symbol in the index reaches', async () => {
+    const body = await getJson('/api/stats');
+    const scale = body.blastScale;
+
+    // `hot` is called by 500 distinct functions and nothing else in the fixture
+    // comes close, so the exact maximum is knowable here.
+    expect(scale.maxDirect).toBe(500);
+    // Its radius is at least its own callers; the sample is capped, so the
+    // count is a floor and the flag says so rather than claiming exhaustive.
+    expect(scale.maxWithinHops).toBeGreaterThanOrEqual(500);
+    expect(scale.hops).toBe(3);
+    expect(scale.sampled).toBeGreaterThan(0);
+    expect(scale.sampled).toBeLessThanOrEqual(24);
+    expect(scale.estimated).toBe(true);
+  });
+
+  it('serves the scale from cache — the second call does not re-traverse', async () => {
+    const first = await getJson('/api/stats');
+    const started = Date.now();
+    const second = await getJson('/api/stats');
+    expect(second.blastScale).toEqual(first.blastScale);
+    // 24 depth-3 traversals over a 500-caller graph are not free; a cached
+    // answer is. The margin is wide because this is a smoke test for the
+    // memo existing at all, not a benchmark.
+    expect(Date.now() - started).toBeLessThan(250);
+  });
 });
 });
 
 
 describe('GET /api/search', () => {
 describe('GET /api/search', () => {
@@ -385,6 +412,28 @@ describe('GET /api/node/<id>', () => {
     expect(body.members.total).toBe(body.members.shown);
     expect(body.members.total).toBe(body.members.shown);
   });
   });
 
 
+  it('gives every member its own fan-in and fan-out — the outline is the body', async () => {
+    const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
+    const byName = new Map(body.members.items.map((m: any) => [m.name, m]));
+
+    for (const member of body.members.items) {
+      expect(typeof member.fanIn).toBe('number');
+      expect(typeof member.fanOut).toBe('number');
+      expect(member.fanIn).toBeGreaterThanOrEqual(0);
+      expect(member.fanOut).toBeGreaterThanOrEqual(0);
+    }
+
+    // `Service.load` calls both, and `Cache` contains them: at least the
+    // containment edge plus one call each. Without these numbers a 700-line
+    // class's outline cannot say which member carries weight.
+    expect((byName.get('read') as any).fanIn).toBeGreaterThanOrEqual(2);
+    expect((byName.get('write') as any).fanIn).toBeGreaterThanOrEqual(2);
+    // The class itself calls nothing — its methods do, which is exactly why
+    // the per-member counts have to come from the members.
+    expect(body.counts.callees).toBe(0);
+    expect(body.members.items.some((m: any) => m.fanOut > 0)).toBe(true);
+  });
+
   it('nests a file outline one level deeper, so a class shows its methods', async () => {
   it('nests a file outline one level deeper, so a class shows its methods', async () => {
     const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`);
     const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`);
     const byDepth = new Map<number, string[]>();
     const byDepth = new Map<number, string[]>();

+ 545 - 0
__tests__/ui-symbol-model.test.ts

@@ -0,0 +1,545 @@
+/**
+ * The Symbol view's decisions, without a browser (CG-44).
+ *
+ * Everything the screen does that could be wrong rather than merely ugly lives
+ * in `ui/src/lib/` as plain functions over the `/api/node` payload: which lines
+ * survive into a windowed body, which identifier a call-site link lands on,
+ * which callers fold away, which reference is a guess. Those are the parts
+ * worth pinning — the geometry that needs a real layout (row placement,
+ * connector paths) is verified against a running viewer instead.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  assignRefs,
+  buildCalleeRail,
+  buildCallerRail,
+  buildCodeBlock,
+  buildOutline,
+  edgeWord,
+  graphCallLines,
+  kindPhrase,
+  refsByLine,
+  showsBody,
+  synthesizedBy,
+  FULL_BODY_LINES,
+  HEAD_LINES,
+  type LineRef,
+} from '../ui/src/lib/symbol-model';
+import { newLexState, tokenize } from '../ui/src/lib/highlight';
+import type { WireRelation, WireSymbolPayload } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function nodeRef(over: Partial<WireSymbolPayload['node']> = {}): any {
+  return {
+    id: 'method:a',
+    kind: 'method',
+    name: 'load',
+    qualifiedName: 'Service::load',
+    file: 'src/service.ts',
+    line: 10,
+    endLine: 20,
+    language: 'typescript',
+    test: false,
+    ...over,
+  };
+}
+
+function relation(over: Partial<WireRelation> & { node?: any } = {}): WireRelation {
+  const { node, ...rest } = over;
+  const lines = rest.lines ?? [12];
+  return {
+    edgeKinds: ['calls'],
+    edges: lines.map((line) => ({ kind: 'calls' as const, line, col: 4 })),
+    edgeCount: lines.length,
+    lines,
+    confidence: 0.9,
+    uncertain: false,
+    synthesized: false,
+    ...rest,
+    node: nodeRef(node),
+  } as WireRelation;
+}
+
+function payload(over: Partial<WireSymbolPayload> = {}): WireSymbolPayload {
+  return {
+    node: { ...nodeRef(), startColumn: 2, endColumn: 3, lines: 11 },
+    ancestors: [],
+    members: { total: 0, shown: 0, truncated: false, items: [] },
+    incoming: { total: 0, shown: 0, truncated: false, items: [] },
+    outgoing: { total: 0, shown: 0, truncated: false, items: [] },
+    typesUsed: [],
+    counts: { callers: 0, callees: 0, typesUsed: 0, fanIn: 0, fanOut: 0, members: 0, hub: false },
+    tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
+    outsideIndex: { total: 0, byKind: {}, samples: [] },
+    blast: null,
+    drift: false,
+    ...over,
+  } as WireSymbolPayload;
+}
+
+const body = (count: number, from = 1): string[] =>
+  Array.from({ length: count }, (_, i) => `line ${from + i}`);
+
+/* ---------------------------------------------------------------- words -- */
+
+describe('edge wording', () => {
+  it('names the relationships that are not a plain call, and leaves calls unlabelled', () => {
+    // Labelling every row "calls" is noise that hides the rows where the
+    // relationship is something else.
+    expect(edgeWord({ kind: 'calls' })).toBe('');
+    expect(edgeWord({ kind: 'instantiates' })).toBe('creates');
+    expect(edgeWord({ kind: 'references' })).toBe('uses type');
+    expect(edgeWord({ kind: 'references', valueRef: true })).toBe('passes as value');
+    expect(edgeWord({ kind: 'implements' })).toBe('implements');
+  });
+
+  it('names the synthesizer behind a heuristic edge, and nothing for a parsed one', () => {
+    const parsed = relation();
+    expect(synthesizedBy(parsed)).toBeNull();
+
+    const synthesized = {
+      ...parsed,
+      synthesized: true,
+      edges: [{ kind: 'calls', line: 12, provenance: 'heuristic', synthesizedBy: 'react-render' }],
+    } as WireRelation;
+    expect(synthesizedBy(synthesized)).toBe('react-render');
+  });
+
+  it('falls back to a truthful placeholder when the synthesizer did not name itself', () => {
+    const synthesized = {
+      ...relation(),
+      synthesized: true,
+      edges: [{ kind: 'calls', line: 12, provenance: 'heuristic' }],
+    } as WireRelation;
+    expect(synthesizedBy(synthesized)).toBe('synthesized');
+  });
+});
+
+describe('kindPhrase', () => {
+  it('reads the modifiers a reader acts on, and stays silent about the default ones', () => {
+    expect(kindPhrase({ kind: 'method', async: true })).toBe('method · async');
+    expect(kindPhrase({ kind: 'type_alias' })).toBe('type');
+    expect(kindPhrase({ kind: 'method', visibility: 'public' })).toBe('method');
+    expect(kindPhrase({ kind: 'method', static: true, visibility: 'private' })).toBe(
+      'method · static · private'
+    );
+  });
+});
+
+/* -------------------------------------------------------------- windows -- */
+
+describe('buildCodeBlock', () => {
+  it('shows a body of 260 lines or fewer whole, with no gaps', () => {
+    const block = buildCodeBlock(1, body(FULL_BODY_LINES), [5, 200]);
+    expect(block.whole).toBe(true);
+    expect(block.windows).toHaveLength(1);
+    expect(block.windows[0]?.start).toBe(1);
+    expect(block.windows[0]?.lines).toHaveLength(FULL_BODY_LINES);
+    expect(block.gapsAfter).toEqual([]);
+    expect(block.tailGap).toBe(0);
+  });
+
+  it('keeps the head plus a window round every call site once the body is longer', () => {
+    // One call, far past the head: head + one ±4 window, one gap between them.
+    const block = buildCodeBlock(1, body(400), [300]);
+    expect(block.whole).toBe(false);
+    expect(block.windows).toHaveLength(2);
+    expect(block.windows[0]).toMatchObject({ start: 1 });
+    expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES);
+    expect(block.windows[1]?.start).toBe(296);
+    expect(block.windows[1]?.lines).toHaveLength(9);
+    expect(block.gapsAfter).toEqual([215]);
+    // 400 − 304 lines never reached the screen, and the block says how many.
+    expect(block.tailGap).toBe(96);
+  });
+
+  it('merges windows that all but touch, rather than drawing a one-line gap', () => {
+    const block = buildCodeBlock(1, body(400), [300, 310]);
+    // 296–304 and 306–314 are two apart: one window, no gap row between them.
+    expect(block.windows).toHaveLength(2);
+    expect(block.windows[1]).toMatchObject({ start: 296 });
+    expect(block.windows[1]?.lines).toHaveLength(19);
+    expect(block.gapsAfter).toEqual([215]);
+  });
+
+  it('ignores call sites already inside the head', () => {
+    const block = buildCodeBlock(1, body(400), [3, 40]);
+    expect(block.windows).toHaveLength(1);
+    expect(block.windows[0]?.lines).toHaveLength(HEAD_LINES);
+    expect(block.tailGap).toBe(320);
+  });
+
+  it("numbers windows from the symbol's real first line, not from one", () => {
+    const block = buildCodeBlock(778, body(400, 778), [1000]);
+    expect(block.windows[0]?.start).toBe(778);
+    expect(block.windows[1]?.start).toBe(996);
+    expect(block.windows[1]?.lines[0]).toBe('line 996');
+  });
+
+  it('never runs a window past the end of the body', () => {
+    const block = buildCodeBlock(1, body(400), [399]);
+    const last = block.windows[block.windows.length - 1];
+    expect((last?.start ?? 0) + (last?.lines.length ?? 0) - 1).toBe(400);
+    expect(block.tailGap).toBe(0);
+  });
+
+  it('windows only on edges that reach the graph, not on unresolved references', () => {
+    // A function calling `console.log` 200 times would otherwise window around
+    // nearly every line, and the head-plus-windows rule would buy nothing.
+    const view = payload({
+      outgoing: { total: 1, shown: 1, truncated: false, items: [relation({ lines: [300] })] },
+      outsideIndex: {
+        total: 1,
+        byKind: { calls: 1 },
+        samples: [{ name: 'console.log', kind: 'calls', line: 350, col: 4 }],
+      },
+    });
+    expect(graphCallLines(view)).toEqual([300]);
+    expect(refsByLine(view).has(350)).toBe(true);
+  });
+});
+
+/* ----------------------------------------------------------------- refs -- */
+
+describe('assignRefs', () => {
+  const toks = (line: string) => tokenize(line, newLexState(), 'typescript');
+  const ref = (over: Partial<LineRef>): LineRef => ({
+    ident: 'withLock',
+    col: null,
+    targetId: 'method:x',
+    uncertain: false,
+    outside: false,
+    title: '',
+    ...over,
+  });
+
+  it('marks the callee, not the receiver the column actually points at', () => {
+    // The recorded column is the start of the calling EXPRESSION, so an exact
+    // hit is the exception: `this` sits at column 11, `withLock` at 27.
+    const line = '    return this.indexMutex.withLock(async () => {';
+    const tokens = toks(line);
+    const claimed = assignRefs(tokens, [ref({ col: 11 })]);
+    const [index] = [...claimed.keys()];
+    expect(tokens[index as number]?.text).toBe('withLock');
+  });
+
+  it('prefers the token the column lands inside when there is one', () => {
+    const line = 'render(); render();';
+    const tokens = toks(line);
+    const second = line.lastIndexOf('render');
+    const claimed = assignRefs(tokens, [ref({ ident: 'render', col: second })]);
+    const [index] = [...claimed.keys()];
+    expect(tokens[index as number]?.col).toBe(second);
+  });
+
+  it('gives two refs to the same name two different tokens', () => {
+    const tokens = toks('render(); render();');
+    const claimed = assignRefs(tokens, [
+      ref({ ident: 'render', col: null, targetId: 'a' }),
+      ref({ ident: 'render', col: null, targetId: 'b' }),
+    ]);
+    expect(claimed.size).toBe(2);
+    expect(new Set([...claimed.values()].map((r) => r.targetId))).toEqual(new Set(['a', 'b']));
+  });
+
+  it('claims nothing when the identifier is not on the line', () => {
+    // Better a missing link than an accent underline on the wrong word.
+    expect(assignRefs(toks('return 1;'), [ref({ ident: 'nowhere' })]).size).toBe(0);
+  });
+
+  it('never marks a keyword, a string or a comment as a call site', () => {
+    const tokens = toks('// call render here');
+    expect(assignRefs(tokens, [ref({ ident: 'render' })]).size).toBe(0);
+    expect(assignRefs(toks('const s = "render";'), [ref({ ident: 'render' })]).size).toBe(0);
+  });
+});
+
+describe('refsByLine', () => {
+  it('carries type references too, so a line that only names a type gets its port', () => {
+    const view = payload({
+      typesUsed: [relation({ node: { id: 'interface:c', kind: 'interface', name: 'Config' }, lines: [11] })],
+    });
+    const refs = refsByLine(view);
+    expect(refs.get(11)?.[0]).toMatchObject({ ident: 'Config', outside: false });
+  });
+
+  it('uses the last segment of a qualified name — that is what is in the source', () => {
+    const view = payload({
+      outgoing: {
+        total: 1,
+        shown: 1,
+        truncated: false,
+        items: [relation({ node: { id: 'm:1', name: 'Cache.read' }, lines: [12] })],
+      },
+    });
+    expect(refsByLine(view).get(12)?.[0]?.ident).toBe('read');
+  });
+
+  it('drops an unresolved "name" that is not an identifier at all', () => {
+    // The resolver's samples are raw bookkeeping; a captured arrow function
+    // cannot be found in the line, and searching for it would claim the wrong
+    // token.
+    const view = payload({
+      outsideIndex: {
+        total: 2,
+        byKind: { calls: 2 },
+        samples: [
+          { name: '(() => {\n  return t', kind: 'calls', line: 12, col: 0 },
+          { name: 'this.db', kind: 'function_ref', line: 13, col: 4 },
+        ],
+      },
+    });
+    const refs = refsByLine(view);
+    expect(refs.has(12)).toBe(false);
+    // `this.db` reduces to `db`, which IS in the line — kept, and marked as
+    // outside the index so it renders as text rather than a link.
+    expect(refs.get(13)?.[0]).toMatchObject({ ident: 'db', outside: true, targetId: null });
+  });
+});
+
+/* ---------------------------------------------------------------- rails -- */
+
+describe('buildCallerRail', () => {
+  const caller = (over: { id: string; file: string; test?: boolean; uncertain?: boolean; edges?: number }) =>
+    ({
+      ...relation({ lines: [4657] }),
+      node: {
+        ...nodeRef({ id: over.id, file: over.file, name: over.id }),
+        test: over.test ?? false,
+      },
+      edgeCount: over.edges ?? 1,
+      uncertain: over.uncertain ?? false,
+    }) as WireRelation;
+
+  it("puts the symbol's own file first and groups the rest by path", () => {
+    const view = payload({
+      node: { ...nodeRef({ file: 'src/service.ts' }), startColumn: 0, endColumn: 0, lines: 11 },
+      incoming: {
+        total: 3,
+        shown: 3,
+        truncated: false,
+        items: [
+          caller({ id: 'z', file: 'src/z.ts' }),
+          caller({ id: 'a', file: 'src/a.ts' }),
+          caller({ id: 'own', file: 'src/service.ts' }),
+        ],
+      },
+    });
+    const rail = buildCallerRail(view);
+    expect(rail.groups.map((g) => g.file)).toEqual(['src/service.ts', 'src/a.ts', 'src/z.ts']);
+    expect(rail.groups[0]?.same).toBe(true);
+    expect(rail.groups[1]?.same).toBe(false);
+  });
+
+  it('folds test callers away with their call and file counts intact', () => {
+    const view = payload({
+      incoming: {
+        total: 3,
+        shown: 3,
+        truncated: false,
+        items: [
+          caller({ id: 'prod', file: 'src/a.ts' }),
+          caller({ id: 't1', file: '__tests__/a.test.ts', test: true, edges: 4 }),
+          caller({ id: 't2', file: '__tests__/b.test.ts', test: true, edges: 2 }),
+        ],
+      },
+    });
+    const rail = buildCallerRail(view);
+    expect(rail.groups).toHaveLength(1);
+    expect(rail.tests.rows).toHaveLength(2);
+    expect(rail.tests.calls).toBe(6);
+    expect(rail.tests.files).toEqual(['__tests__/a.test.ts', '__tests__/b.test.ts']);
+    // The header count stays the real one — nothing is silently dropped.
+    expect(rail.total).toBe(3);
+  });
+
+  it('folds an uncertain test caller as uncertain, not as a test', () => {
+    // Uncertainty is a claim about the EDGE. Filing it under "tests" would
+    // present a name-only guess as an established call.
+    const view = payload({
+      incoming: {
+        total: 1,
+        shown: 1,
+        truncated: false,
+        items: [caller({ id: 'g', file: '__tests__/a.test.ts', test: true, uncertain: true })],
+      },
+    });
+    const rail = buildCallerRail(view);
+    expect(rail.uncertain).toHaveLength(1);
+    expect(rail.tests.rows).toHaveLength(0);
+    expect(rail.groups).toHaveLength(0);
+  });
+
+  it('reports the callers the API had to cap away', () => {
+    const view = payload({
+      incoming: { total: 545, shown: 1, truncated: true, items: [caller({ id: 'a', file: 'src/a.ts' })] },
+    });
+    expect(buildCallerRail(view).hiddenGroups).toBe(544);
+  });
+});
+
+describe('buildCalleeRail', () => {
+  it('anchors each row to its first call site and folds the guesses to the bottom', () => {
+    const view = payload({
+      outgoing: {
+        total: 2,
+        shown: 2,
+        truncated: false,
+        items: [
+          relation({ node: { id: 'sure' }, lines: [12, 18] }),
+          { ...relation({ node: { id: 'guess' }, lines: [15] }), uncertain: true, confidence: 0.4 },
+        ],
+      },
+    });
+    const rail = buildCalleeRail(view);
+    expect(rail.rows).toHaveLength(1);
+    expect(rail.rows[0]?.anchor).toBe(12);
+    expect(rail.rows[0]?.lines).toEqual([12, 18]);
+    expect(rail.uncertain).toHaveLength(1);
+  });
+
+  it('separates calls that leave the index from type references that do', () => {
+    const view = payload({
+      outsideIndex: { total: 24, byKind: { calls: 21, references: 2, function_ref: 1 }, samples: [] },
+    });
+    const rail = buildCalleeRail(view);
+    expect(rail.outsideCalls).toBe(22);
+    expect(rail.outsideTypeRefs).toBe(2);
+  });
+
+  it('leaves a row with no recorded line unanchored rather than guessing a height', () => {
+    const view = payload({
+      outgoing: {
+        total: 1,
+        shown: 1,
+        truncated: false,
+        items: [{ ...relation({ lines: [] }), lines: [], edges: [] } as WireRelation],
+      },
+    });
+    expect(buildCalleeRail(view).rows[0]?.anchor).toBeNull();
+  });
+});
+
+/* -------------------------------------------------------------- outline -- */
+
+describe('members outline', () => {
+  it('dims data members and indents the ones nested a level deeper', () => {
+    const view = payload({
+      members: {
+        total: 2,
+        shown: 2,
+        truncated: false,
+        items: [
+          { ...nodeRef({ kind: 'property', name: 'store' }), parentId: 'x', depth: 1, fanIn: 1, fanOut: 0 },
+          { ...nodeRef({ kind: 'method', name: 'read' }), parentId: 'y', depth: 2, fanIn: 3, fanOut: 5 },
+        ] as any,
+      },
+    });
+    const rows = buildOutline(view);
+    expect(rows[0]).toMatchObject({ dimmed: true, nested: false });
+    expect(rows[1]).toMatchObject({ dimmed: false, nested: true });
+  });
+});
+
+describe('showsBody', () => {
+  it("swaps a large container's body for its outline, and keeps a large function's", () => {
+    expect(showsBody('class', 700)).toBe(false);
+    expect(showsBody('file', 2000)).toBe(false);
+    expect(showsBody('class', 40)).toBe(true);
+    // A 700-line function IS its body — there is no outline to show instead.
+    expect(showsBody('function', 700)).toBe(true);
+    expect(showsBody('method', 259)).toBe(true);
+  });
+});
+
+/* ---------------------------------------------------------------- lexer -- */
+
+describe('tokenize', () => {
+  const kinds = (line: string, state = newLexState(), language = 'typescript') =>
+    tokenize(line, state, language).map((t) => `${t.cls}:${t.text}`);
+
+  it('separates the four things the near-monochrome theme colours', () => {
+    expect(kinds('const x = 1; // note')).toEqual([
+      'keyword:const',
+      'space: ',
+      'ident:x',
+      'space: ',
+      'punct:=',
+      'space: ',
+      'number:1',
+      'punct:;',
+      'space: ',
+      'comment:// note',
+    ]);
+  });
+
+  it('carries a block comment across lines so the next line is not read as code', () => {
+    const state = newLexState();
+    expect(kinds('/* open', state)).toEqual(['comment:/* open']);
+    expect(state.block).toBe(true);
+    expect(kinds('still comment', state)).toEqual(['comment:still comment']);
+    expect(kinds('done */ const x = 1;', state)).toEqual([
+      'comment:done */',
+      'space: ',
+      'keyword:const',
+      'space: ',
+      'ident:x',
+      'space: ',
+      'punct:=',
+      'space: ',
+      'number:1',
+      'punct:;',
+    ]);
+    expect(state.block).toBe(false);
+  });
+
+  it('carries a template literal across lines, and closes it on the right backtick', () => {
+    const state = newLexState();
+    expect(kinds('const s = `open', state)).toContain('string:`open');
+    expect(state.stringEnd).toBe('`');
+    expect(kinds('closed` + x', state)).toEqual([
+      'string:closed`',
+      'space: ',
+      'punct:+',
+      'space: ',
+      'ident:x',
+    ]);
+  });
+
+  it('does not eat the rest of a window on an apostrophe in prose', () => {
+    // An unterminated single-line quote is punctuation in English far more
+    // often than a real string, so it stops at the line.
+    const state = newLexState();
+    kinds("// it's fine", state);
+    expect(state.stringEnd).toBeNull();
+    const after = kinds('const x = 1;', state);
+    expect(after[0]).toBe('keyword:const');
+  });
+
+  it('reads a # comment as a comment in Python and as code in TypeScript', () => {
+    expect(kinds('# note', newLexState(), 'python')).toEqual(['comment:# note']);
+    expect(kinds('x = 1  # note', newLexState(), 'python').at(-1)).toBe('comment:# note');
+    expect(kinds('# note', newLexState(), 'typescript')[0]).not.toBe('comment:# note');
+  });
+
+  it('closes a Python triple-quoted string on the triple, not on the first quote', () => {
+    const state = newLexState();
+    expect(kinds('"""docstring', state, 'python')).toEqual(['string:"""docstring']);
+    expect(state.stringEnd).toBe('"""');
+    expect(kinds('more"""', state, 'python')).toEqual(['string:more"""']);
+  });
+
+  it("reports each token's column, which is how a ref finds its identifier", () => {
+    const tokens = tokenize('  return render();', newLexState(), 'typescript');
+    const render = tokens.find((t) => t.text === 'render');
+    expect(render?.col).toBe('  return '.length);
+  });
+
+  it('falls back to a C-family reading for a language it has no table for', () => {
+    // Silence beats a wrong claim, but a plain `//` comment is not a claim
+    // worth getting wrong in a language we have not enumerated.
+    expect(kinds('// note', newLexState(), 'some-new-language')).toEqual(['comment:// note']);
+  });
+});

+ 26 - 0
src/db/queries.ts

@@ -1961,6 +1961,32 @@ export class QueryBuilder {
     return out;
     return out;
   }
   }
 
 
+  /**
+   * The nodes with the most DISTINCT dependents, most first.
+   *
+   * "Distinct" is the difference that matters: a helper called forty times from
+   * one function has a fan-in of 40 but exactly one dependent. This counts the
+   * second thing — the number a reader means by "N callers" — so the top of
+   * this list is the set of symbols a change actually radiates furthest from.
+   *
+   * `contains` is excluded because it is structure, not dependency: counting it
+   * would rank every file and class above the code they hold.
+   */
+  getTopDependedOn(limit: number): Array<{ nodeId: string; dependents: number }> {
+    if (limit <= 0) return [];
+    const rows = this.db
+      .prepare(
+        `SELECT target AS nodeId, COUNT(DISTINCT source) AS dependents
+           FROM edges
+          WHERE kind != 'contains' AND source != target
+       GROUP BY target
+       ORDER BY dependents DESC
+          LIMIT ?`
+      )
+      .all(limit) as Array<{ nodeId: string; dependents: number }>;
+    return rows;
+  }
+
   /**
   /**
    * References recorded against a symbol that never resolved to a node — the
    * References recorded against a symbol that never resolved to a node — the
    * calls and type mentions that leave the index (a third-party package, a
    * calls and type mentions that leave the index (a third-party package, a

+ 9 - 0
src/index.ts

@@ -1368,6 +1368,15 @@ export class CodeGraph {
     return this.queries.countOutgoingEdges(ids);
     return this.queries.countOutgoingEdges(ids);
   }
   }
 
 
+  /**
+   * The symbols with the most distinct dependents, most first — the index's
+   * hubs. Distinct dependents, not edges: a helper called forty times from one
+   * function has one dependent, and it is dependents a blast radius grows from.
+   */
+  getTopDependedOn(limit: number): Array<{ nodeId: string; dependents: number }> {
+    return this.queries.getTopDependedOn(limit);
+  }
+
   /**
   /**
    * References from a symbol that never resolved to an indexed node — the
    * References from a symbol that never resolved to an indexed node — the
    * calls and type mentions that leave the index. Lets a reader account for
    * calls and type mentions that leave the index. Lets a reader account for

+ 23 - 4
src/ui-server/api/node.ts

@@ -26,6 +26,7 @@ import { isTestFile } from '../../search/query-utils';
 import { notFound } from './respond';
 import { notFound } from './respond';
 import { findIndexedFile, hasDriftedOnDisk } from './source';
 import { findIndexedFile, hasDriftedOnDisk } from './source';
 import {
 import {
+  BLAST_DEPTH,
   CALLER_EDGE_KINDS,
   CALLER_EDGE_KINDS,
   CONTAINER_KINDS,
   CONTAINER_KINDS,
   HUB_THRESHOLD,
   HUB_THRESHOLD,
@@ -46,15 +47,23 @@ import {
   type WireNodeRef,
   type WireNodeRef,
 } from './wire';
 } from './wire';
 
 
-/** Depth the blast-radius summary walks. Matches `codegraph_explore`'s claim. */
-const BLAST_DEPTH = 3;
-
 /** A member row in the focal symbol's outline, with its place in the tree. */
 /** A member row in the focal symbol's outline, with its place in the tree. */
 export interface WireMember extends WireNodeRef {
 export interface WireMember extends WireNodeRef {
   /** The container this member belongs to — the focal node, or one of its children. */
   /** The container this member belongs to — the focal node, or one of its children. */
   parentId: string;
   parentId: string;
   /** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
   /** 1 = direct member, 2 = a member of a member (a class's method inside a file). */
   depth: number;
   depth: number;
+  /**
+   * Edges in and out of this member — the outline's `← in  → out` columns.
+   *
+   * A container's own fan-out is usually zero (a class calls nothing; its
+   * methods do), so without these an outline of a 700-line class says nothing
+   * about which member is load-bearing and which is a getter. Edge counts, not
+   * distinct counterparts: the column is a weight, and it sits beside a
+   * signature rather than beside a caller list it could contradict.
+   */
+  fanIn: number;
+  fanOut: number;
 }
 }
 
 
 export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
 export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
@@ -234,11 +243,21 @@ function buildMembers(
   const all = [...direct, ...nested].sort(
   const all = [...direct, ...nested].sort(
     (a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
     (a, b) => a.node.startLine - b.node.startLine || a.node.name.localeCompare(b.node.name)
   );
   );
+  const shown = all.slice(0, MAX_OUTLINE_NODES);
+
+  // Two queries for the whole outline, not two per row: a file with 400
+  // symbols would otherwise be 800 lookups behind one screen.
+  const memberIds = shown.map((entry) => entry.node.id);
+  const fanIn = cg.getFanIn(memberIds);
+  const fanOut = cg.getFanOut(memberIds);
+
   return {
   return {
-    items: all.slice(0, MAX_OUTLINE_NODES).map((entry) => ({
+    items: shown.map((entry) => ({
       ...toNodeRef(entry.node),
       ...toNodeRef(entry.node),
       parentId: entry.parentId,
       parentId: entry.parentId,
       depth: entry.depth,
       depth: entry.depth,
+      fanIn: fanIn.get(entry.node.id) ?? 0,
+      fanOut: fanOut.get(entry.node.id) ?? 0,
     })),
     })),
     total: all.length,
     total: all.length,
   };
   };

+ 87 - 1
src/ui-server/api/stats.ts

@@ -11,7 +11,86 @@
 
 
 import * as path from 'path';
 import * as path from 'path';
 import type { CodeGraph } from '../../index';
 import type { CodeGraph } from '../../index';
-import { HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire';
+import { BLAST_DEPTH, HUB_THRESHOLD, UNCERTAIN_BELOW } from './wire';
+
+/**
+ * How many of the index's most-depended-on symbols the blast scale measures.
+ *
+ * The Symbol view's blast bar is a comparison — "wide for this repo, or
+ * narrow?" — so it needs a denominator, and the honest one is the widest
+ * radius in the index. Measuring all of them means a depth-3 traversal per
+ * symbol, which on a large repo is minutes. Measuring the most-depended-on
+ * ones costs 24 traversals and finds the widest radius in practice: a radius
+ * is grown by dependents, so the symbol with the widest one is very nearly
+ * always near the top of that list.
+ *
+ * "Very nearly always" is not "always" — a symbol with three dependents that
+ * each have three hundred can beat them — so the scale is a floor, not a
+ * claim: {@link blastScaleFor} reports it as `sampled`, and the viewer raises
+ * it whenever the symbol on screen exceeds it rather than drawing past 100%.
+ */
+const BLAST_SCALE_SAMPLE = 24;
+
+export interface WireBlastScale {
+  /** Most distinct dependents any symbol in the index has. Exact — one query. */
+  maxDirect: number;
+  /** Widest depth-{@link BLAST_DEPTH} radius found across the sampled symbols. */
+  maxWithinHops: number;
+  hops: number;
+  /** How many symbols were measured for `maxWithinHops`. */
+  sampled: number;
+  /** True whenever `maxWithinHops` came from a sample rather than every symbol. */
+  estimated: boolean;
+}
+
+/**
+ * The denominator for the Symbol view's blast bar.
+ *
+ * Computed once per process and cached against the index's build stamp: it is
+ * a property of the whole graph, every Symbol view needs it, and re-deriving it
+ * per request would put 24 traversals in front of every screen.
+ */
+let cachedScale: { key: string; value: WireBlastScale } | null = null;
+
+export function blastScaleFor(
+  cg: CodeGraph,
+  projectRoot: string,
+  edgeCount: number
+): WireBlastScale {
+  // Keyed on the project AND the index's stamp AND its edge count, so a
+  // re-index (or a sync that only moved edges) invalidates it and two indexes
+  // opened by one process cannot share a denominator. A stale one would
+  // silently rescale every bar in the app.
+  const key = `${projectRoot}\u0000${cg.getLastIndexedAt() ?? 0}:${edgeCount}`;
+  if (cachedScale?.key === key) return cachedScale.value;
+
+  const top = cg.getTopDependedOn(BLAST_SCALE_SAMPLE);
+  let maxWithinHops = 0;
+  for (const candidate of top) {
+    try {
+      const subgraph = cg.getImpactRadius(candidate.nodeId, BLAST_DEPTH);
+      maxWithinHops = Math.max(maxWithinHops, subgraph.nodes.size - 1);
+    } catch {
+      // A candidate that cannot be traversed (a node the edge table names but
+      // the node table lost) narrows the sample; it must not fail the screen.
+    }
+  }
+
+  const value: WireBlastScale = {
+    maxDirect: top[0]?.dependents ?? 0,
+    maxWithinHops,
+    hops: BLAST_DEPTH,
+    sampled: top.length,
+    estimated: true,
+  };
+  cachedScale = { key, value };
+  return value;
+}
+
+/** Drop the memoised scale — for tests, which build a fresh index per case. */
+export function resetBlastScaleCache(): void {
+  cachedScale = null;
+}
 
 
 export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
 export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
   const stats = cg.getStats();
   const stats = cg.getStats();
@@ -59,5 +138,12 @@ export function buildStats(cg: CodeGraph, projectRoot: string): unknown {
      * second copy of the same numbers.
      * second copy of the same numbers.
      */
      */
     thresholds: { hub: HUB_THRESHOLD, uncertainBelow: UNCERTAIN_BELOW },
     thresholds: { hub: HUB_THRESHOLD, uncertainBelow: UNCERTAIN_BELOW },
+    /**
+     * The denominator the Symbol view's blast bar is drawn against, so one
+     * symbol's radius reads as wide or narrow *for this repo* instead of as a
+     * bare number. See {@link blastScaleFor} for what "sampled" costs and
+     * concedes.
+     */
+    blastScale: blastScaleFor(cg, projectRoot, stats.edgeCount),
   };
   };
 }
 }

+ 6 - 0
src/ui-server/api/wire.ts

@@ -48,6 +48,12 @@ export const MAX_EDGES_PER_GROUP = 40;
 /** Test files named in a node's test-caller summary (explore uses the same shape). */
 /** Test files named in a node's test-caller summary (explore uses the same shape). */
 export const MAX_TEST_FILES = 6;
 export const MAX_TEST_FILES = 6;
 
 
+/**
+ * Dependency hops the blast-radius summary walks. Matches the depth
+ * `codegraph_explore` claims when it says "within 3 hops".
+ */
+export const BLAST_DEPTH = 3;
+
 /** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
 /** Caller hops walked looking for a test. Mirrors `codegraph_explore`'s "tests:" line. */
 export const TEST_CALLER_HOPS = 3;
 export const TEST_CALLER_HOPS = 3;
 
 

+ 9 - 6
ui/src/App.svelte

@@ -10,13 +10,16 @@
   import NotFoundView from './views/NotFoundView.svelte';
   import NotFoundView from './views/NotFoundView.svelte';
   import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
   import { router, navigate, back, mapHref, flowHref } from './lib/router.svelte';
   import { trail } from './lib/trail.svelte';
   import { trail } from './lib/trail.svelte';
+  import { project } from './lib/project.svelte';
 
 
-  // Filled by the project stats call once the JSON API exists (CG-42); the
-  // top bar renders nothing rather than a placeholder until then.
-  let project = $state<string | null>(null);
-  let stats = $state<string | null>(null);
   let query = $state('');
   let query = $state('');
 
 
+  // One `/api/stats` for the whole app: the top bar's counts and the Symbol
+  // view's blast-radius denominator come out of the same payload.
+  $effect(() => {
+    void project.ensure();
+  });
+
   let topbar: TopBar | null = $state(null);
   let topbar: TopBar | null = $state(null);
 
 
   let route = $derived(router.route);
   let route = $derived(router.route);
@@ -81,7 +84,7 @@
 
 
 <svelte:window {onkeydown} />
 <svelte:window {onkeydown} />
 
 
-<TopBar bind:this={topbar} bind:query {project} {stats} />
+<TopBar bind:this={topbar} bind:query project={project.name} stats={project.summary} />
 <TrailBar />
 <TrailBar />
 <main>
 <main>
   {#if route.view === 'symbol'}
   {#if route.view === 'symbol'}
@@ -95,7 +98,7 @@
   {:else if route.view === 'unknown'}
   {:else if route.view === 'unknown'}
     <NotFoundView path={route.path} />
     <NotFoundView path={route.path} />
   {:else}
   {:else}
-    <HomeView {project} />
+    <HomeView project={project.name} />
   {/if}
   {/if}
 </main>
 </main>
 
 

+ 11 - 2
ui/src/components/TrailBar.svelte

@@ -31,8 +31,17 @@
   {:else}
   {:else}
     {#each hops as hop, i (hop.id)}
     {#each hops as hop, i (hop.id)}
       {#if i > 0}
       {#if i > 0}
-        <span class="hop-arrow" class:up={hop.dir === 'up'} aria-hidden="true">
-          {hop.dir === 'up' ? '←' : '→'}
+        <span
+          class="hop-arrow"
+          class:up={hop.dir === 'up'}
+          title={hop.dir === 'up'
+            ? 'stepped up to a caller'
+            : hop.dir === 'down'
+              ? 'stepped down into a call'
+              : 'jumped here'}
+          aria-hidden="true"
+        >
+          {hop.dir === 'up' ? '←' : hop.dir === 'down' ? '→' : '·'}
         </span>
         </span>
       {/if}
       {/if}
       <button
       <button

+ 202 - 0
ui/src/components/symbol/BlastStrip.svelte

@@ -0,0 +1,202 @@
+<!--
+  What would need re-checking if this symbol changed (design spec §3.2).
+
+  The bar exists because the numbers alone do not answer the question a reader
+  actually has, which is comparative: is 19 dependents a lot? So both fills are
+  drawn against the widest radius in the index (`/api/stats` → `blastScale`),
+  and the legend says so rather than letting a full-width bar imply "everything".
+
+  The scale is sampled, not exhaustive — measuring every symbol's radius means a
+  traversal per symbol. When the symbol on screen is wider than the sample found,
+  it becomes the scale instead of overflowing it: a bar that runs past its track
+  is a drawing bug, and clamping silently would be a lie about the comparison.
+-->
+<script lang="ts">
+  import { fileHref } from '../../lib/router.svelte';
+  import { plural } from '../../lib/symbol-model';
+  import type { WireBlastScale, WireBlastSummary } from '../../lib/api';
+
+  interface Props {
+    blast: WireBlastSummary;
+    scale: WireBlastScale | null;
+    /** Calls from test files — the tests that would catch a regression. */
+    testCalls: number;
+    testFiles: number;
+  }
+
+  let { blast, scale, testCalls, testFiles }: Props = $props();
+
+  let maxDirect = $derived(Math.max(1, scale?.maxDirect ?? 0, blast.direct));
+  let maxWithin = $derived(Math.max(1, scale?.maxWithinHops ?? 0, blast.withinHops));
+
+  const share = (value: number, max: number): number =>
+    value <= 0 ? 0 : Math.max(0.5, Math.min(100, (100 * value) / max));
+</script>
+
+<div class="blast">
+  <div class="bh">
+    <b>Blast radius</b>
+    <span class="stat"><strong>{blast.direct}</strong> direct dependent{blast.direct === 1 ? '' : 's'}</span>
+    <span class="stat"><strong>{blast.withinHops}</strong> within {blast.hops} hops</span>
+    <span class="stat"><strong>{blast.files}</strong> file{blast.files === 1 ? '' : 's'}</span>
+    <span class="stat"><strong>{blast.testFiles}</strong> test file{blast.testFiles === 1 ? '' : 's'}</span>
+    {#if blast.routes > 0}
+      <span class="stat"><strong>{blast.routes}</strong> route{blast.routes === 1 ? '' : 's'}</span>
+    {/if}
+  </div>
+
+  <div
+    class="bar"
+    title={`Scaled to the widest radius in the index: ${maxWithin} symbols within ${blast.hops} hops.`}
+  >
+    <i style:width={`${share(blast.withinHops, maxWithin)}%`}></i>
+    <i class="direct" style:width={`${share(blast.direct, maxDirect)}%`}></i>
+  </div>
+
+  <div class="legend">
+    dark: direct dependents · light: within {blast.hops} hops — scaled to the widest radius in the
+    index{#if scale?.estimated}{' '}<span class="dim"
+        >(measured across its {scale.sampled} most-depended-on symbols)</span
+      >{/if}
+  </div>
+
+  {#if blast.topFiles.length > 0}
+    <details>
+      <summary>What would need re-checking if this changed</summary>
+      <div class="body">
+        {#each blast.topFiles as entry (entry.file)}
+          <div class="fp">
+            <a href={fileHref(entry.file)} class:test={entry.test}>{entry.file}</a>
+            <b>{entry.symbols}</b>
+          </div>
+        {/each}
+        {#if blast.files > blast.topFiles.length}
+          <div class="fp dim">+{blast.files - blast.topFiles.length} more files</div>
+        {/if}
+        {#if testCalls > 0}
+          <div class="note">
+            plus {plural(testCalls, 'call')} from {plural(testFiles, 'test file')} — the tests that
+            would catch a regression.
+          </div>
+        {/if}
+      </div>
+    </details>
+  {/if}
+</div>
+
+<style>
+  .blast {
+    margin-top: 22px;
+    padding-top: 10px;
+    border-top: 1px solid var(--rule);
+  }
+
+  .bh {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 6px 14px;
+  }
+
+  .bh b {
+    font-weight: 600;
+  }
+
+  .stat {
+    color: var(--ink-2);
+    font-size: 12.5px;
+  }
+
+  .stat strong {
+    color: var(--ink);
+    font-weight: 600;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .bar {
+    position: relative;
+    max-width: 420px;
+    height: 6px;
+    margin-top: 8px;
+    background: var(--press);
+  }
+
+  .bar i {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    background: var(--ink-2);
+  }
+
+  /* Drawn second so the shorter, darker "direct" share sits over the lighter
+     "within N hops" one rather than beside it — they are nested quantities. */
+  .bar i.direct {
+    background: var(--ink);
+  }
+
+  .legend {
+    margin-top: 4px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+
+  details {
+    margin-top: 8px;
+  }
+
+  summary {
+    color: var(--ink-2);
+    cursor: pointer;
+    font-size: 12px;
+    list-style: none;
+  }
+
+  summary::-webkit-details-marker {
+    display: none;
+  }
+
+  summary::before {
+    content: '+ ';
+    color: var(--ink-3);
+    font-family: var(--mono);
+  }
+
+  details[open] summary::before {
+    content: '− ';
+  }
+
+  .body {
+    padding-top: 6px;
+  }
+
+  .fp {
+    display: flex;
+    justify-content: space-between;
+    gap: 10px;
+    padding: 2px 0;
+    color: var(--ink-2);
+    font: 11px var(--mono);
+  }
+
+  .fp a:hover {
+    color: var(--ink);
+    text-decoration: underline;
+  }
+
+  .fp a.test {
+    color: var(--ink-3);
+  }
+
+  .fp b {
+    color: var(--ink);
+    font-weight: 500;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .note {
+    padding-top: 6px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+</style>

+ 280 - 0
ui/src/components/symbol/CalleeRail.svelte

@@ -0,0 +1,280 @@
+<!--
+  Calls — the right rail (design spec §3.2).
+
+  Every row is absolutely positioned beside the line that makes the call, which
+  is the whole idea of the screen: the callee list is not a list, it is an
+  annotation of the body. Rows keep source order and are pushed down when two
+  call sites are closer together than a row is tall, so the sequence still reads
+  top to bottom even where the geometry cannot be exact.
+
+  The tops are computed by the view, which is the only thing that can measure
+  where a line ended up. This component draws what it is told.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import { hot, railFocus } from '../../lib/focus.svelte';
+  import { plural, type CalleeRailModel, type CalleeRow } from '../../lib/symbol-model';
+  import type { WireNodeRef } from '../../lib/api';
+
+  interface Props {
+    model: CalleeRailModel;
+    /** Top offset in px for each row in `model.rows`, same order. */
+    tops: number[];
+    foldTop: number;
+    noteTop: number;
+    /** The focal symbol's file — a callee in it reads "same file", not a path. */
+    focalFile: string;
+    /** The symbol this one was reached from, when it is a callee. */
+    originId: string | null;
+    /** Empty-rail wording depends on why it is empty. */
+    emptyReason: string;
+    onstepDown: (node: WireNodeRef) => void;
+  }
+
+  let { model, tops, foldTop, noteTop, focalFile, originId, emptyReason, onstepDown }: Props =
+    $props();
+
+  function rowTitle(row: CalleeRow): string {
+    return `${row.relation.node.qualifiedName} — ${row.relation.node.file}:${row.relation.node.line}`;
+  }
+</script>
+
+<div class="rail-h" data-rail-header>
+  <span>Calls <span class="n">{model.rows.length}</span></span>
+  <span class="hint">step down →</span>
+</div>
+
+{#each model.rows as row, i (row.relation.node.id)}
+  {@const node = row.relation.node}
+  <div
+    class="rrow"
+    class:origin={node.id === originId}
+    class:hot={hot.is(node.id)}
+    class:sel={railFocus.at('right', i)}
+    style:top={`${tops[i] ?? 0}px`}
+    data-target={node.id}
+    role="button"
+    tabindex="0"
+    title={rowTitle(row)}
+    onclick={() => onstepDown(node)}
+    onkeydown={(e) => {
+      if (e.key === 'Enter' || e.key === ' ') {
+        e.preventDefault();
+        onstepDown(node);
+      }
+    }}
+    onmouseenter={() => hot.set(node.id)}
+    onmouseleave={() => hot.clear(node.id)}
+  >
+    <KindGlyph kind={node.kind} />
+    <div class="body">
+      <div class="nm">
+        {node.name}{#if row.lines.length > 1}<span class="dim"> ×{row.lines.length}</span>{/if}
+      </div>
+      <div class="meta">
+        <span>{node.file === focalFile ? 'same file' : node.file}</span>
+        {#if row.words.length > 0}<span>{row.words.join(', ')}</span>{/if}
+        {#if row.relation.hub}<span class="tag">hub · {row.relation.fanIn}</span>{/if}
+        {#if row.via}<span class="tag" title="A synthesized edge — dynamic dispatch the parser cannot see"
+            >via {row.via}</span
+          >{/if}
+      </div>
+    </div>
+  </div>
+{/each}
+
+{#if model.uncertain.length > 0}
+  <details class="rfold" data-rail-fold style:top={`${foldTop}px`}>
+    <summary>
+      Uncertain <span class="dim"
+        >· {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'},
+        confidence &lt; 0.6</span
+      >
+    </summary>
+    <div class="fold-body">
+      {#each model.uncertain as row (row.relation.node.id)}
+        {@const node = row.relation.node}
+        <div
+          class="rrow static uncertain"
+          class:hot={hot.is(node.id)}
+          data-target={node.id}
+          role="button"
+          tabindex="0"
+          title={rowTitle(row)}
+          onclick={() => onstepDown(node)}
+          onkeydown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') {
+              e.preventDefault();
+              onstepDown(node);
+            }
+          }}
+          onmouseenter={() => hot.set(node.id)}
+          onmouseleave={() => hot.clear(node.id)}
+        >
+          <KindGlyph kind={node.kind} />
+          <div class="body">
+            <div class="nm">{node.name}</div>
+            <div class="meta">
+              <span>{node.file}</span>
+              {#if row.relation.confidence !== null}<span>{row.relation.confidence}</span>{/if}
+            </div>
+          </div>
+        </div>
+      {/each}
+    </div>
+  </details>
+{/if}
+
+{#if model.rows.length === 0 && model.uncertain.length === 0}
+  <div class="rnote" style:top="60px">{emptyReason}</div>
+{:else if model.outsideCalls > 0 || model.outsideTypeRefs > 0 || model.hiddenGroups > 0}
+  <div class="rnote" style:top={`${noteTop}px`}>
+    {#if model.outsideCalls > 0}
+      +{plural(model.outsideCalls, 'more call')} into symbols outside the index{#if model.outsideTypeRefs > 0}{' '}·
+        {plural(model.outsideTypeRefs, 'type reference')}{/if}.
+    {:else if model.outsideTypeRefs > 0}
+      {plural(model.outsideTypeRefs, 'type reference')} into symbols outside the index.
+    {/if}
+    {#if model.hiddenGroups > 0}
+      <br />+{model.hiddenGroups} more callee{model.hiddenGroups === 1 ? '' : 's'} not shown.
+    {/if}
+  </div>
+{/if}
+
+<style>
+  .rail-h {
+    position: sticky;
+    top: 0;
+    z-index: 2;
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    padding: 12px 14px 8px;
+    border-bottom: 1px solid var(--rule-soft);
+    background: var(--paper);
+    font-weight: 600;
+    font-size: 13px;
+  }
+
+  .rail-h .n {
+    color: var(--ink-3);
+    font-weight: 400;
+  }
+
+  .rail-h .hint {
+    color: var(--ink-3);
+    font-weight: 400;
+    font-size: 11.5px;
+  }
+
+  .rrow {
+    position: absolute;
+    right: 12px;
+    left: 14px;
+    display: grid;
+    grid-template-columns: 16px 1fr;
+    gap: 8px;
+    align-items: center;
+    height: 34px;
+    padding: 0 6px;
+    border: 1px solid transparent;
+    cursor: pointer;
+  }
+
+  /* Inside the uncertain fold the rows are a list again — nothing to line up
+     with, because an unresolved edge has no trustworthy call site. */
+  .rrow.static {
+    position: static;
+    height: auto;
+    padding: 4px 6px;
+  }
+
+  .rrow:hover {
+    background: var(--press);
+  }
+
+  .rrow.sel {
+    border-color: var(--ink);
+  }
+
+  .rrow.hot {
+    border-color: var(--accent-line);
+    background: var(--accent-soft);
+  }
+
+  .rrow.origin {
+    background: var(--accent-soft);
+  }
+
+  .body {
+    min-width: 0;
+  }
+
+  .nm {
+    overflow: hidden;
+    font: 12.5px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .rrow.uncertain .nm {
+    color: var(--ink-2);
+    text-decoration: underline dotted var(--ink-4);
+    text-underline-offset: 3px;
+  }
+
+  .meta {
+    display: flex;
+    gap: 8px;
+    overflow: hidden;
+    color: var(--ink-3);
+    font-size: 11px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .tag {
+    flex: 0 0 auto;
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-3);
+    font-size: 10.5px;
+  }
+
+  .rfold {
+    position: absolute;
+    right: 12px;
+    left: 14px;
+  }
+
+  .rfold summary {
+    padding: 6px;
+    color: var(--ink-2);
+    cursor: pointer;
+    font-size: 12px;
+    list-style: none;
+  }
+
+  .rfold summary::-webkit-details-marker {
+    display: none;
+  }
+
+  .rfold summary::before {
+    content: '+ ';
+    color: var(--ink-3);
+    font-family: var(--mono);
+  }
+
+  .rfold[open] summary::before {
+    content: '− ';
+  }
+
+  .rnote {
+    position: absolute;
+    right: 12px;
+    left: 20px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    line-height: 1.4;
+  }
+</style>

+ 357 - 0
ui/src/components/symbol/CallersRail.svelte

@@ -0,0 +1,357 @@
+<!--
+  Called by — the left rail (design spec §3.2).
+
+  Grouped by file, the symbol's own file first as "same file", because the
+  first question about a caller is "is this local, or does it come from
+  somewhere else in the repo". The call-site chips (`:4657`) are the useful
+  part: clicking one opens the caller already scrolled to the line that makes
+  the call, which is the step a reader would otherwise do by hand.
+
+  This rail draws no connectors. It scrolls independently of the code, so a
+  line drawn to a caller row would point at the wrong place the moment either
+  side moved.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import { fileHref } from '../../lib/router.svelte';
+  import { hot, railFocus } from '../../lib/focus.svelte';
+  import { basename, plural, type CallerRailModel, type CallerRow } from '../../lib/symbol-model';
+  import type { WireNodeRef } from '../../lib/api';
+
+  interface Props {
+    model: CallerRailModel;
+    /** The symbol this one was reached from, when it is a caller. */
+    originId: string | null;
+    exported: boolean;
+    /** Follow a caller, optionally landing on one of its call sites. */
+    onstepUp: (node: WireNodeRef, line?: number) => void;
+  }
+
+  let { model, originId, exported, onstepUp }: Props = $props();
+
+  /** A file node's own "symbol" is the file's top level; say so. */
+  function rowName(node: WireNodeRef): string {
+    return node.kind === 'file' ? `${basename(node.file)} (top level)` : node.name;
+  }
+
+  function rowTitle(row: CallerRow): string {
+    return `${row.relation.node.qualifiedName} — ${row.relation.node.file}:${row.relation.node.line}`;
+  }
+
+  /**
+   * A row's place in the flat order the keyboard walks (file groups in order,
+   * folds excluded — arrowing into collapsed content would move a selection
+   * nobody can see). Computed from the group offsets so the rail can stay a
+   * nested render while the keyboard sees one list.
+   */
+  function indexOf(groupIndex: number, rowIndex: number): number {
+    let base = 0;
+    for (let i = 0; i < groupIndex; i++) base += model.groups[i]?.rows.length ?? 0;
+    return base + rowIndex;
+  }
+</script>
+
+<div class="rail-h">
+  <span>Called by <span class="n">{model.total}</span></span>
+  <span class="hint">← step up</span>
+</div>
+
+{#if model.total === 0}
+  <div class="note">
+    Nothing in the graph calls or references this symbol{exported
+      ? ' — it is exported, so callers may live outside the index (or it is an entry point).'
+      : '.'}
+  </div>
+{/if}
+
+{#each model.groups as group, groupIndex (group.file)}
+  <div class="filegroup">
+    <div class="fpath">
+      <a href={fileHref(group.file)} title={group.file}>{group.same ? 'same file' : group.file}</a>
+      <b>{group.rows.length}</b>
+    </div>
+    {#each group.rows as row, rowIndex (row.relation.node.id)}
+      {@const node = row.relation.node}
+      {@const isOrigin = node.id === originId}
+      <div
+        class="row"
+        class:origin={isOrigin}
+        class:sel={railFocus.at('left', indexOf(groupIndex, rowIndex))}
+        role="button"
+        tabindex="0"
+        title={rowTitle(row)}
+        onclick={() => onstepUp(node)}
+        onkeydown={(e) => {
+          if (e.key === 'Enter' || e.key === ' ') {
+            e.preventDefault();
+            onstepUp(node);
+          }
+        }}
+        onmouseenter={() => hot.set(node.id)}
+        onmouseleave={() => hot.clear(node.id)}
+      >
+        <KindGlyph kind={node.kind} />
+        <div>
+          <div class="nm">{rowName(node)}</div>
+          <div class="meta">
+            {#if row.words.length > 0}<span class="kindlbl">{row.words.join(', ')}</span>{/if}
+            {#each row.lines as line (line)}
+              <button
+                type="button"
+                class="chip"
+                title={`Open ${node.name} at line ${line}`}
+                onclick={(e) => {
+                  e.stopPropagation();
+                  onstepUp(node, line);
+                }}>:{line}</button
+              >
+            {/each}
+            {#if row.via}<span class="kindlbl">via {row.via}</span>{/if}
+            {#if isOrigin}<span class="kindlbl">you came from here</span>{/if}
+          </div>
+        </div>
+      </div>
+    {/each}
+  </div>
+{/each}
+
+{#if model.tests.rows.length > 0}
+  <details class="fold">
+    <summary>
+      Tests <span class="dim"
+        >· {plural(model.tests.calls, 'call')} from {plural(model.tests.files.length, 'file')}</span
+      >
+    </summary>
+    <div class="body">
+      {#each model.tests.rows as row (row.relation.node.id)}
+        {@const node = row.relation.node}
+        <div
+          class="row"
+          role="button"
+          tabindex="0"
+          title={rowTitle(row)}
+          onclick={() => onstepUp(node)}
+          onkeydown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') {
+              e.preventDefault();
+              onstepUp(node);
+            }
+          }}
+          onmouseenter={() => hot.set(node.id)}
+          onmouseleave={() => hot.clear(node.id)}
+        >
+          <KindGlyph kind={node.kind} />
+          <div>
+            <div class="nm">{rowName(node)}</div>
+            <div class="meta"><span class="kindlbl">{node.file}</span></div>
+          </div>
+        </div>
+      {/each}
+    </div>
+  </details>
+{/if}
+
+{#if model.uncertain.length > 0}
+  <details class="fold">
+    <summary>
+      Uncertain <span class="dim"
+        >· {model.uncertain.length} name-only match{model.uncertain.length === 1 ? '' : 'es'},
+        confidence &lt; 0.6</span
+      >
+    </summary>
+    <div class="body">
+      {#each model.uncertain as row (row.relation.node.id)}
+        {@const node = row.relation.node}
+        <div
+          class="row uncertain"
+          role="button"
+          tabindex="0"
+          title={rowTitle(row)}
+          onclick={() => onstepUp(node)}
+          onkeydown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') {
+              e.preventDefault();
+              onstepUp(node);
+            }
+          }}
+          onmouseenter={() => hot.set(node.id)}
+          onmouseleave={() => hot.clear(node.id)}
+        >
+          <KindGlyph kind={node.kind} />
+          <div>
+            <div class="nm">{rowName(node)}</div>
+            <div class="meta">
+              <span class="kindlbl">{basename(node.file)}</span>
+              {#if row.relation.confidence !== null}
+                <span class="kindlbl">{row.relation.confidence}</span>
+              {/if}
+            </div>
+          </div>
+        </div>
+      {/each}
+    </div>
+  </details>
+{/if}
+
+{#if model.hiddenGroups > 0}
+  <div class="note">
+    +{model.hiddenGroups} more caller{model.hiddenGroups === 1 ? '' : 's'} not shown — this symbol
+    has more than the rail lists.
+  </div>
+{/if}
+
+<style>
+  .rail-h {
+    position: sticky;
+    top: 0;
+    z-index: 2;
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    padding: 12px 14px 8px;
+    border-bottom: 1px solid var(--rule-soft);
+    background: var(--paper);
+    font-weight: 600;
+    font-size: 13px;
+  }
+
+  .rail-h .n {
+    color: var(--ink-3);
+    font-weight: 400;
+  }
+
+  .rail-h .hint {
+    color: var(--ink-3);
+    font-weight: 400;
+    font-size: 11.5px;
+  }
+
+  .filegroup {
+    padding: 10px 14px 4px;
+  }
+
+  .fpath {
+    display: flex;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 4px;
+    color: var(--ink-3);
+    font: 11px var(--mono);
+  }
+
+  .fpath a:hover {
+    color: var(--ink);
+    text-decoration: underline;
+  }
+
+  .fpath b {
+    color: var(--ink-2);
+    font-weight: 500;
+  }
+
+  .row {
+    position: relative;
+    display: grid;
+    grid-template-columns: 16px 1fr;
+    gap: 8px;
+    align-items: start;
+    margin: 0 -6px;
+    padding: 5px 6px 5px 4px;
+    border: 1px solid transparent;
+    cursor: pointer;
+  }
+
+  .row:hover {
+    background: var(--press);
+  }
+
+  .row.sel {
+    border-color: var(--ink);
+  }
+
+  .row.origin {
+    border-color: var(--accent-line);
+    background: var(--accent-soft);
+  }
+
+  .nm {
+    overflow: hidden;
+    color: var(--ink);
+    font: 12.5px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .row.uncertain .nm {
+    color: var(--ink-2);
+    text-decoration: underline dotted var(--ink-4);
+    text-underline-offset: 3px;
+  }
+
+  .meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px 8px;
+    align-items: baseline;
+    margin-top: 1px;
+    color: var(--ink-3);
+    font-size: 11px;
+  }
+
+  .kindlbl {
+    color: var(--ink-3);
+  }
+
+  .chip {
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink-2);
+    font: 11px var(--mono);
+  }
+
+  .chip:hover {
+    border-color: var(--ink);
+    color: var(--ink);
+  }
+
+  .fold {
+    padding: 8px 14px;
+  }
+
+  .fold > summary {
+    display: flex;
+    gap: 6px;
+    align-items: baseline;
+    color: var(--ink-2);
+    cursor: pointer;
+    font-size: 12px;
+    list-style: none;
+  }
+
+  .fold > summary::-webkit-details-marker {
+    display: none;
+  }
+
+  .fold > summary::before {
+    content: '+';
+    width: 10px;
+    color: var(--ink-3);
+    font-family: var(--mono);
+  }
+
+  .fold[open] > summary::before {
+    content: '−';
+  }
+
+  .fold .body {
+    padding: 6px 0 0 16px;
+  }
+
+  .note {
+    padding: 8px 14px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+    line-height: 1.4;
+  }
+</style>

+ 76 - 0
ui/src/components/symbol/Connectors.svelte

@@ -0,0 +1,76 @@
+<!--
+  The hairlines from a gutter port to its callee row (design spec §3.2).
+
+  One curve per CALL SITE, not per row: a helper called from three lines gets
+  three connectors into one row, which is the honest drawing — the row is the
+  symbol, the curves are the calls.
+
+  Line style carries the claim. Solid means the resolver matched it; dashed
+  `2 3` means it is a name-only guess; dashed `6 3` in a lighter ink means the
+  edge was synthesized rather than parsed (dynamic dispatch), so the reader can
+  see at a glance which parts of the picture the parser actually saw.
+-->
+<script lang="ts">
+  import { hot } from '../../lib/focus.svelte';
+  import type { Connector } from '../../lib/symbol-model';
+
+  interface Props {
+    connectors: Connector[];
+    width: number;
+    height: number;
+  }
+
+  let { connectors, width, height }: Props = $props();
+</script>
+
+<svg
+  class="overlay"
+  {width}
+  {height}
+  viewBox={`0 0 ${width} ${height}`}
+  aria-hidden="true"
+  focusable="false"
+>
+  {#each connectors as connector, i (`${connector.targetId}:${i}`)}
+    <path
+      d={connector.d}
+      class:uncertain={connector.uncertain}
+      class:heur={connector.heuristic}
+      class:origin={connector.origin}
+      class:hot={hot.is(connector.targetId)}
+    />
+  {/each}
+</svg>
+
+<style>
+  .overlay {
+    position: absolute;
+    inset: 0;
+    overflow: visible;
+    pointer-events: none;
+  }
+
+  path {
+    fill: none;
+    stroke: var(--ink-4);
+    stroke-width: 1;
+  }
+
+  path.uncertain {
+    stroke-dasharray: 2 3;
+  }
+
+  path.heur {
+    stroke: var(--ink-3);
+    stroke-dasharray: 6 3;
+  }
+
+  path.origin {
+    stroke: var(--accent);
+  }
+
+  path.hot {
+    stroke: var(--accent);
+    stroke-width: 1.5;
+  }
+</style>

+ 124 - 0
ui/src/components/symbol/MembersOutline.svelte

@@ -0,0 +1,124 @@
+<!--
+  A container's members in source order, with the two numbers that say which
+  one to open (design spec §3.2).
+
+  This replaces the body for anything over 80 lines, and the `← in  → out`
+  columns are why it is a better view than the body rather than a poorer one:
+  a class's own fan-out is nearly always zero because a class calls nothing —
+  its methods do — so scrolling 700 lines of braces tells you less about where
+  the weight sits than twenty rows with their edge counts.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import type { WireNodeRef } from '../../lib/api';
+  import type { OutlineRow } from '../../lib/symbol-model';
+
+  interface Props {
+    rows: OutlineRow[];
+    total: number;
+    truncated: boolean;
+    onopen: (node: WireNodeRef) => void;
+  }
+
+  let { rows, total, truncated, onopen }: Props = $props();
+</script>
+
+<div class="subh">
+  <span>Members</span>
+  <span class="n">{total}</span>
+</div>
+
+<div class="outline">
+  {#each rows as row (row.member.id)}
+    <button
+      type="button"
+      class="orow"
+      class:nested={row.nested}
+      class:dimmed={row.dimmed}
+      onclick={() => onopen(row.member)}
+      title={`${row.member.qualifiedName} — ${row.member.file}:${row.member.line}`}
+    >
+      <KindGlyph kind={row.member.kind} />
+      <span class="nm">{row.member.name}</span>
+      <span class="sig">{row.member.signature ?? ''}</span>
+      <span class="cnt">
+        {#if row.member.fanIn}← {row.member.fanIn}{/if}{#if row.member.fanIn && row.member.fanOut}&nbsp;
+        {/if}{#if row.member.fanOut}→ {row.member.fanOut}{/if}
+      </span>
+    </button>
+  {/each}
+</div>
+
+{#if truncated}
+  <div class="note">
+    Showing {rows.length} of {total} members — open the file to see the rest.
+  </div>
+{/if}
+
+<style>
+  .subh {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+    margin: 18px 0 4px;
+    font-weight: 600;
+    font-size: 13px;
+  }
+
+  .subh .n {
+    color: var(--ink-3);
+    font-weight: 400;
+  }
+
+  .outline {
+    border-top: 1px solid var(--rule);
+  }
+
+  .orow {
+    display: grid;
+    grid-template-columns: 16px minmax(160px, auto) 1fr auto;
+    gap: 10px;
+    align-items: baseline;
+    width: 100%;
+    padding: 6px 4px;
+    border-bottom: 1px solid var(--rule-faint);
+    text-align: left;
+  }
+
+  .orow:hover {
+    background: var(--press);
+  }
+
+  .orow.nested {
+    padding-left: 22px;
+  }
+
+  .nm {
+    font: 12.5px var(--mono);
+  }
+
+  .orow.dimmed .nm {
+    color: var(--ink-3);
+  }
+
+  .sig {
+    overflow: hidden;
+    color: var(--ink-3);
+    font: 11.5px var(--mono);
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .cnt {
+    color: var(--ink-3);
+    font: 11px var(--mono);
+    font-variant-numeric: tabular-nums;
+    white-space: nowrap;
+  }
+
+  .note {
+    padding: 8px 0;
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+</style>

+ 271 - 0
ui/src/components/symbol/SourceBlock.svelte

@@ -0,0 +1,271 @@
+<!--
+  The verbatim body, with a gutter port on every line that has an outgoing
+  edge and an accent link on every call site (design spec §3.2).
+
+  Two things make this more than a <pre>:
+
+  * The lexer state is threaded across lines AND across the gaps between
+    windows, so the first line after a skipped block is not mis-read as the
+    inside of a comment that closed 200 lines ago.
+  * Each ref is matched to an actual token rather than to a column, because the
+    recorded column points at the start of the calling expression — see
+    `assignRefs`.
+-->
+<script lang="ts">
+  import { newLexState, tokenClass, tokenize, type Token } from '../../lib/highlight';
+  import { assignRefs, type CodeBlock, type LineRef } from '../../lib/symbol-model';
+  import { hot } from '../../lib/focus.svelte';
+
+  interface Props {
+    block: CodeBlock;
+    language: string;
+    refs: Map<number, LineRef[]>;
+    /** The line the definition's own name sits on — it is set in bold there. */
+    defLine: number;
+    defName: string;
+    /** Line from `?hl=` — tinted and scrolled to. */
+    highlight: number | null;
+    onfollow: (ref: LineRef) => void;
+  }
+
+  let { block, language, refs, defLine, defName, highlight, onfollow }: Props = $props();
+
+  interface Part {
+    text: string;
+    cls: string | null;
+    ref: LineRef | null;
+    def: boolean;
+  }
+
+  interface RenderedLine {
+    n: number;
+    parts: Part[];
+    /** 'sure' = at least one resolved edge here; 'unsure' = only guesses. */
+    port: 'sure' | 'unsure' | null;
+    /** Targets named on this line, so a hovered rail row can light it. */
+    targets: string[];
+  }
+
+  interface Chunk {
+    /** Lines skipped before this window; 0 for the first. */
+    gapBefore: number;
+    lines: RenderedLine[];
+  }
+
+  let chunks = $derived.by<Chunk[]>(() => {
+    const state = newLexState();
+    return block.windows.map((window, windowIndex) => ({
+      gapBefore: windowIndex === 0 ? 0 : (block.gapsAfter[windowIndex - 1] ?? 0),
+      lines: window.lines.map((text, offset) => {
+        const n = window.start + offset;
+        const tokens = tokenize(text, state, language);
+        const lineRefs = refs.get(n) ?? [];
+        const claimed = assignRefs(tokens, lineRefs);
+        return {
+          n,
+          parts: toParts(tokens, claimed, n === defLine ? defName : null),
+          port: portFor(lineRefs),
+          targets: [...new Set(lineRefs.map((r) => r.targetId).filter((id): id is string => !!id))],
+        };
+      }),
+    }));
+  });
+
+  function toParts(tokens: Token[], claimed: Map<number, LineRef>, definition: string | null): Part[] {
+    return tokens.map((token, index) => {
+      const ref = claimed.get(index) ?? null;
+      return {
+        text: token.text,
+        cls: ref ? null : tokenClass(token.cls),
+        ref,
+        def: !ref && definition !== null && token.cls === 'ident' && token.text === definition,
+      };
+    });
+  }
+
+  /**
+   * A filled port means the graph resolved something on this line; a hollow one
+   * means it only guessed. A line with no outgoing edge has no port at all —
+   * absence is the signal, so an empty gutter must stay empty.
+   */
+  function portFor(lineRefs: readonly LineRef[]): 'sure' | 'unsure' | null {
+    if (lineRefs.length === 0) return null;
+    return lineRefs.some((r) => !r.uncertain && !r.outside) ? 'sure' : 'unsure';
+  }
+
+  function isHot(line: RenderedLine): boolean {
+    return line.n === highlight || line.targets.some((id) => hot.is(id));
+  }
+</script>
+
+<div class="code">
+  {#each chunks as chunk (chunk.lines[0]?.n ?? -1)}
+    {#if chunk.gapBefore > 0}
+      <div class="gap">⋯ {chunk.gapBefore} lines without calls</div>
+    {/if}
+    {#each chunk.lines as line (line.n)}
+      <div class="ln" class:hot={isHot(line)} data-line={line.n}>
+        <span class="no">{line.n}</span>
+        <span class="tx"
+          >{#each line.parts as part, i (i)}{#if part.ref && !part.ref.outside}{@const ref = part.ref}<span
+                class="ref"
+                class:uncertain={ref.uncertain}
+                class:hot={hot.is(ref.targetId)}
+                role="link"
+                tabindex="0"
+                title={ref.title}
+                onclick={() => onfollow(ref)}
+                onkeydown={(e) => {
+                  if (e.key === 'Enter' || e.key === ' ') {
+                    e.preventDefault();
+                    onfollow(ref);
+                  }
+                }}
+                onmouseenter={() => hot.set(ref.targetId)}
+                onmouseleave={() => hot.clear(ref.targetId)}>{part.text}</span
+              >{:else if part.ref}<span class="ref stub" title={part.ref.title}>{part.text}</span
+              >{:else if part.def}<span class="t-def">{part.text}</span
+              >{:else if part.cls}<span class={part.cls}>{part.text}</span
+              >{:else}{part.text}{/if}{/each}</span
+        >
+        <span class="port">
+          {#if line.port}<i class:sure={line.port === 'sure'}></i>{/if}
+        </span>
+      </div>
+    {/each}
+  {/each}
+
+  {#if block.tailGap > 0}
+    <div class="gap">⋯ {block.tailGap} more lines</div>
+  {/if}
+</div>
+
+<style>
+  .code {
+    margin-top: 16px;
+    padding-top: 6px;
+    border-top: 1px solid var(--rule);
+    font: var(--code-size) / var(--code-lh) var(--mono);
+  }
+
+  /* 44px gutter | source | 18px port cell. The port lives in its own column
+     so a long line scrolling sideways never slides under it. */
+  .ln {
+    position: relative;
+    display: grid;
+    grid-template-columns: 44px 1fr 18px;
+    align-items: stretch;
+  }
+
+  .ln:hover {
+    background: var(--paper-2);
+  }
+
+  .ln.hot {
+    background: var(--accent-soft);
+  }
+
+  .no {
+    padding-right: 12px;
+    color: var(--ink-4);
+    font-size: 11px;
+    text-align: right;
+    user-select: none;
+  }
+
+  .tx {
+    white-space: pre;
+    overflow-x: auto;
+    scrollbar-width: none;
+  }
+
+  .tx::-webkit-scrollbar {
+    display: none;
+  }
+
+  .port {
+    position: relative;
+  }
+
+  .port i {
+    position: absolute;
+    top: 7px;
+    right: 4px;
+    width: 6px;
+    height: 6px;
+    border: 1px solid var(--ink-3);
+    background: var(--paper);
+  }
+
+  .port i.sure {
+    background: var(--ink-3);
+  }
+
+  .ln.hot .port i {
+    border-color: var(--accent);
+    background: var(--accent);
+  }
+
+  .gap {
+    margin: 2px 0;
+    padding: 2px 0 2px 44px;
+    border-top: 1px dashed var(--rule-soft);
+    border-bottom: 1px dashed var(--rule-soft);
+    color: var(--ink-4);
+    font-size: 11px;
+  }
+
+  /* ---- token classes (near-monochrome by design, spec §2.2) ---- */
+  .t-c {
+    color: var(--ink-3);
+  }
+
+  .t-s {
+    color: var(--ink-2);
+  }
+
+  .t-k {
+    font-weight: 500;
+  }
+
+  .t-n {
+    color: var(--ink-2);
+  }
+
+  .t-def {
+    font-weight: 600;
+  }
+
+  /* The only colour in the body: a call site the graph resolved. */
+  .ref {
+    color: var(--accent);
+    cursor: pointer;
+    text-decoration: underline;
+    text-decoration-color: var(--accent-line);
+    text-underline-offset: 3px;
+  }
+
+  .ref:hover,
+  .ref.hot {
+    background: var(--accent-soft);
+    text-decoration-color: var(--accent);
+  }
+
+  .ref.uncertain {
+    color: var(--ink-2);
+    text-decoration-style: dotted;
+    text-decoration-color: var(--ink-4);
+  }
+
+  /* Outside the index: there is nothing to open, so it does not offer to. */
+  .ref.stub {
+    color: var(--ink-2);
+    cursor: default;
+    text-decoration-color: var(--rule-soft);
+  }
+
+  .ref.stub:hover {
+    background: none;
+    text-decoration-color: var(--rule-soft);
+  }
+</style>

+ 286 - 0
ui/src/components/symbol/SymbolHeader.svelte

@@ -0,0 +1,286 @@
+<!--
+  The focus card: what this symbol is, where it lives, and the three claims
+  worth making before the body (design spec §3.2).
+
+  The badges are the honesty layer. "exported" and "hub · N callers" are facts
+  about reach; the test badge is the one that changes behaviour — an amber
+  "No test reaches this within 3 caller hops" is the difference between editing
+  freely and editing carefully, so it is stated in the header rather than left
+  to be inferred from an empty rail.
+-->
+<script lang="ts">
+  import KindGlyph from '../KindGlyph.svelte';
+  import { fileHref } from '../../lib/router.svelte';
+  import { kindPhrase, plural } from '../../lib/symbol-model';
+  import type {
+    WireNodeDetail,
+    WireNodeRef,
+    WireRelation,
+    WireSymbolPayload,
+  } from '../../lib/api';
+
+  interface Props {
+    payload: WireSymbolPayload;
+    onopen: (node: WireNodeRef) => void;
+  }
+
+  let { payload, onopen }: Props = $props();
+
+  let node = $derived<WireNodeDetail>(payload.node);
+  let tests = $derived(payload.tests);
+
+  /** `extends`/`implements` this symbol declares, and the ones declared on it. */
+  let supertypes = $derived(
+    payload.outgoing.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
+  );
+  let subtypes = $derived(
+    payload.incoming.items.filter((r) => r.edgeKinds.some((k) => k === 'extends' || k === 'implements'))
+  );
+
+  const TYPE_CHIP_LIMIT = 12;
+  let typeChips = $derived(payload.typesUsed.slice(0, TYPE_CHIP_LIMIT));
+
+  function relationWord(relation: WireRelation): string {
+    return relation.edgeKinds.includes('implements') ? 'implements' : 'extends';
+  }
+
+  /**
+   * The test claim, worded to exactly what was checked. An interrupted search
+   * (`exhaustive: false`) only ever established that no test calls the symbol
+   * directly, so the badge must not widen that to three hops.
+   */
+  let testBadge = $derived.by(() => {
+    if (tests.reached) {
+      return {
+        warn: false,
+        text: `Reached by tests · ${plural(tests.fileCount, 'file')} within ${tests.hopsSearched} hop${tests.hopsSearched === 1 ? '' : 's'}`,
+        title: tests.files.join(', '),
+      };
+    }
+    return {
+      warn: true,
+      text: tests.exhaustive
+        ? `No test reaches this within ${tests.hopsSearched} caller hops`
+        : 'No test calls this directly',
+      title: tests.exhaustive
+        ? 'No test file reaches this symbol within the caller hops searched.'
+        : 'The caller search ran out of budget — only direct callers were checked.',
+    };
+  });
+</script>
+
+<div class="card-h">
+  <KindGlyph kind={node.kind} titled />
+  <h1>{node.name}</h1>
+  <span class="kindword">{kindPhrase(node)}</span>
+  <span class="loc mono">
+    <a href={fileHref(node.file, { line: node.line })}>{node.file}</a>:{node.line}–{node.endLine}
+    · {plural(node.lines, 'line')}
+  </span>
+</div>
+
+{#if payload.ancestors.length > 0}
+  <div class="parents mono">
+    in {#each payload.ancestors as ancestor, i (ancestor.id)}{#if i > 0}<span class="sep"> › </span
+        >{/if}<button type="button" onclick={() => onopen(ancestor)}>{ancestor.name}</button
+      >{/each}
+  </div>
+{/if}
+
+<div class="badges">
+  {#if node.exported}<span class="badge">exported</span>{/if}
+  {#if payload.counts.hub}
+    <span class="badge hub" title="Changing this reaches a lot of the repo">
+      hub · {plural(payload.counts.callers, 'caller')}
+    </span>
+  {/if}
+  {#if payload.drift}
+    <span class="badge warn" title="The line ranges below come from the last index sync">
+      <span class="sw"></span>changed on disk after the last index sync
+    </span>
+  {/if}
+  <span class="badge" class:warn={testBadge.warn} title={testBadge.title}>
+    <span class="sw"></span>{testBadge.text}
+  </span>
+</div>
+
+{#if node.signature}
+  <div class="sig">{node.name}{node.signature}</div>
+{/if}
+
+{#if node.docstring}
+  <div class="doc">{node.docstring}</div>
+{/if}
+
+{#if supertypes.length > 0 || subtypes.length > 0 || typeChips.length > 0}
+  <div class="rel">
+    {#if supertypes.length > 0}
+      <span>
+        {#each supertypes as relation (relation.node.id)}
+          {relationWord(relation)}
+          <button type="button" class="chip" onclick={() => onopen(relation.node)}>
+            {relation.node.name}
+          </button>
+        {/each}
+      </span>
+    {/if}
+    {#if subtypes.length > 0}
+      <span>
+        {subtypes[0]?.edgeKinds.includes('implements') ? 'implemented by' : 'extended by'}
+        {#each subtypes as relation (relation.node.id)}
+          <button type="button" class="chip" onclick={() => onopen(relation.node)}>
+            {relation.node.name}
+          </button>
+        {/each}
+      </span>
+    {/if}
+    {#if typeChips.length > 0}
+      <span>
+        uses types
+        {#each typeChips as relation (relation.node.id)}
+          <button type="button" class="chip" onclick={() => onopen(relation.node)}>
+            {relation.node.name}
+          </button>
+        {/each}
+        {#if payload.typesUsed.length > TYPE_CHIP_LIMIT}
+          <span class="dim">+{payload.typesUsed.length - TYPE_CHIP_LIMIT}</span>
+        {/if}
+      </span>
+    {/if}
+  </div>
+{/if}
+
+<style>
+  .card-h {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 6px 12px;
+  }
+
+  .card-h h1 {
+    margin: 0;
+    font: 600 20px/1.2 var(--mono);
+    letter-spacing: -0.01em;
+  }
+
+  .kindword {
+    color: var(--ink-3);
+    font-size: 12.5px;
+  }
+
+  .loc {
+    color: var(--ink-2);
+    font-size: 11.5px;
+  }
+
+  .loc a:hover {
+    text-decoration: underline;
+  }
+
+  .parents {
+    margin-top: 6px;
+    color: var(--ink-3);
+    font-size: 11.5px;
+  }
+
+  .parents button {
+    color: inherit;
+    font: inherit;
+  }
+
+  .parents button:hover {
+    color: var(--ink);
+    text-decoration: underline;
+  }
+
+  .parents .sep {
+    color: var(--ink-4);
+  }
+
+  .badges {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-top: 10px;
+  }
+
+  .badge {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    padding: 2px 7px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink-2);
+    font-size: 11.5px;
+  }
+
+  /* Amber is used here and nowhere else in the app. */
+  .badge.warn {
+    border-color: var(--amber);
+    background: var(--amber-soft);
+    color: var(--amber);
+  }
+
+  .badge.hub {
+    border-color: var(--ink);
+  }
+
+  .sw {
+    display: inline-block;
+    width: 8px;
+    height: 8px;
+    border: 1px solid currentColor;
+  }
+
+  .badge.warn .sw {
+    background: currentColor;
+  }
+
+  .sig {
+    margin-top: 10px;
+    color: var(--ink-2);
+    font: 12px var(--mono);
+    white-space: pre-wrap;
+    word-break: break-word;
+  }
+
+  .doc {
+    margin-top: 8px;
+    max-width: 70ch;
+    color: var(--ink-2);
+    font-size: 12.5px;
+    white-space: pre-wrap;
+  }
+
+  .rel {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 6px;
+    margin-top: 10px;
+    color: var(--ink-3);
+    font-size: 12px;
+  }
+
+  .rel > span {
+    display: inline-flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 6px;
+  }
+
+  .chip {
+    padding: 1px 6px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper);
+    color: var(--ink-2);
+    font: 11.5px var(--mono);
+  }
+
+  .chip:hover {
+    border-color: var(--ink);
+    color: var(--ink);
+  }
+</style>

+ 268 - 0
ui/src/lib/api.ts

@@ -0,0 +1,268 @@
+/**
+ * The viewer's side of the read-only JSON API (`src/ui-server/api/`, CG-42).
+ *
+ * The types below mirror the server's wire shapes rather than re-deriving
+ * them: the API is versioned with the binary that serves it, so a field the
+ * server stopped sending should break the type-check here, not surface as
+ * `undefined` in a rail three screens later.
+ *
+ * One rule for every call: the API answers JSON for *every* outcome, including
+ * refusals. So a non-2xx still has a body worth reading, and `ApiFailure`
+ * carries the server's own sentence instead of "Failed to fetch".
+ */
+
+/* ---------------------------------------------------------------- shapes -- */
+
+export type NodeKind = string;
+export type EdgeKind = string;
+
+export interface WireNodeRef {
+  id: string;
+  kind: NodeKind;
+  name: string;
+  qualifiedName: string;
+  /** Project-relative, forward slashes on every platform. */
+  file: string;
+  line: number;
+  endLine: number;
+  language: string;
+  signature?: string;
+  exported?: boolean;
+  /** Lives in a file that looks like test or fixture code. */
+  test: boolean;
+}
+
+export interface WireNodeDetail extends WireNodeRef {
+  startColumn: number;
+  endColumn: number;
+  docstring?: string;
+  visibility?: string;
+  async?: boolean;
+  static?: boolean;
+  abstract?: boolean;
+  decorators?: string[];
+  typeParameters?: string[];
+  returnType?: string;
+  lines: number;
+}
+
+export interface WireMember extends WireNodeRef {
+  parentId: string;
+  /** 1 = a direct member; 2 = a member of a member (a method inside a file's class). */
+  depth: number;
+  fanIn: number;
+  fanOut: number;
+}
+
+export interface WireEdge {
+  kind: EdgeKind;
+  line?: number;
+  col?: number;
+  confidence?: number;
+  resolvedBy?: string;
+  provenance?: string;
+  synthesizedBy?: string;
+  via?: string;
+  registeredAt?: string;
+  valueRef?: boolean;
+}
+
+/** Every edge between the focal symbol and ONE other symbol, as a single row. */
+export interface WireRelation {
+  node: WireNodeRef;
+  edgeKinds: EdgeKind[];
+  edges: WireEdge[];
+  edgeCount: number;
+  /** Distinct call-site lines, ascending — what the gutter ports anchor to. */
+  lines: number[];
+  confidence: number | null;
+  uncertain: boolean;
+  synthesized: boolean;
+  fanIn?: number;
+  hub?: boolean;
+}
+
+export interface WireList<T> {
+  total: number;
+  shown: number;
+  truncated: boolean;
+  items: T[];
+}
+
+export interface WireTestSummary {
+  reached: boolean;
+  hops: number | null;
+  fileCount: number;
+  files: string[];
+  /** False weakens the claim to "no test calls this directly" — see the server. */
+  exhaustive: boolean;
+  hopsSearched: number;
+}
+
+export interface WireOutsideIndex {
+  total: number;
+  byKind: Record<string, number>;
+  samples: Array<{ name: string; kind: string; line?: number; col?: number }>;
+}
+
+export interface WireBlastSummary {
+  direct: number;
+  withinHops: number;
+  hops: number;
+  files: number;
+  testFiles: number;
+  routes: number;
+  topFiles: Array<{ file: string; symbols: number; test: boolean }>;
+}
+
+export interface WireSymbolPayload {
+  node: WireNodeDetail;
+  /** Outermost first: file, then module/class, then the symbol's own parent. */
+  ancestors: WireNodeRef[];
+  members: WireList<WireMember>;
+  incoming: WireList<WireRelation>;
+  outgoing: WireList<WireRelation>;
+  typesUsed: WireRelation[];
+  counts: {
+    callers: number;
+    callees: number;
+    typesUsed: number;
+    fanIn: number;
+    fanOut: number;
+    members: number;
+    hub: boolean;
+  };
+  tests: WireTestSummary;
+  outsideIndex: WireOutsideIndex;
+  blast: WireBlastSummary | null;
+  /** The file changed on disk since the index — line ranges may be shifted. */
+  drift: boolean;
+}
+
+export interface WireSource {
+  file: string;
+  language: string;
+  drift: boolean;
+  contentHash: string;
+  indexedAt: number;
+  generated: boolean;
+  totalLines: number | null;
+  from?: number;
+  to?: number;
+  /** Absent when `drift` — a mis-sliced body is worse than no body. */
+  lines?: string[];
+  truncated?: boolean;
+  reason?: string;
+}
+
+export interface WireBlastScale {
+  maxDirect: number;
+  maxWithinHops: number;
+  hops: number;
+  sampled: number;
+  estimated: boolean;
+}
+
+export interface WireStats {
+  project: { root: string; name: string };
+  index: {
+    state: string | null;
+    lastIndexedAt: number | null;
+    stale: boolean;
+    version: string | null;
+    extractionVersion: number | null;
+    backend: string;
+    journalMode: string;
+    pendingReferences: number;
+    generatedFiles: number;
+    watching: boolean;
+    watcherDegraded: boolean;
+  };
+  graph: {
+    nodes: number;
+    edges: number;
+    files: number;
+    nodesByKind: Record<string, number>;
+    edgesByKind: Record<string, number>;
+    filesByLanguage: Record<string, number>;
+    dbSizeBytes: number;
+    walSizeBytes: number;
+  };
+  frameworks: string[];
+  thresholds: { hub: number; uncertainBelow: number };
+  blastScale: WireBlastScale;
+}
+
+/* ----------------------------------------------------------------- fetch -- */
+
+/** An error the server described. `guidance` is its "what to do instead" line. */
+export class ApiFailure extends Error {
+  readonly status: number;
+  readonly code: string;
+  readonly guidance: string | null;
+
+  constructor(status: number, code: string, message: string, guidance: string | null) {
+    super(message);
+    this.name = 'ApiFailure';
+    this.status = status;
+    this.code = code;
+    this.guidance = guidance;
+  }
+}
+
+/** What `fail()` in `src/ui-server/api/respond.ts` sends. */
+interface ApiErrorBody {
+  error?: string;
+  code?: string;
+  hint?: string;
+}
+
+async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
+  let response: Response;
+  try {
+    response = await fetch(path, { signal, headers: { accept: 'application/json' } });
+  } catch (cause) {
+    if (signal?.aborted) throw cause;
+    // The one failure the server cannot describe, because it never heard the
+    // request: `codegraph ui` was stopped while the tab stayed open.
+    throw new ApiFailure(
+      0,
+      'unreachable',
+      'The codegraph ui server is not answering.',
+      'It may have been stopped — restart it with `codegraph ui` and reload this page.'
+    );
+  }
+
+  const body = (await response.json().catch(() => null)) as unknown;
+  if (!response.ok) {
+    const failure = (body as ApiErrorBody | null) ?? {};
+    throw new ApiFailure(
+      response.status,
+      failure.code ?? 'error',
+      failure.error ?? `The server answered ${response.status}.`,
+      failure.hint ?? null
+    );
+  }
+  return body as T;
+}
+
+export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
+  return getJson<WireStats>('api/stats', signal);
+}
+
+export function fetchSymbol(id: string, signal?: AbortSignal): Promise<WireSymbolPayload> {
+  // Ids carry ':' and '/' (`method:<hash>`, `file:src/mcp/tools.ts`); encode
+  // per segment so the path stays readable and still round-trips.
+  const encoded = id.split('/').map(encodeURIComponent).join('/');
+  return getJson<WireSymbolPayload>(`api/node/${encoded}`, signal);
+}
+
+export function fetchSource(
+  file: string,
+  from: number,
+  to: number,
+  signal?: AbortSignal
+): Promise<WireSource> {
+  const params = new URLSearchParams({ file, from: String(from), to: String(to) });
+  return getJson<WireSource>(`api/source?${params}`, signal);
+}

+ 67 - 0
ui/src/lib/focus.svelte.ts

@@ -0,0 +1,67 @@
+/**
+ * The two pieces of Symbol-view state that more than one pane has to agree on.
+ *
+ * `hot` is the hover link: the callee rail, the gutter port, the call site in
+ * the body and the connector between them are four renderings of ONE edge, and
+ * lighting all four from whichever the pointer happens to be over is what makes
+ * the screen read as a single object rather than three lists side by side.
+ *
+ * `railFocus` is the keyboard's place in the rails (↑/↓ move, ←/→ switch,
+ * Enter follows). It is separate from `hot` on purpose: the keyboard's position
+ * must survive the mouse moving across the screen, and a hover must not steal
+ * the place the reader is arrowing through.
+ */
+
+export type RailSide = 'left' | 'right';
+
+let hotTarget = $state<string | null>(null);
+let focusedRail = $state<RailSide>('right');
+let focusedIndex = $state(-1);
+
+export const hot = {
+  get target(): string | null {
+    return hotTarget;
+  },
+  /** True when `id` is the edge currently lit — the test every pane runs. */
+  is(id: string | null | undefined): boolean {
+    return id != null && hotTarget === id;
+  },
+  set(id: string | null): void {
+    hotTarget = id;
+  },
+  /** Clear only if `id` is still the lit one — a stale mouseout must not win. */
+  clear(id: string | null): void {
+    if (id == null || hotTarget === id) hotTarget = null;
+  },
+};
+
+export const railFocus = {
+  get rail(): RailSide {
+    return focusedRail;
+  },
+  get index(): number {
+    return focusedIndex;
+  },
+  /** True when this row is the keyboard's current position. */
+  at(rail: RailSide, index: number): boolean {
+    return focusedRail === rail && focusedIndex === index;
+  },
+  move(rail: RailSide, index: number): void {
+    focusedRail = rail;
+    focusedIndex = index;
+  },
+  /** Step within the active rail, clamped to its length. */
+  step(delta: number, length: number): void {
+    if (length === 0) return;
+    focusedIndex = Math.max(0, Math.min(length - 1, focusedIndex + delta));
+  },
+  /** Switch rails, landing on the first row rather than an unrelated index. */
+  switchTo(rail: RailSide): void {
+    focusedRail = rail;
+    if (focusedIndex < 0) focusedIndex = 0;
+  },
+  reset(): void {
+    focusedRail = 'right';
+    focusedIndex = -1;
+  },
+};

+ 340 - 0
ui/src/lib/highlight.ts

@@ -0,0 +1,340 @@
+/**
+ * Near-monochrome tokenising for the code block (design spec §2.2).
+ *
+ * The colouring is deliberately almost absent: comments and strings recede,
+ * keywords carry weight rather than hue, and the ONLY colour in the body is a
+ * resolved call site. That is the point of the screen — the graph's edges are
+ * what the eye should find, and a six-colour syntax theme buries them.
+ *
+ * A hand-rolled lexer, not a highlighter library. It has one job — separate
+ * comments, strings, numbers and keywords from everything else, well enough to
+ * be honest across the 30-odd languages the engine indexes — and doing it here
+ * keeps the viewer free of a runtime dependency and of a per-grammar download
+ * on a machine that is reading its own source offline. CG-43 replaces this
+ * with Shiki tokens produced server-side; `tokenize` is the seam.
+ */
+
+export type TokenClass =
+  | 'comment'
+  | 'string'
+  | 'keyword'
+  | 'number'
+  | 'ident'
+  | 'space'
+  | 'punct';
+
+export interface Token {
+  cls: TokenClass;
+  text: string;
+  /** Column of the token's first character, 0-based — how a ref finds its identifier. */
+  col: number;
+}
+
+/**
+ * Lexer state that survives from one line to the next: a block comment or a
+ * multi-line string opened on an earlier line. Rendering a window of a file
+ * without this makes the first line after a `/*` look like code.
+ */
+export interface LexState {
+  block: boolean;
+  /** The delimiter that will close the open multi-line string (a backtick, `"""`, …). */
+  stringEnd: string | null;
+}
+
+export function newLexState(): LexState {
+  return { block: false, stringEnd: null };
+}
+
+/* --------------------------------------------------------------- dialects -- */
+
+interface Dialect {
+  lineComment: string[];
+  blockComment: [string, string] | null;
+  /** Quote characters that never span lines. */
+  quotes: string[];
+  /** Delimiters that MAY span lines (template literals, triple quotes, heredoc-ish). */
+  multiline: string[];
+  keywords: ReadonlySet<string>;
+}
+
+const kw = (words: string): ReadonlySet<string> => new Set(words.split(/\s+/).filter(Boolean));
+
+/**
+ * Keywords shared widely enough across the C-family that listing them once is
+ * both shorter and more accurate than a per-language table nobody maintains.
+ */
+const C_FAMILY = `
+  abstract as async await break case catch class const constexpr continue default defer delete do
+  else enum export extends extern false final finally for from func function go goto if impl implements
+  import in instanceof interface internal is let match mod module mut namespace new nil null object
+  operator out override package private protected public readonly record ref return sealed select self
+  static struct super switch this throw throws trait true try type typedef typeof union unsafe use using
+  var virtual void when where while with yield
+`;
+
+const DIALECTS: Record<string, Dialect> = {
+  c: {
+    lineComment: ['//'],
+    blockComment: ['/*', '*/'],
+    quotes: ['"', "'"],
+    multiline: [],
+    keywords: kw(C_FAMILY),
+  },
+  ts: {
+    lineComment: ['//'],
+    blockComment: ['/*', '*/'],
+    quotes: ['"', "'"],
+    multiline: ['`'],
+    keywords: kw(
+      `${C_FAMILY} any asserts bigint boolean declare infer keyof never number readonly satisfies
+       string symbol undefined unknown`
+    ),
+  },
+  hash: {
+    // Python, Ruby, shell, YAML, Nix, Terraform, Perl, R, Elixir…
+    lineComment: ['#'],
+    blockComment: null,
+    quotes: ['"', "'"],
+    multiline: ['"""', "'''"],
+    keywords: kw(
+      `and as assert async await begin break case class def defp defmodule del do elif else elsif end
+       ensure except exec finally for from global if import in is lambda let module next nil none not
+       or pass raise require rescue return self struct then trait true false try unless until use when
+       while with yield`
+    ),
+  },
+  sql: {
+    lineComment: ['--'],
+    blockComment: ['/*', '*/'],
+    quotes: ["'", '"'],
+    multiline: [],
+    keywords: kw(
+      `select insert update delete from where group by order having join left right inner outer on as
+       and or not null create table index view primary key foreign references into values set limit`
+    ),
+  },
+  lisp: {
+    lineComment: [';'],
+    blockComment: null,
+    quotes: ['"'],
+    multiline: [],
+    keywords: kw('def defn defmacro let fn if cond do loop recur ns require import when case'),
+  },
+};
+
+/** Engine `Language` values → the lexer that reads them closely enough. */
+const LANGUAGE_DIALECT: Record<string, keyof typeof DIALECTS> = {
+  typescript: 'ts',
+  tsx: 'ts',
+  javascript: 'ts',
+  jsx: 'ts',
+  svelte: 'ts',
+  vue: 'ts',
+  astro: 'ts',
+  dart: 'c',
+  java: 'c',
+  kotlin: 'c',
+  scala: 'c',
+  csharp: 'c',
+  vbnet: 'hash',
+  go: 'c',
+  rust: 'c',
+  swift: 'c',
+  objc: 'c',
+  c: 'c',
+  cpp: 'c',
+  cuda: 'c',
+  metal: 'c',
+  php: 'c',
+  zig: 'c',
+  solidity: 'c',
+  glsl: 'c',
+  python: 'hash',
+  ruby: 'hash',
+  crystal: 'hash',
+  elixir: 'hash',
+  perl: 'hash',
+  r: 'hash',
+  shell: 'hash',
+  bash: 'hash',
+  powershell: 'hash',
+  yaml: 'hash',
+  toml: 'hash',
+  nix: 'hash',
+  terraform: 'hash',
+  hcl: 'hash',
+  dockerfile: 'hash',
+  makefile: 'hash',
+  sql: 'sql',
+  clojure: 'lisp',
+  lisp: 'lisp',
+  scheme: 'lisp',
+  elm: 'ts',
+  haskell: 'ts',
+  lua: 'hash',
+  erlang: 'hash',
+  cobol: 'hash',
+};
+
+function dialectFor(language: string | undefined): Dialect {
+  const key = LANGUAGE_DIALECT[(language ?? '').toLowerCase()] ?? 'ts';
+  return DIALECTS[key] as Dialect;
+}
+
+/* ----------------------------------------------------------------- lexer -- */
+
+const IDENT_START = /[A-Za-z_$@]/;
+const IDENT_BODY = /[\w$]/;
+
+/**
+ * Split one line into tokens, carrying `state` across lines.
+ *
+ * Mutates `state` — a window of source is tokenised line by line in order, and
+ * threading the block-comment flag through a return value would make every
+ * caller responsible for a detail only this function understands.
+ */
+export function tokenize(line: string, state: LexState, language?: string): Token[] {
+  const d = dialectFor(language);
+  const out: Token[] = [];
+  const len = line.length;
+  let i = 0;
+
+  const push = (cls: TokenClass, from: number, to: number): void => {
+    if (to > from) out.push({ cls, text: line.slice(from, to), col: from });
+  };
+
+  while (i < len) {
+    // --- continuations of something opened on an earlier line ---------------
+    if (state.block && d.blockComment) {
+      const close = line.indexOf(d.blockComment[1], i);
+      if (close < 0) {
+        push('comment', i, len);
+        i = len;
+      } else {
+        push('comment', i, close + d.blockComment[1].length);
+        i = close + d.blockComment[1].length;
+        state.block = false;
+      }
+      continue;
+    }
+    if (state.stringEnd) {
+      const end = findUnescaped(line, state.stringEnd, i);
+      if (end < 0) {
+        push('string', i, len);
+        i = len;
+      } else {
+        push('string', i, end + state.stringEnd.length);
+        i = end + state.stringEnd.length;
+        state.stringEnd = null;
+      }
+      continue;
+    }
+
+    const rest = line.slice(i);
+
+    // --- comments -----------------------------------------------------------
+    const lineMarker = d.lineComment.find((m) => rest.startsWith(m));
+    if (lineMarker) {
+      push('comment', i, len);
+      i = len;
+      continue;
+    }
+    if (d.blockComment && rest.startsWith(d.blockComment[0])) {
+      const close = line.indexOf(d.blockComment[1], i + d.blockComment[0].length);
+      if (close < 0) {
+        push('comment', i, len);
+        i = len;
+        state.block = true;
+      } else {
+        push('comment', i, close + d.blockComment[1].length);
+        i = close + d.blockComment[1].length;
+      }
+      continue;
+    }
+
+    // --- strings ------------------------------------------------------------
+    // Longest delimiter first, so `"""` never matches as `"`.
+    const multi = [...d.multiline].sort((a, b) => b.length - a.length).find((m) => rest.startsWith(m));
+    if (multi) {
+      const end = findUnescaped(line, multi, i + multi.length);
+      if (end < 0) {
+        push('string', i, len);
+        i = len;
+        state.stringEnd = multi;
+      } else {
+        push('string', i, end + multi.length);
+        i = end + multi.length;
+      }
+      continue;
+    }
+    const quote = d.quotes.find((q) => rest.startsWith(q));
+    if (quote) {
+      const end = findUnescaped(line, quote, i + quote.length);
+      // An unterminated single-line quote is an apostrophe in prose far more
+      // often than a real string, so it stops at the line rather than eating
+      // the rest of the window.
+      push('string', i, end < 0 ? len : end + quote.length);
+      i = end < 0 ? len : end + quote.length;
+      continue;
+    }
+
+    // --- words, numbers, space, everything else -----------------------------
+    const ch = line[i] as string;
+    if (IDENT_START.test(ch)) {
+      let j = i + 1;
+      while (j < len && IDENT_BODY.test(line[j] as string)) j++;
+      const word = line.slice(i, j);
+      push(d.keywords.has(word) ? 'keyword' : 'ident', i, j);
+      i = j;
+      continue;
+    }
+    if (ch >= '0' && ch <= '9') {
+      let j = i + 1;
+      while (j < len && /[\w.]/.test(line[j] as string)) j++;
+      push('number', i, j);
+      i = j;
+      continue;
+    }
+    if (/\s/.test(ch)) {
+      let j = i + 1;
+      while (j < len && /\s/.test(line[j] as string)) j++;
+      push('space', i, j);
+      i = j;
+      continue;
+    }
+    push('punct', i, i + 1);
+    i++;
+  }
+
+  return out;
+}
+
+/** Index of `needle` at or after `from`, skipping backslash-escaped ones. */
+function findUnescaped(line: string, needle: string, from: number): number {
+  let i = from;
+  while (i < line.length) {
+    if (line[i] === '\\') {
+      i += 2;
+      continue;
+    }
+    if (line.startsWith(needle, i)) return i;
+    i++;
+  }
+  return -1;
+}
+
+/** The CSS class for a token, or null where the default ink is right. */
+export function tokenClass(cls: TokenClass): string | null {
+  switch (cls) {
+    case 'comment':
+      return 't-c';
+    case 'string':
+      return 't-s';
+    case 'keyword':
+      return 't-k';
+    case 'number':
+      return 't-n';
+    default:
+      return null;
+  }
+}

+ 49 - 0
ui/src/lib/project.svelte.ts

@@ -0,0 +1,49 @@
+/**
+ * The project's own facts — loaded once, read everywhere.
+ *
+ * `/api/stats` describes the index rather than any one symbol, so every screen
+ * that needs a piece of it (the top bar's counts, the Symbol view's blast
+ * scale) would otherwise re-fetch the same payload. The promise is memoised,
+ * not the value, so callers made before it lands still get the same request.
+ */
+
+import { fetchStats, type WireStats } from './api';
+
+let stats = $state<WireStats | null>(null);
+let error = $state<string | null>(null);
+let inflight: Promise<void> | null = null;
+
+function load(): Promise<void> {
+  if (inflight) return inflight;
+  inflight = fetchStats()
+    .then((value) => {
+      stats = value;
+      error = null;
+    })
+    .catch((cause: unknown) => {
+      // A failure here costs a couple of numbers in the top bar and the blast
+      // bar's denominator — never the screen. It is recorded, not thrown.
+      error = cause instanceof Error ? cause.message : String(cause);
+    });
+  return inflight;
+}
+
+export const project = {
+  get stats(): WireStats | null {
+    return stats;
+  },
+  get error(): string | null {
+    return error;
+  },
+  /** "codegraph" — the indexed project's directory name. */
+  get name(): string | null {
+    return stats?.project.name ?? null;
+  },
+  /** "13,495 symbols · 47,433 edges · 632 files indexed". */
+  get summary(): string | null {
+    if (!stats) return null;
+    const n = (value: number): string => value.toLocaleString();
+    return `${n(stats.graph.nodes)} symbols · ${n(stats.graph.edges)} edges · ${n(stats.graph.files)} files indexed`;
+  },
+  ensure: load,
+};

+ 511 - 0
ui/src/lib/symbol-model.ts

@@ -0,0 +1,511 @@
+/**
+ * Everything the Symbol view derives from one `/api/node` payload, as plain
+ * functions over plain data.
+ *
+ * None of this touches the DOM or Svelte's reactivity. The screen's hard parts
+ * — which lines get a port, which callee row sits at which height, which call
+ * site is a link — are all decisions about the payload, and keeping them here
+ * means they can be reasoned about (and tested) without a browser.
+ *
+ * Design spec §3.2.
+ */
+
+import type {
+  WireEdge,
+  WireMember,
+  WireOutsideIndex,
+  WireRelation,
+  WireSymbolPayload,
+} from './api';
+
+/* ------------------------------------------------------------- constants -- */
+
+/** Bodies at or under this are shown whole (design spec §3.2). */
+export const FULL_BODY_LINES = 260;
+/** Above that, the head is shown in full before the windows begin. */
+export const HEAD_LINES = 80;
+/** Lines of context kept either side of a call site in a windowed body. */
+export const WINDOW_CONTEXT = 4;
+/** Two windows closer than this merge — a 1-line gap row costs more than it saves. */
+const WINDOW_MERGE_GAP = 2;
+/** Windows in one body. Past this the body is a listing, not a reading. */
+const MAX_WINDOWS = 30;
+/** A container bigger than this shows its outline instead of its body. */
+export const CONTAINER_BODY_LINES = 80;
+
+/** Kinds that hold other symbols — they get an outline, not a 700-line body. */
+export const CONTAINER_KINDS = new Set([
+  'file',
+  'module',
+  'namespace',
+  'class',
+  'struct',
+  'interface',
+  'trait',
+  'protocol',
+  'enum',
+  'union',
+]);
+
+/** Kinds whose outline rows are dimmed: data, not behaviour. */
+const QUIET_MEMBER_KINDS = new Set(['property', 'field', 'enum_member', 'constant', 'variable']);
+
+/* ----------------------------------------------------------------- words -- */
+
+/**
+ * What an edge is called in a rail's meta line.
+ *
+ * `calls` returns '' deliberately: it is the default reading of the whole
+ * screen, and labelling every row "calls" is noise that hides the rows where
+ * the relationship is something else.
+ */
+export function edgeWord(edge: WireEdge): string {
+  switch (edge.kind) {
+    case 'calls':
+      return '';
+    case 'instantiates':
+      return 'creates';
+    case 'references':
+      return edge.valueRef ? 'passes as value' : 'uses type';
+    default:
+      return edge.kind;
+  }
+}
+
+/** The distinct edge words for a relation, in first-seen order, blanks dropped. */
+export function relationWords(relation: WireRelation): string[] {
+  const words: string[] = [];
+  for (const edge of relation.edges) {
+    const word = edgeWord(edge);
+    if (word && !words.includes(word)) words.push(word);
+  }
+  return words;
+}
+
+/** The synthesizer that produced this relation's edge, when one did. */
+export function synthesizedBy(relation: WireRelation): string | null {
+  if (!relation.synthesized) return null;
+  const edge = relation.edges.find((e) => e.provenance === 'heuristic');
+  return edge?.synthesizedBy ?? edge?.via ?? 'synthesized';
+}
+
+export function basename(path: string): string {
+  return path.slice(path.lastIndexOf('/') + 1);
+}
+
+/** The trailing segment of a dotted/qualified name — what appears in the source. */
+export function lastSegment(name: string): string {
+  const dot = name.lastIndexOf('.');
+  return dot < 0 ? name : name.slice(dot + 1);
+}
+
+/* --------------------------------------------------------------- windows -- */
+
+export interface SourceWindow {
+  /** 1-based file line of `lines[0]`. */
+  start: number;
+  lines: string[];
+}
+
+export interface CodeBlock {
+  windows: SourceWindow[];
+  /** Lines skipped between window i and i+1 — the "⋯ N lines without calls" rows. */
+  gapsAfter: number[];
+  /** Lines dropped after the last window, if the body did not run to its end. */
+  tailGap: number;
+  /** The body was shown whole. */
+  whole: boolean;
+}
+
+/**
+ * Cut a long body down to its head plus the neighbourhood of every call site.
+ *
+ * The rule is the one the prototype established and the screenshots pin: a
+ * body of {@link FULL_BODY_LINES} or fewer is shown whole, and a longer one
+ * keeps its first {@link HEAD_LINES} lines — where the signature, the guards
+ * and the shape of the function live — plus ±{@link WINDOW_CONTEXT} lines
+ * around each call, because a call site with no context is a name, not code.
+ *
+ * @param startLine 1-based first line of the symbol
+ * @param lines     the body's source, `lines[0]` being `startLine`
+ * @param callLines every line in the body that makes an outgoing edge
+ */
+export function buildCodeBlock(
+  startLine: number,
+  lines: readonly string[],
+  callLines: readonly number[]
+): CodeBlock {
+  const endLine = startLine + lines.length - 1;
+  const slice = (from: number, to: number): SourceWindow => ({
+    start: from,
+    lines: lines.slice(from - startLine, to - startLine + 1),
+  });
+
+  if (lines.length <= FULL_BODY_LINES) {
+    return {
+      windows: lines.length > 0 ? [slice(startLine, endLine)] : [],
+      gapsAfter: [],
+      tailGap: 0,
+      whole: true,
+    };
+  }
+
+  const headEnd = Math.min(endLine, startLine + HEAD_LINES - 1);
+  const ranges: Array<[number, number]> = [[startLine, headEnd]];
+  const sites = [...new Set(callLines)]
+    .filter((line) => line > headEnd && line <= endLine)
+    .sort((a, b) => a - b);
+  for (const line of sites) {
+    ranges.push([
+      Math.max(startLine, line - WINDOW_CONTEXT),
+      Math.min(endLine, line + WINDOW_CONTEXT),
+    ]);
+  }
+
+  const merged: Array<[number, number]> = [];
+  for (const range of ranges) {
+    const last = merged[merged.length - 1];
+    if (last && range[0] <= last[1] + WINDOW_MERGE_GAP) last[1] = Math.max(last[1], range[1]);
+    else merged.push([...range] as [number, number]);
+  }
+
+  const kept = merged.slice(0, MAX_WINDOWS);
+  const windows = kept.map(([from, to]) => slice(from, to));
+  const gapsAfter = kept.slice(0, -1).map((range, i) => (kept[i + 1] as [number, number])[0] - range[1] - 1);
+  const lastEnd = kept[kept.length - 1]?.[1] ?? endLine;
+
+  return { windows, gapsAfter, tailGap: Math.max(0, endLine - lastEnd), whole: false };
+}
+
+/* ------------------------------------------------------------------ refs -- */
+
+/** One identifier in the body that the graph has something to say about. */
+export interface LineRef {
+  /** The identifier as it appears in the source — what the token must match. */
+  ident: string;
+  /** 0-based column the edge was recorded at, or null when it carries none. */
+  col: number | null;
+  /** Target node id, or null for a reference that leaves the index. */
+  targetId: string | null;
+  uncertain: boolean;
+  /** No node behind it — rendered as text with a soft underline, not a link. */
+  outside: boolean;
+  title: string;
+}
+
+/**
+ * Which identifiers on which lines are edges, keyed by 1-based line.
+ *
+ * Includes the type references (`uses types …` in the header) so a line that
+ * only names a type still gets its port: the port's claim is "something leaves
+ * the graph from this line", and a type reference does.
+ */
+export function refsByLine(payload: WireSymbolPayload): Map<number, LineRef[]> {
+  const byLine = new Map<number, LineRef[]>();
+  const add = (line: number, ref: LineRef): void => {
+    const bucket = byLine.get(line);
+    if (bucket) bucket.push(ref);
+    else byLine.set(line, [ref]);
+  };
+
+  for (const relation of [...payload.outgoing.items, ...payload.typesUsed]) {
+    for (const edge of relation.edges) {
+      if (!edge.line) continue;
+      const word = edgeWord(edge);
+      add(edge.line, {
+        ident: lastSegment(relation.node.name),
+        col: typeof edge.col === 'number' ? edge.col : null,
+        targetId: relation.node.id,
+        uncertain: relation.uncertain,
+        outside: false,
+        title:
+          `${word || 'calls'} ${relation.node.qualifiedName} — ${relation.node.file}:${relation.node.line}` +
+          (edge.confidence != null ? ` · confidence ${edge.confidence}` : '') +
+          (edge.resolvedBy ? ` · resolved by ${edge.resolvedBy}` : ''),
+      });
+    }
+  }
+
+  for (const ref of outsideRefs(payload.outsideIndex)) add(ref.line, ref.ref);
+  return byLine;
+}
+
+/**
+ * The lines a long body is windowed around.
+ *
+ * Only edges that reach something IN the graph count. An unresolved reference
+ * still gets its port and its soft underline where it happens to be on screen,
+ * but it must not open a window of its own: a function with 170 calls into
+ * `console`, `Promise` and `fs` would window around nearly every line and the
+ * head-plus-windows rule would buy nothing.
+ */
+export function graphCallLines(payload: WireSymbolPayload): number[] {
+  const lines = new Set<number>();
+  for (const relation of [...payload.outgoing.items, ...payload.typesUsed]) {
+    for (const line of relation.lines) lines.add(line);
+  }
+  return [...lines].sort((a, b) => a - b);
+}
+
+/**
+ * References with no node behind them, as line refs.
+ *
+ * The samples are raw resolver bookkeeping, so anything that is not a plain
+ * identifier — a whole arrow function captured as a "name", a receiver
+ * expression — is dropped rather than searched for in the line: a ref that
+ * cannot match a token would silently claim the wrong one.
+ */
+function outsideRefs(outside: WireOutsideIndex): Array<{ line: number; ref: LineRef }> {
+  const out: Array<{ line: number; ref: LineRef }> = [];
+  for (const sample of outside.samples) {
+    if (!sample.line) continue;
+    const ident = lastSegment(sample.name ?? '');
+    if (!/^[A-Za-z_$][\w$]*$/.test(ident)) continue;
+    out.push({
+      line: sample.line,
+      ref: {
+        ident,
+        col: typeof sample.col === 'number' ? sample.col : null,
+        targetId: null,
+        uncertain: false,
+        outside: true,
+        title: `${sample.name} is not in the index — nothing here resolves it`,
+      },
+    });
+  }
+  return out;
+}
+
+/**
+ * Decide which token on a line each ref refers to.
+ *
+ * A line can name the same identifier twice (`b.render(a.render())`) and the
+ * recorded column points at the start of the *expression*, not at the callee's
+ * own name, so an exact column hit is the exception rather than the rule. The
+ * ladder — containing token, then first token at or after the column, then any
+ * unclaimed one, then the last — is what makes `this.mutex.withLock(…)` mark
+ * `withLock` instead of `this`.
+ *
+ * @returns token index → the ref that claimed it
+ */
+export function assignRefs(
+  tokens: ReadonlyArray<{ cls: string; text: string; col: number }>,
+  refs: readonly LineRef[]
+): Map<number, LineRef> {
+  const claimed = new Map<number, LineRef>();
+  for (const ref of refs) {
+    const candidates: number[] = [];
+    tokens.forEach((token, index) => {
+      if (token.cls === 'ident' && token.text === ref.ident) candidates.push(index);
+    });
+    if (candidates.length === 0) continue;
+
+    let pick: number | undefined;
+    if (ref.col !== null) {
+      const col = ref.col;
+      pick = candidates.find((i) => {
+        const t = tokens[i] as { text: string; col: number };
+        return t.col <= col && col < t.col + t.text.length;
+      });
+      if (pick === undefined) pick = candidates.find((i) => (tokens[i] as { col: number }).col >= col);
+    }
+    if (pick === undefined) pick = candidates.find((i) => !claimed.has(i));
+    if (pick === undefined) pick = candidates[candidates.length - 1];
+    if (pick === undefined || claimed.has(pick)) continue;
+    claimed.set(pick, ref);
+  }
+  return claimed;
+}
+
+/* ------------------------------------------------------------ right rail -- */
+
+export interface CalleeRow {
+  relation: WireRelation;
+  /** First call-site line — the height the row wants to sit at. */
+  anchor: number | null;
+  /** Distinct call-site lines; `×N` appears when there is more than one. */
+  lines: number[];
+  words: string[];
+  via: string | null;
+}
+
+export interface CalleeRailModel {
+  rows: CalleeRow[];
+  uncertain: CalleeRow[];
+  /** Callee groups the API had to cap away. */
+  hiddenGroups: number;
+  outsideCalls: number;
+  outsideTypeRefs: number;
+}
+
+export function buildCalleeRail(payload: WireSymbolPayload): CalleeRailModel {
+  const rows: CalleeRow[] = [];
+  const uncertain: CalleeRow[] = [];
+
+  for (const relation of payload.outgoing.items) {
+    const row: CalleeRow = {
+      relation,
+      anchor: relation.lines[0] ?? null,
+      lines: relation.lines,
+      words: relationWords(relation),
+      via: synthesizedBy(relation),
+    };
+    if (relation.uncertain) uncertain.push(row);
+    else rows.push(row);
+  }
+
+  const typeRefs = payload.outsideIndex.byKind['references'] ?? 0;
+  return {
+    rows,
+    uncertain,
+    hiddenGroups: payload.outgoing.total - payload.outgoing.shown,
+    outsideCalls: Math.max(0, payload.outsideIndex.total - typeRefs),
+    outsideTypeRefs: typeRefs,
+  };
+}
+
+/* ------------------------------------------------------------- left rail -- */
+
+export interface CallerRow {
+  relation: WireRelation;
+  words: string[];
+  /** Call-site lines in the CALLER's file — the `:4657` chips. */
+  lines: number[];
+  via: string | null;
+}
+
+export interface CallerFileGroup {
+  file: string;
+  /** True for the focal symbol's own file, which is labelled "same file". */
+  same: boolean;
+  rows: CallerRow[];
+}
+
+export interface CallerRailModel {
+  groups: CallerFileGroup[];
+  uncertain: CallerRow[];
+  tests: { rows: CallerRow[]; calls: number; files: string[] };
+  /** Distinct callers, including the ones folded into tests and uncertain. */
+  total: number;
+  hiddenGroups: number;
+}
+
+/**
+ * The left rail: callers grouped by file, with tests and name-only guesses
+ * folded away.
+ *
+ * The folds are not "hide the boring ones" — they are the two cases where a
+ * long list would drown the answer. Tests are usually the largest group and
+ * the least surprising ("of course the test file calls it"), and an uncertain
+ * caller is a guess the reader should be able to see marked as one rather than
+ * mixed into the same list as a resolved call. Both carry their counts.
+ */
+export function buildCallerRail(payload: WireSymbolPayload): CallerRailModel {
+  const focalFile = payload.node.file;
+  const byFile = new Map<string, CallerRow[]>();
+  const uncertain: CallerRow[] = [];
+  const testRows: CallerRow[] = [];
+
+  for (const relation of payload.incoming.items) {
+    const row: CallerRow = {
+      relation,
+      words: relationWords(relation),
+      lines: relation.lines,
+      via: synthesizedBy(relation),
+    };
+    // Uncertainty wins over test-ness: a name-only guess is a claim about the
+    // edge, and burying it in the tests fold would present it as established.
+    if (relation.uncertain) {
+      uncertain.push(row);
+      continue;
+    }
+    if (relation.node.test) {
+      testRows.push(row);
+      continue;
+    }
+    const bucket = byFile.get(relation.node.file);
+    if (bucket) bucket.push(row);
+    else byFile.set(relation.node.file, [row]);
+  }
+
+  const groups: CallerFileGroup[] = [...byFile.entries()]
+    .map(([file, rows]) => ({ file, same: file === focalFile, rows }))
+    .sort((a, b) => (a.same ? -1 : b.same ? 1 : a.file.localeCompare(b.file)));
+
+  return {
+    groups,
+    uncertain,
+    tests: {
+      rows: testRows,
+      calls: testRows.reduce((sum, row) => sum + row.relation.edgeCount, 0),
+      files: [...new Set(testRows.map((row) => row.relation.node.file))].sort(),
+    },
+    total: payload.incoming.total,
+    hiddenGroups: payload.incoming.total - payload.incoming.shown,
+  };
+}
+
+/* ----------------------------------------------------------- connectors -- */
+
+/** One hairline from a gutter port to a callee row. Geometry comes from the view. */
+export interface Connector {
+  /** SVG path data — a single cubic from the port to the row. */
+  d: string;
+  targetId: string;
+  uncertain: boolean;
+  /** Synthesized rather than parsed — dynamic dispatch, drawn dashed. */
+  heuristic: boolean;
+  /** The edge the reader arrived by. */
+  origin: boolean;
+}
+
+/* -------------------------------------------------------------- outline -- */
+
+export interface OutlineRow {
+  member: WireMember;
+  nested: boolean;
+  dimmed: boolean;
+}
+
+export function buildOutline(payload: WireSymbolPayload): OutlineRow[] {
+  return payload.members.items.map((member) => ({
+    member,
+    nested: member.depth > 1,
+    dimmed: QUIET_MEMBER_KINDS.has(member.kind),
+  }));
+}
+
+/* ------------------------------------------------------------- decisions -- */
+
+/**
+ * Whether this symbol's body is worth drawing at all.
+ *
+ * A 700-line class body is a list of members with braces between them: the
+ * outline says the same thing in 20 rows and lets the reader pick one. Below
+ * {@link CONTAINER_BODY_LINES} the body IS the useful view of a container, so
+ * both are shown.
+ */
+export function showsBody(kind: string, lines: number): boolean {
+  return !(CONTAINER_KINDS.has(kind) && lines > CONTAINER_BODY_LINES);
+}
+
+/** The kind word and the modifiers that belong beside a symbol's name. */
+export function kindPhrase(node: {
+  kind: string;
+  async?: boolean;
+  static?: boolean;
+  abstract?: boolean;
+  visibility?: string;
+}): string {
+  const parts = [node.kind === 'type_alias' ? 'type' : node.kind.replace(/_/g, ' ')];
+  if (node.async) parts.push('async');
+  if (node.static) parts.push('static');
+  if (node.abstract) parts.push('abstract');
+  if (node.visibility && node.visibility !== 'public') parts.push(node.visibility);
+  return parts.join(' · ');
+}
+
+/** "1 caller" / "12 callers" — the counts sit next to too many nouns to inline. */
+export function plural(count: number, one: string, many = `${one}s`): string {
+  return `${count} ${count === 1 ? one : many}`;
+}

+ 54 - 0
ui/src/lib/walk.ts

@@ -0,0 +1,54 @@
+/**
+ * Walking the graph — the one place a symbol navigation is performed.
+ *
+ * Every step records its DIRECTION before it navigates, because the direction
+ * is not recoverable afterwards. "I stepped down into a call" and "I stepped up
+ * to a caller" produce the same pair of symbols; only the act distinguishes
+ * them, and the Symbol view needs it twice over: the trail bar draws `→` or `←`
+ * between hops, and the arrival rail tints the row you came from ("you came
+ * from here") — which is the LEFT rail after stepping down, and the RIGHT rail
+ * after stepping up.
+ *
+ * The trail is pushed first and travels in the URL, so a reload or a shared
+ * link reproduces the walk rather than starting a fresh one at the same symbol.
+ */
+
+import { navigate, symbolHref } from './router.svelte';
+import { encodeTrail, trail, type HopDirection } from './trail.svelte';
+
+export interface WalkTarget {
+  id: string;
+  name?: string | null;
+  kind?: string | null;
+}
+
+/**
+ * Move to a symbol, recording how you got there.
+ *
+ * @param dir  'down' following a call, 'up' going to a caller, 'start' for a
+ *             jump that is neither (search, a breadcrumb, a members outline).
+ * @param line a line to highlight and scroll to in the destination.
+ */
+export function walkTo(target: WalkTarget, dir: HopDirection, line?: number): void {
+  trail.push({ id: target.id, name: target.name ?? null, kind: target.kind ?? null, dir });
+  const href = symbolHref(target.id, { trail: encodeTrail(trail.hops), ...(line ? { line } : {}) });
+  navigate(href);
+}
+
+/**
+ * Where the reader arrived from, and which rail should show it.
+ *
+ * A hop marked `up` means the reader stepped from a callee to this symbol, so
+ * the symbol they left is one of THIS symbol's callees — the right rail. A
+ * `down` hop is the mirror. A `start` hop came from nowhere on screen.
+ */
+export function arrivedFrom(): { id: string; rail: 'left' | 'right' } | null {
+  const hops = trail.hops;
+  if (hops.length < 2) return null;
+  const current = hops[hops.length - 1];
+  const previous = hops[hops.length - 2];
+  if (!current || !previous) return null;
+  if (current.dir === 'down') return { id: previous.id, rail: 'left' };
+  if (current.dir === 'up') return { id: previous.id, rail: 'right' };
+  return null;
+}

+ 529 - 14
ui/src/views/SymbolView.svelte

@@ -1,32 +1,547 @@
 <!--
 <!--
-  Placeholder for the core screen: callers | verbatim source with gutter
-  ports | line-anchored callee rail (design spec §3.2, task CG-44). It needs
-  the read-only JSON API (CG-42), so until that lands this states the target
-  rather than faking a reader.
+  The Symbol view: callers | verbatim source with gutter ports | line-anchored
+  callee rail (design spec §3.2, task CG-44).
+
+  The geometry is the point of the screen, and it is the one thing that cannot
+  be derived from the payload: where a callee row belongs depends on where its
+  call-site line ended up, which depends on the font, the window width, whether
+  a fold is open. So this component measures — after every render, on every
+  resize — and hands the rail and the overlay their coordinates. Everything
+  else it does is plumbing around that.
+
+  Two scroll containers, deliberately. The left rail scrolls alone; the centre
+  and the right rail scroll together inside the stage, because a callee row
+  that drifts away from its line is worse than no rail at all.
 -->
 -->
 <script lang="ts">
 <script lang="ts">
+  import { tick, untrack } from 'svelte';
+  import CalleeRail from '../components/symbol/CalleeRail.svelte';
+  import CallersRail from '../components/symbol/CallersRail.svelte';
+  import Connectors from '../components/symbol/Connectors.svelte';
+  import BlastStrip from '../components/symbol/BlastStrip.svelte';
+  import MembersOutline from '../components/symbol/MembersOutline.svelte';
+  import SourceBlock from '../components/symbol/SourceBlock.svelte';
+  import SymbolHeader from '../components/symbol/SymbolHeader.svelte';
+  import { ApiFailure, fetchSource, fetchSymbol, type WireNodeRef, type WireSource, type WireSymbolPayload } from '../lib/api';
+  import { hot, railFocus } from '../lib/focus.svelte';
+  import { project } from '../lib/project.svelte';
+  import {
+    buildCalleeRail,
+    buildCallerRail,
+    buildCodeBlock,
+    buildOutline,
+    graphCallLines,
+    refsByLine,
+    showsBody,
+    synthesizedBy,
+    type Connector,
+    type LineRef,
+  } from '../lib/symbol-model';
+  import { trail } from '../lib/trail.svelte';
+  import { arrivedFrom, walkTo } from '../lib/walk';
+
   interface Props {
   interface Props {
     id: string;
     id: string;
     line: number | null;
     line: number | null;
   }
   }
+
   let { id, line }: Props = $props();
   let { id, line }: Props = $props();
+
+  /* ------------------------------------------------------------ geometry -- */
+
+  /** Row height and the gap between two rows pushed apart — spec §3.2. */
+  const ROW_HEIGHT = 34;
+  const ROW_GAP = 6;
+  /** Fallback for the sticky rail header before it has been measured. */
+  const RAIL_HEADER_FALLBACK = 38;
+
+  /* --------------------------------------------------------------- state -- */
+
+  let payload = $state<WireSymbolPayload | null>(null);
+  let source = $state<WireSource | null>(null);
+  let failure = $state<ApiFailure | null>(null);
+  let loading = $state(true);
+
+  let innerEl = $state<HTMLDivElement | null>(null);
+  let centerEl = $state<HTMLElement | null>(null);
+  let railEl = $state<HTMLElement | null>(null);
+  let leftRailEl = $state<HTMLElement | null>(null);
+
+  let tops = $state<number[]>([]);
+  let foldTop = $state(0);
+  let noteTop = $state(0);
+  let stageMinHeight = $state(0);
+  let connectors = $state<Connector[]>([]);
+  let overlay = $state({ width: 0, height: 0 });
+
+  /* ---------------------------------------------------------------- data -- */
+
+  $effect(() => {
+    const wanted = id;
+    const controller = new AbortController();
+    untrack(() => load(wanted, controller.signal));
+    return () => controller.abort();
+  });
+
+  async function load(nodeId: string, signal: AbortSignal): Promise<void> {
+    loading = true;
+    failure = null;
+    payload = null;
+    source = null;
+    railFocus.reset();
+    hot.set(null);
+    void project.ensure();
+
+    let node: WireSymbolPayload;
+    try {
+      node = await fetchSymbol(nodeId, signal);
+    } catch (cause) {
+      if (signal.aborted) return;
+      failure = asFailure(cause);
+      loading = false;
+      return;
+    }
+    if (signal.aborted) return;
+    payload = node;
+    loading = false;
+    trail.resolve(nodeId, { name: node.node.name, kind: node.node.kind });
+
+    // The body is only fetched when it will be drawn: a 2,000-line file node
+    // shows its outline, and asking for 2,000 lines to throw them away is the
+    // difference between a screen that settles at once and one that does not.
+    if (!showsBody(node.node.kind, node.node.lines) || node.drift) return;
+    try {
+      const slice = await fetchSource(node.node.file, node.node.line, node.node.endLine, signal);
+      if (!signal.aborted) source = slice;
+    } catch {
+      // No slice: the header, the rails and the blast strip are all still
+      // true, so the screen loses the body and says so rather than erroring.
+    }
+  }
+
+  function asFailure(cause: unknown): ApiFailure {
+    if (cause instanceof ApiFailure) return cause;
+    return new ApiFailure(0, 'error', cause instanceof Error ? cause.message : String(cause), null);
+  }
+
+  /* -------------------------------------------------------------- models -- */
+
+  let callers = $derived(payload ? buildCallerRail(payload) : null);
+  let callees = $derived(payload ? buildCalleeRail(payload) : null);
+  let refs = $derived(payload ? refsByLine(payload) : new Map<number, LineRef[]>());
+  let outline = $derived(payload ? buildOutline(payload) : []);
+
+  let wantsBody = $derived(payload ? showsBody(payload.node.kind, payload.node.lines) : false);
+
+  let codeBlock = $derived.by(() => {
+    if (!payload || !source?.lines) return null;
+    const from = source.from ?? payload.node.line;
+    return buildCodeBlock(from, source.lines, graphCallLines(payload));
+  });
+
+  let origin = $derived(arrivedFrom());
+  let originLeft = $derived(origin?.rail === 'left' ? origin.id : null);
+  let originRight = $derived(origin?.rail === 'right' ? origin.id : null);
+
+  let emptyCalleeReason = $derived.by(() => {
+    if (!payload) return '';
+    if (!wantsBody) {
+      return `A ${payload.node.kind.replace(/_/g, ' ')} makes no calls itself — its members do. Open one from the outline.`;
+    }
+    return 'This symbol makes no resolved calls — a leaf.';
+  });
+
+  /* ------------------------------------------------------------ movement -- */
+
+  /**
+   * Follow a call. No line is carried across: the call-site line belongs to the
+   * symbol being left, and the destination opens at its own definition.
+   */
+  function stepDown(node: WireNodeRef): void {
+    walkTo(node, 'down');
+  }
+
+  /** Go to a caller, landing on the line that makes the call when one is named. */
+  function stepUp(node: WireNodeRef, at?: number): void {
+    walkTo(node, 'up', at);
+  }
+
+  /** A jump that is neither up nor down: a breadcrumb, a chip, a member. */
+  function open(node: WireNodeRef): void {
+    walkTo(node, 'start');
+  }
+
+  function followRef(ref: LineRef): void {
+    if (!ref.targetId) return;
+    const target = payload?.outgoing.items.find((r) => r.node.id === ref.targetId)?.node
+      ?? payload?.typesUsed.find((r) => r.node.id === ref.targetId)?.node;
+    if (target) walkTo(target, 'down');
+  }
+
+  /* ------------------------------------------------------------ keyboard -- */
+
+  function leftRows(): WireNodeRef[] {
+    return (callers?.groups ?? []).flatMap((group) => group.rows.map((row) => row.relation.node));
+  }
+
+  function rightRows(): WireNodeRef[] {
+    return (callees?.rows ?? []).map((row) => row.relation.node);
+  }
+
+  function activeRows(): WireNodeRef[] {
+    return railFocus.rail === 'left' ? leftRows() : rightRows();
+  }
+
+  function onkeydown(event: KeyboardEvent): void {
+    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
+    const target = event.target;
+    if (
+      target instanceof HTMLElement &&
+      (target.isContentEditable ||
+        target instanceof HTMLInputElement ||
+        target instanceof HTMLTextAreaElement ||
+        target instanceof HTMLSelectElement)
+    ) {
+      return;
+    }
+    if (!payload) return;
+
+    switch (event.key) {
+      case 'ArrowLeft':
+        railFocus.switchTo('left');
+        break;
+      case 'ArrowRight':
+        railFocus.switchTo('right');
+        break;
+      case 'ArrowDown':
+      case 'j':
+        railFocus.step(1, activeRows().length);
+        break;
+      case 'ArrowUp':
+      case 'k':
+        railFocus.step(-1, activeRows().length);
+        break;
+      case 'Enter': {
+        const node = activeRows()[railFocus.index];
+        if (node) {
+          event.preventDefault();
+          if (railFocus.rail === 'left') stepUp(node);
+          else stepDown(node);
+        }
+        return;
+      }
+      default:
+        return;
+    }
+    event.preventDefault();
+    // Keep the selection on screen; the rails are the only thing that scrolls
+    // out from under the keyboard.
+    void tick().then(() => {
+      const scope = railFocus.rail === 'left' ? leftRailEl : railEl;
+      // Rows are the only focusable buttons in a rail, and they render in the
+      // same order the keyboard walks them.
+      scope?.querySelectorAll('[role="button"]')[railFocus.index]?.scrollIntoView({
+        block: 'nearest',
+      });
+    });
+  }
+
+  /* ----------------------------------------------------------- measuring -- */
+
+  /**
+   * Place every callee row beside its call site, then draw the connectors.
+   *
+   * Rows are laid out in source order and never allowed to overlap: a row wants
+   * to sit at the centre of its first call-site line, but takes
+   * `previous + height + gap` when that would collide. Order beats exactness —
+   * a rail whose rows jump around relative to the body stops being a reading of
+   * the code — and the connector still runs to the line, so the displacement is
+   * visible rather than silent.
+   */
+  function relayout(): void {
+    const inner = innerEl;
+    const center = centerEl;
+    const rail = railEl;
+    const rows = callees?.rows ?? [];
+    if (!inner || !center || !rail) return;
+
+    const headerHeight =
+      rail.querySelector<HTMLElement>('[data-rail-header]')?.offsetHeight ?? RAIL_HEADER_FALLBACK;
+
+    const lineCentre = (n: number): number | null => {
+      const el = center.querySelector<HTMLElement>(`[data-line="${n}"]`);
+      return el ? el.offsetTop + el.offsetHeight / 2 : null;
+    };
+
+    let y = headerHeight + 14;
+    const nextTops: number[] = [];
+    const rowCentres: Array<number | null> = [];
+    for (const row of rows) {
+      const centre = row.anchor !== null ? lineCentre(row.anchor) : null;
+      const wanted = centre !== null ? centre - ROW_HEIGHT / 2 : y;
+      y = Math.max(wanted, y);
+      nextTops.push(y);
+      rowCentres.push(y + ROW_HEIGHT / 2);
+      y += ROW_HEIGHT + ROW_GAP;
+    }
+
+    const nextFoldTop = y + 8;
+    if ((callees?.uncertain.length ?? 0) > 0) {
+      const fold = rail.querySelector<HTMLElement>('[data-rail-fold]');
+      y = nextFoldTop + (fold?.offsetHeight ?? 30);
+    }
+    const nextNoteTop = y + 14;
+
+    tops = nextTops;
+    foldTop = nextFoldTop;
+    noteTop = nextNoteTop;
+    stageMinHeight = Math.max(center.offsetHeight, nextNoteTop + 60);
+
+    // Connectors: one per call site, from the centre column's right edge to the
+    // row's own centre. Both coordinate systems are the stage's, so the port
+    // and the row agree even when the stage is scrolled.
+    const x0 = center.offsetLeft + center.offsetWidth - 10;
+    const x1 = rail.offsetLeft + 14;
+    const cx = (x0 + x1) / 2;
+    const next: Connector[] = [];
+    rows.forEach((row, index) => {
+      const ry = rowCentres[index];
+      if (ry == null) return;
+      const via = synthesizedBy(row.relation);
+      for (const callLine of row.lines) {
+        const ly = lineCentre(callLine);
+        if (ly === null) continue;
+        next.push({
+          d: `M${x0},${ly} C${cx},${ly} ${cx},${ry} ${x1},${ry}`,
+          targetId: row.relation.node.id,
+          uncertain: row.relation.uncertain,
+          heuristic: via !== null,
+          origin: row.relation.node.id === originRight,
+        });
+      }
+    });
+    connectors = next;
+    overlay = { width: inner.scrollWidth, height: Math.max(inner.offsetHeight, stageMinHeight) };
+  }
+
+  let scheduled = false;
+  function scheduleRelayout(): void {
+    if (scheduled) return;
+    scheduled = true;
+    requestAnimationFrame(() => {
+      scheduled = false;
+      relayout();
+    });
+  }
+
+  // Re-measure whenever what is drawn changes. The dependencies are the INPUTS
+  // (the models and the block); the outputs it writes are read untracked inside
+  // relayout(), so this cannot feed itself.
+  $effect(() => {
+    void codeBlock;
+    void callees;
+    void outline;
+    void payload;
+    void tick().then(scheduleRelayout);
+  });
+
+  // Layout is a function of pixels, not of data: a resized window, a loaded
+  // font and an opened fold all move the lines without changing the payload.
+  $effect(() => {
+    const inner = innerEl;
+    const center = centerEl;
+    const rail = railEl;
+    if (!inner || !center || !rail) return;
+    const observer = new ResizeObserver(scheduleRelayout);
+    observer.observe(inner);
+    observer.observe(center);
+    observer.observe(rail);
+    // Opening a fold moves the rail's contents without resizing any box the
+    // observer watches — the folds are absolutely positioned. `toggle` does not
+    // bubble, so it is caught on the way down.
+    inner.addEventListener('toggle', scheduleRelayout, true);
+    void document.fonts?.ready.then(scheduleRelayout);
+    return () => {
+      observer.disconnect();
+      inner.removeEventListener('toggle', scheduleRelayout, true);
+    };
+  });
+
+  // Scroll the highlighted call site into view once, when it first appears —
+  // and not again, so a later resize does not yank the reader back to it.
+  let scrolledTo: string | null = null;
+  $effect(() => {
+    const key = line === null ? null : `${id}:${line}`;
+    const center = centerEl;
+    if (!key || !center || !codeBlock || scrolledTo === key) return;
+    const el = center.querySelector(`[data-line="${line}"]`);
+    if (!el) return;
+    scrolledTo = key;
+    el.scrollIntoView({ block: 'center' });
+  });
 </script>
 </script>
 
 
-<div class="scroll">
-  <div class="emptystate">
-    <h2>Symbol view</h2>
-    <p>
-      <span class="mono">{id}</span>{#if line}<span class="dim"> · line {line}</span>{/if}
-    </p>
-    <p>
-      Callers, the symbol's source, and its callees are not wired up in this build yet.
-    </p>
+<svelte:window {onkeydown} />
+
+{#if failure}
+  <div class="scroll">
+    <div class="emptystate">
+      <h2>{failure.code === 'not-found' ? 'No such symbol' : 'Could not load this symbol'}</h2>
+      <p>{failure.message}</p>
+      {#if failure.guidance}<p class="dim">{failure.guidance}</p>{/if}
+    </div>
+  </div>
+{:else if loading || !payload || !callers || !callees}
+  <div class="scroll">
+    <div class="emptystate"><p class="dim">Loading…</p></div>
   </div>
   </div>
-</div>
+{:else}
+  <div class="focus">
+    <aside class="rail-left" bind:this={leftRailEl} aria-label="Called by">
+      <CallersRail
+        model={callers}
+        originId={originLeft}
+        exported={payload.node.exported === true}
+        onstepUp={stepUp}
+      />
+    </aside>
+
+    <div class="stage">
+      <div class="stage-inner" bind:this={innerEl} style:min-height={`${stageMinHeight}px`}>
+        <Connectors {connectors} width={overlay.width} height={overlay.height} />
+
+        <section class="center" bind:this={centerEl}>
+          <SymbolHeader {payload} onopen={open} />
+
+          {#if payload.drift}
+            <div class="drift">
+              {payload.node.file} changed on disk after the last index sync — the body is not shown, because
+              the line ranges the graph holds no longer match the file. Run <code>codegraph sync</code>
+              to bring it up to date.
+            </div>
+          {:else if codeBlock}
+            <SourceBlock
+              block={codeBlock}
+              language={payload.node.language}
+              {refs}
+              defLine={payload.node.line}
+              defName={payload.node.name}
+              highlight={line}
+              onfollow={followRef}
+            />
+          {:else if !wantsBody}
+            <!-- The outline below IS the body for a container this size. -->
+          {:else if source}
+            <div class="note">{source.reason ?? 'Source is not available for this symbol.'}</div>
+          {/if}
+
+          {#if outline.length > 0}
+            <MembersOutline
+              rows={outline}
+              total={payload.members.total}
+              truncated={payload.members.truncated}
+              onopen={open}
+            />
+          {/if}
+
+          {#if payload.blast}
+            <BlastStrip
+              blast={payload.blast}
+              scale={project.stats?.blastScale ?? null}
+              testCalls={callers.tests.calls}
+              testFiles={callers.tests.files.length}
+            />
+          {/if}
+        </section>
+
+        <aside class="rail-right" bind:this={railEl} aria-label="Calls">
+          <CalleeRail
+            model={callees}
+            {tops}
+            {foldTop}
+            {noteTop}
+            focalFile={payload.node.file}
+            originId={originRight}
+            emptyReason={emptyCalleeReason}
+            onstepDown={stepDown}
+          />
+        </aside>
+      </div>
+    </div>
+  </div>
+{/if}
 
 
 <style>
 <style>
   .scroll {
   .scroll {
     height: 100%;
     height: 100%;
     overflow: auto;
     overflow: auto;
   }
   }
+
+  .focus {
+    display: grid;
+    grid-template-columns: 300px minmax(520px, 1fr);
+    height: 100%;
+    min-height: 0;
+  }
+
+  .rail-left {
+    overflow: auto;
+    border-right: 1px solid var(--rule-soft);
+    background: var(--paper);
+  }
+
+  .stage {
+    position: relative;
+    overflow: auto;
+  }
+
+  /* The positioning context every measured coordinate is expressed in: line
+     offsets, rail row tops and the SVG overlay all share this origin. */
+  .stage-inner {
+    position: relative;
+    display: grid;
+    grid-template-columns: minmax(480px, 1fr) 320px;
+    min-height: 100%;
+  }
+
+  .center {
+    min-width: 0;
+    padding: 18px 22px 40px;
+  }
+
+  .rail-right {
+    position: relative;
+    border-left: 1px solid var(--rule-faint);
+  }
+
+  .drift {
+    margin-top: 16px;
+    padding: 10px 12px;
+    border: 1px solid var(--amber);
+    background: var(--amber-soft);
+    color: var(--amber);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+
+  .drift code {
+    font-family: var(--mono);
+    font-size: 12px;
+  }
+
+  .note {
+    padding: 12px 0;
+    color: var(--ink-3);
+    font-size: 12px;
+  }
+
+  @media (max-width: 1100px) {
+    .focus {
+      grid-template-columns: 240px minmax(360px, 1fr);
+    }
+
+    .stage-inner {
+      grid-template-columns: minmax(360px, 1fr) 260px;
+    }
+  }
 </style>
 </style>