소스 검색

feat(ui): the viewer's screens as @colbymchenry/codegraph-ui, behind one adapter (CG-61)

`ui/src` now builds two ways from one tree: the static app `codegraph ui`
serves, and — via `svelte-package` — a Svelte library the Pro app imports.
A forked component would be a second answer to the same question about the
same graph, so there is no fork.

Everything a screen knows arrives through a `GraphAdapter`: eleven methods
answering the wire shapes verbatim, with `createHttpAdapter()` (the loopback
JSON API) as the default and a host's in-process engine reads as the point.
`lib/api.ts` became a one-line-per-call facade over it, which is why no call
site in the views changed. The payload types moved to `lib/wire.ts` — no
imports, no runtime — so a host can depend on the vocabulary alone.

Two more seams and one guard:

- `lib/navigation.ts` holds the href builders behind a `NavigationDriver`, so
  a host addresses its own URL space. The app's half — the hash parser and the
  live route, which attach window listeners at module scope — stays in
  `router.svelte.ts` and is pruned out of the package: rendering a Symbol view
  must not install a hash router in somebody else's application.
- `lib/theme.css` carries the design tokens and maps Svelte Flow's `--xy-*`
  variables onto them, so a host never sees library defaults. Dark now also
  answers to a bare `[data-theme]`, which is how `<CodegraphUi theme>` themes
  a container rather than the document.
- `scripts/check-ui-package.mjs` prunes the app's shell, resolves the
  extensionless specifiers svelte-package leaves behind, and asserts that
  nothing but `lib/adapter.js` reaches the network.

The search box, its keyboard and its panel are one component now
(`SearchPalette`), because splitting them is what breaks a palette.

`__tests__/ui-package.test.ts` mounts the three screens from the package entry
against a mock adapter in jsdom; it runs as a second vitest project so the
`browser` resolve condition it needs cannot reach the engine's suites.

Versioned with the engine. Prepared, not published: `private: true` is the
guard and `pack-npm.sh` only packs a tarball under CODEGRAPH_PACK_UI=1.
Colby McHenry 1 주 전
부모
커밋
c15413f200
42개의 변경된 파일3969개의 추가작업 그리고 1169개의 파일을 삭제
  1. 3 0
      .gitignore
  2. 4 1
      CHANGELOG.md
  3. 17 0
      CLAUDE.md
  4. 643 0
      __tests__/ui-package.test.ts
  5. 21 1
      docs/design/codegraph-ui-design-spec.md
  6. 662 56
      package-lock.json
  7. 3 0
      package.json
  8. 189 0
      scripts/check-ui-package.mjs
  9. 26 0
      scripts/pack-npm.sh
  10. 47 0
      scripts/sync-ui-version.mjs
  11. 124 7
      ui/README.md
  12. 40 4
      ui/package.json
  13. 7 98
      ui/src/app.css
  14. 80 0
      ui/src/components/CodegraphUi.svelte
  15. 82 0
      ui/src/components/PalettePanel.svelte
  16. 154 54
      ui/src/components/SearchPalette.svelte
  17. 5 151
      ui/src/components/TopBar.svelte
  18. 1 1
      ui/src/components/TrailBar.svelte
  19. 1 1
      ui/src/components/entry/EntrySection.svelte
  20. 1 1
      ui/src/components/file/FileModeTabs.svelte
  21. 1 1
      ui/src/components/file/FileRail.svelte
  22. 1 1
      ui/src/components/map/MapSidePanel.svelte
  23. 1 1
      ui/src/components/symbol/BlastStrip.svelte
  24. 1 1
      ui/src/components/symbol/CallersRail.svelte
  25. 1 1
      ui/src/components/symbol/SymbolHeader.svelte
  26. 221 0
      ui/src/index.ts
  27. 360 0
      ui/src/lib/adapter.ts
  28. 56 645
      ui/src/lib/api.ts
  29. 118 63
      ui/src/lib/live.svelte.ts
  30. 209 0
      ui/src/lib/navigation.ts
  31. 33 74
      ui/src/lib/router.svelte.ts
  32. 192 0
      ui/src/lib/theme.css
  33. 1 1
      ui/src/lib/walk.ts
  34. 589 0
      ui/src/lib/wire.ts
  35. 1 1
      ui/src/views/EntryView.svelte
  36. 1 1
      ui/src/views/FileView.svelte
  37. 1 1
      ui/src/views/FlowView.svelte
  38. 1 1
      ui/src/views/HomeView.svelte
  39. 1 1
      ui/src/views/MapView.svelte
  40. 1 1
      ui/src/views/SymbolView.svelte
  41. 6 0
      vitest.config.mts
  42. 63 0
      vitest.workspace.mts

+ 3 - 0
.gitignore

@@ -4,6 +4,9 @@ node_modules/
 # Build output
 dist/
 
+# svelte-package's scratch dir (ui/ library build)
+.svelte-kit/
+
 .cmem
 
 # IDE

+ 4 - 1
CHANGELOG.md

@@ -62,11 +62,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   This also takes about 3 MB of grammar files and two dependencies out of the install.
 
+- **The viewer's screens are now a component library other tools can render.** The Symbol view, the Flow strip and the Map are packaged as `@colbymchenry/codegraph-ui` — the same components `codegraph ui` draws, not a copy of them — so another application can show you a symbol's callers, a call path or your architecture over its own copy of the graph. Everything a screen knows arrives through one small interface it is handed, so the tool doing the rendering decides where the data comes from and where a click goes; a design-token stylesheet ships with it so the screens can be themed to match whatever they are embedded in. It is versioned with the engine, so the reader and the graph it reads always match.
+
+  Nothing changes for `codegraph ui` itself — it is the same viewer, now the library's first user. The package is prepared, not yet on npm.
+
 ### Fixes
 
 - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it.
 
-
 ## [1.6.0] - 2026-08-26
 
 ### Highlights

+ 17 - 0
CLAUDE.md

@@ -12,6 +12,7 @@ Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer
 
 ```bash
 npm run build           # tsc + copy schema.sql and *.wasm + build the viewer into dist/; chmods dist/bin/codegraph.js
+npm run build:lib       # the viewer's components as @colbymchenry/codegraph-ui (ui/dist) — NOT part of `build`
 npm run dev             # tsc --watch
 npm run clean           # rm -rf dist
 
@@ -36,6 +37,22 @@ browser viewer into `dist/viewer/` (never `dist/ui/` — that's the terminal ui)
 highlighting reads a file with the same grammar the engine indexed it with, so a missing wasm is an
 unhighlighted screen as well as an extraction gap.
 
+`npm run build:lib` is separate and does NOT run as part of `npm run build`: it compiles the same
+`ui/src` tree a second way, with `svelte-package`, into `ui/dist` — the `@colbymchenry/codegraph-ui`
+component library the Pro app imports (task CG-61). `scripts/check-ui-package.mjs` then prunes the
+standalone app's shell out of it, resolves the extensionless import specifiers `svelte-package`
+leaves behind, and asserts the seam: nothing outside `lib/adapter.js` may reach the network. The
+package is **prepared, not published** — `ui/package.json` carries `"private": true` deliberately,
+and `scripts/pack-npm.sh` only packs a tarball when `CODEGRAPH_PACK_UI=1`.
+
+Tests run as **two vitest projects** (`vitest.workspace.mts`): `engine` (node) and `ui` (jsdom, the
+Svelte plugin, `resolve.conditions: ['browser']`) for the single `__tests__/ui-package.test.ts`.
+`npm test` still runs both. The split is not cosmetic — `browser` is a package-resolution
+condition, and applied globally it hands the engine's suites the browser builds of
+`web-tree-sitter` and friends. The root config (`vitest.config.mts`, `.mts` because the plugin is
+ESM-only and the repo is CJS) is the shared base; note that a workspace project **concatenates**
+the base's `include` with its own, which is why the `ui` project does not `extends` it.
+
 Node engines: `>=20.0.0 <25.0.0`. There is a hard exit on Node 25.x and below 20 (see `src/bin/node-version-check.ts`).
 
 ## Architecture

+ 643 - 0
__tests__/ui-package.test.ts

@@ -0,0 +1,643 @@
+/**
+ * `@colbymchenry/codegraph-ui` — the package's own test (task CG-61).
+ *
+ * A minimal Svelte host mounts the three headline components from the package
+ * entry against a MOCK adapter and asserts what lands in the document. That is
+ * the whole promise of the package in one file: CodeGraph Pro renders these
+ * same components over its own in-process engine reads, so if a screen can be
+ * drawn from an object literal here, it can be drawn from a graph there.
+ *
+ * The import is `ui/src/index.ts` — the package entry itself, not the
+ * components one by one — so a name dropped from the public surface fails here
+ * rather than in the Pro app.
+ *
+ * Everything below is deliberately about the SEAM, not about the screens:
+ * layout, geometry and the rails have their own suites (`ui-symbol-model`,
+ * `ui-flow-model`, `ui-map-model`). What is being proved here is that no
+ * component reaches past the adapter for anything.
+ */
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { flushSync, mount, unmount } from 'svelte';
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+import {
+  ArchitectureMap,
+  CodegraphUi,
+  FlowStrip,
+  SearchPalette,
+  SymbolView,
+  TrailBar,
+  createHttpAdapter,
+  fileHref,
+  flowHref,
+  getGraphAdapter,
+  hashNavigation,
+  live,
+  mapHref,
+  setGraphAdapter,
+  setNavigationDriver,
+  symbolHref,
+  trail,
+  type GraphAdapter,
+  type NavigationDriver,
+  type WireFlowPayload,
+  type WireMapPayload,
+  type WireNodeRef,
+  type WireSource,
+  type WireStats,
+  type WireSymbolPayload,
+} from '../ui/src/index';
+
+/* ---------------------------------------------------------------- fixtures */
+
+const ROOT = join(import.meta.dirname, '..');
+
+function nodeRef(overrides: Partial<WireNodeRef> = {}): WireNodeRef {
+  return {
+    id: 'function:parseToken@src/auth/token.ts:12',
+    kind: 'function',
+    name: 'parseToken',
+    qualifiedName: 'parseToken',
+    file: 'src/auth/token.ts',
+    line: 12,
+    endLine: 18,
+    language: 'typescript',
+    test: false,
+    ...overrides,
+  };
+}
+
+const CALLER = nodeRef({
+  id: 'function:handleCallback@src/auth/callback.ts:40',
+  name: 'handleCallback',
+  qualifiedName: 'handleCallback',
+  file: 'src/auth/callback.ts',
+  line: 40,
+  endLine: 60,
+});
+
+const CALLEE = nodeRef({
+  id: 'function:decodeJwt@src/auth/jwt.ts:3',
+  name: 'decodeJwt',
+  qualifiedName: 'decodeJwt',
+  file: 'src/auth/jwt.ts',
+  line: 3,
+  endLine: 9,
+});
+
+const SYMBOL: WireSymbolPayload = {
+  node: {
+    ...nodeRef(),
+    startColumn: 0,
+    endColumn: 1,
+    lines: 7,
+    exported: true,
+  },
+  ancestors: [nodeRef({ id: 'file:src/auth/token.ts', kind: 'file', name: 'token.ts' })],
+  members: { total: 0, shown: 0, truncated: false, items: [] },
+  incoming: {
+    total: 1,
+    shown: 1,
+    truncated: false,
+    items: [
+      {
+        node: CALLER,
+        edgeKinds: ['calls'],
+        edges: [{ kind: 'calls', line: 44, col: 6, confidence: 1 }],
+        edgeCount: 1,
+        lines: [44],
+        confidence: 1,
+        uncertain: false,
+        synthesized: false,
+      },
+    ],
+  },
+  outgoing: {
+    total: 1,
+    shown: 1,
+    truncated: false,
+    items: [
+      {
+        node: CALLEE,
+        edgeKinds: ['calls'],
+        edges: [{ kind: 'calls', line: 14, col: 10, confidence: 1 }],
+        edgeCount: 1,
+        lines: [14],
+        confidence: 1,
+        uncertain: false,
+        synthesized: false,
+      },
+    ],
+  },
+  typesUsed: [],
+  counts: { callers: 1, callees: 1, typesUsed: 0, fanIn: 1, fanOut: 1, members: 0, hub: false },
+  tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
+  outsideIndex: { total: 0, byKind: {}, samples: [] },
+  blast: {
+    direct: 1,
+    withinHops: 2,
+    hops: 3,
+    files: 2,
+    testFiles: 0,
+    routes: 0,
+    topFiles: [{ file: 'src/auth/callback.ts', symbols: 1, test: false }],
+  },
+  drift: false,
+};
+
+const SOURCE_LINES = [
+  'export function parseToken(raw: string): Token {',
+  '  // Normalize expiry before anything else reads it.',
+  '  const claims = decodeJwt(raw);',
+  '  return { ...claims, expiresAt: claims.exp * 1000 };',
+  '}',
+];
+
+const SOURCE: WireSource = {
+  file: 'src/auth/token.ts',
+  language: 'typescript',
+  drift: false,
+  showing: 'indexed',
+  contentHash: 'abc123',
+  indexedAt: 1_700_000_000_000,
+  generated: false,
+  totalLines: 40,
+  from: 12,
+  to: 18,
+  lines: SOURCE_LINES,
+};
+
+const FLOW: WireFlowPayload = {
+  query: { kind: 'directed', from: 'handleCallback', to: 'decodeJwt', symbols: [] },
+  flows: [
+    {
+      id: 'flow-1',
+      label: 'handleCallback → decodeJwt',
+      partial: false,
+      boundary: null,
+      hops: [
+        {
+          node: CALLER,
+          edge: null,
+          callRef: { line: 44, col: 6, name: 'parseToken', targetId: SYMBOL.node.id, backwards: false },
+          source: {
+            file: 'src/auth/callback.ts',
+            language: 'typescript',
+            from: 44,
+            to: 46,
+            lines: ['  const token = parseToken(raw);'],
+            drift: false,
+          },
+        },
+        {
+          node: nodeRef(),
+          edge: {
+            kind: 'calls',
+            line: 44,
+            label: 'calls',
+            upward: false,
+            uncertain: false,
+            synthesized: false,
+          },
+          callRef: null,
+          source: {
+            file: 'src/auth/token.ts',
+            language: 'typescript',
+            from: 12,
+            to: 14,
+            lines: SOURCE_LINES.slice(0, 3),
+            drift: false,
+          },
+        },
+      ],
+    },
+  ],
+  ambiguous: [],
+  unresolved: [],
+  reason: null,
+  index: { lastIndexedAt: 1_700_000_000_000, edges: 4, files: 3 },
+  timing: { elapsedMs: 2 },
+};
+
+const MAP: WireMapPayload = {
+  root: 'src',
+  depth: 1,
+  roots: [{ root: 'src', label: 'src', files: 3 }],
+  modules: [
+    {
+      id: 'src/auth',
+      label: 'auth',
+      files: 2,
+      symbols: 6,
+      languages: [{ language: 'typescript', files: 2 }],
+      test: false,
+      facade: false,
+      fileList: { total: 2, shown: 2, truncated: false, items: ['src/auth/token.ts', 'src/auth/callback.ts'] },
+    },
+    {
+      id: 'src/http',
+      label: 'http',
+      files: 1,
+      symbols: 3,
+      languages: [{ language: 'typescript', files: 1 }],
+      test: false,
+      facade: false,
+      fileList: { total: 1, shown: 1, truncated: false, items: ['src/http/server.ts'] },
+    },
+  ],
+  links: [
+    {
+      source: 'src/http',
+      target: 'src/auth',
+      count: 9,
+      declared: 7,
+      byKind: [{ kind: 'calls', count: 9 }],
+      topPairs: [{ from: 'src/http/server.ts', to: 'src/auth/token.ts', count: 9, declared: 7 }],
+    },
+  ],
+  cycles: { total: 0, shown: 0, truncated: false, items: [] },
+  excluded: { uncertainEdges: 0, confidenceBelow: 0.6 },
+  index: { lastIndexedAt: 1_700_000_000_000, edges: 9, files: 3 },
+  timing: { elapsedMs: 1, cached: false },
+};
+
+const STATS: WireStats = {
+  project: { root: '/tmp/demo', name: 'demo' },
+  index: {
+    state: 'ready',
+    lastIndexedAt: 1_700_000_000_000,
+    stale: false,
+    version: '1.0.0',
+    extractionVersion: 1,
+    backend: 'node-sqlite',
+    journalMode: 'wal',
+    pendingReferences: 0,
+    generatedFiles: 0,
+    watching: false,
+    watcherDegraded: false,
+  },
+  graph: {
+    nodes: 9,
+    edges: 9,
+    files: 3,
+    nodesByKind: { function: 9 },
+    edgesByKind: { calls: 9 },
+    filesByLanguage: { typescript: 3 },
+    dbSizeBytes: 1024,
+    walSizeBytes: 0,
+  },
+  frameworks: [],
+  thresholds: { hub: 40, uncertainBelow: 0.6 },
+  blastScale: { maxDirect: 20, maxWithinHops: 60, hops: 3, sampled: 24, estimated: true },
+};
+
+/* ------------------------------------------------------------ mock adapter */
+
+/** Every method the components can reach, and a record of which ones they did. */
+function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
+  const calls: string[] = [];
+  const seen = <T>(name: string, value: T): Promise<T> => {
+    calls.push(name);
+    return Promise.resolve(value);
+  };
+  const adapter: GraphAdapter = {
+    stats: () => seen('stats', STATS),
+    search: () =>
+      seen('search', {
+        query: '',
+        text: '',
+        filters: { kinds: [], languages: [], paths: [], names: [] },
+        results: { total: 0, shown: 0, truncated: false, items: [] },
+        groups: [],
+      }),
+    node: (id) => {
+      calls.push(`node:${id}`);
+      return Promise.resolve(SYMBOL);
+    },
+    nodes: () => seen('nodes', { items: [], missing: [] }),
+    source: (request) => {
+      calls.push(`source:${request.file}`);
+      return Promise.resolve(SOURCE);
+    },
+    file: () =>
+      seen('file', {
+        file: {
+          path: 'src/auth/token.ts',
+          language: 'typescript',
+          size: 900,
+          modifiedAt: 0,
+          indexedAt: 0,
+          contentHash: 'abc123',
+          nodeCount: 3,
+          generated: false,
+          test: false,
+          errors: [],
+          id: 'file:src/auth/token.ts',
+        },
+        topLevel: { calls: 0 },
+        drift: false,
+        outline: { total: 0, shown: 0, truncated: false, items: [] },
+        imports: { total: 0, shown: 0, truncated: false, items: [] },
+        importedBy: { total: 0, shown: 0, truncated: false, items: [] },
+        unresolvedImports: [],
+        dependencies: [],
+        dependents: [],
+      }),
+    fileCode: () =>
+      seen('fileCode', {
+        file: {
+          path: 'src/auth/token.ts',
+          language: 'typescript',
+          size: 900,
+          indexedAt: 0,
+          contentHash: 'abc123',
+          generated: false,
+          test: false,
+          errors: [],
+          id: 'file:src/auth/token.ts',
+          totalLines: 40,
+        },
+        drift: false,
+        outline: { total: 0, shown: 0, truncated: false, items: [] },
+        calls: { total: 0, shown: 0, truncated: false, items: [] },
+        outside: { total: 0, shown: 0, truncated: false, items: [] },
+        intraFileCalls: 0,
+        timing: { elapsedMs: 1 },
+      }),
+    flow: () => seen('flow', FLOW),
+    map: () => seen('map', MAP),
+    routes: () =>
+      seen('routes', {
+        routed: false,
+        routeCount: 0,
+        shown: 0,
+        truncated: false,
+        topHandlerFile: null,
+        topHandlerFileCount: 0,
+        entries: [],
+      }),
+    entryPoints: () =>
+      seen('entryPoints', {
+        frameworks: [],
+        routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
+        files: { total: 0, shown: 0, truncated: false, items: [] },
+        tests: { total: 0, shown: 0, truncated: false, items: [] },
+        hubs: { total: 0, shown: 0, truncated: false, items: [] },
+        index: { lastIndexedAt: null, files: 3 },
+        timing: { elapsedMs: 1, cached: false },
+      }),
+    // Deliberately no `events`: a host without a live channel is the normal
+    // case, and nothing may poll in its absence.
+  };
+  return { adapter, calls };
+}
+
+/* ----------------------------------------------------------------- harness */
+
+let host: HTMLDivElement;
+let mounted: Record<string, unknown> | null = null;
+
+/** jsdom has none of the observers a canvas library expects. */
+beforeAll(() => {
+  class NoopObserver {
+    observe(): void {}
+    unobserve(): void {}
+    disconnect(): void {}
+  }
+  const globals = globalThis as Record<string, unknown>;
+  globals.ResizeObserver ??= NoopObserver;
+  globals.IntersectionObserver ??= NoopObserver;
+  globals.MutationObserver ??= NoopObserver;
+  globals.requestAnimationFrame ??= (fn: FrameRequestCallback) =>
+    setTimeout(() => fn(0), 0) as unknown as number;
+  globals.cancelAnimationFrame ??= (handle: number) => clearTimeout(handle);
+  // jsdom's own `matchMedia` is a stub that is not callable here, and Svelte's
+  // `MediaQuery` (which `@xyflow/svelte`'s store constructs eagerly) calls it
+  // the moment a canvas mounts. Replace it outright rather than guarding.
+  const media = (query: string) => ({
+    media: query,
+    matches: false,
+    onchange: null,
+    addEventListener() {},
+    removeEventListener() {},
+    addListener() {},
+    removeListener() {},
+    dispatchEvent: () => false,
+  });
+  Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: media });
+  globals.matchMedia = media;
+  if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
+});
+
+beforeEach(() => {
+  host = document.createElement('div');
+  document.body.appendChild(host);
+  trail.clear();
+});
+
+afterEach(() => {
+  if (mounted) {
+    void unmount(mounted);
+    mounted = null;
+  }
+  host.remove();
+  setGraphAdapter(null);
+  setNavigationDriver(null);
+});
+
+/**
+ * Mount a component and let its data effects settle.
+ *
+ * Every screen fetches inside an `$effect`, so a render is not finished until
+ * the promise the adapter returned has resolved and the follow-up render has
+ * flushed. Two macrotask turns cover the deepest chain any of them has (the
+ * Symbol view: node, then its source).
+ */
+async function render(
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  component: any,
+  props: Record<string, unknown>
+): Promise<void> {
+  mounted = mount(component, { target: host, props }) as Record<string, unknown>;
+  for (let turn = 0; turn < 4; turn += 1) {
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    flushSync();
+  }
+}
+
+describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
+  it('SymbolView draws callers, source and the callee rail from a mock adapter', async () => {
+    const { adapter, calls } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    await render(SymbolView, { id: SYMBOL.node.id, line: null });
+
+    // It asked the adapter, by id, and it asked for the symbol's own slice.
+    expect(calls).toContain(`node:${SYMBOL.node.id}`);
+    expect(calls).toContain('source:src/auth/token.ts');
+
+    const text = host.textContent ?? '';
+    expect(text).toContain('parseToken');
+    // The caller rail (left) and the callee rail (right) are both drawn.
+    expect(text).toContain('handleCallback');
+    expect(text).toContain('decodeJwt');
+    // The verbatim source, not a summary of it.
+    expect(text).toContain('expiresAt');
+    // The honesty badge: nothing in the fixture's graph tests this symbol.
+    expect(text.toLowerCase()).toContain('test');
+  });
+
+  it('FlowStrip draws one card per hop from a mock adapter', async () => {
+    const { adapter, calls } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    await render(FlowStrip, {
+      from: 'handleCallback',
+      to: 'decodeJwt',
+      symbols: null,
+      trailParam: null,
+    });
+
+    expect(calls).toContain('flow');
+    const text = host.textContent ?? '';
+    expect(text).toContain('handleCallback');
+    expect(text).toContain('parseToken');
+  });
+
+  it('ArchitectureMap draws modules and their dependency from a mock adapter', async () => {
+    const { adapter, calls } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    await render(ArchitectureMap, { root: 'src', depth: 1, tests: false });
+
+    expect(calls).toContain('map');
+    const text = host.textContent ?? '';
+    expect(text).toContain('auth');
+    expect(text).toContain('http');
+  });
+
+  it('TrailBar and SearchPalette mount and read through the same adapter', async () => {
+    const { adapter } = mockAdapter();
+    setGraphAdapter(adapter);
+
+    trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
+    await render(TrailBar, {});
+    expect(host.textContent ?? '').toContain('parseToken');
+
+    void unmount(mounted as Record<string, unknown>);
+    mounted = null;
+    host.innerHTML = '';
+
+    await render(SearchPalette, {});
+    expect(host.querySelector('input[role="combobox"]')).not.toBeNull();
+  });
+
+  it('CodegraphUi installs the adapter before its children ask for data', async () => {
+    const { adapter, calls } = mockAdapter();
+    // NOT installed by hand — the provider is the only thing that installs it.
+    expect(getGraphAdapter()).not.toBe(adapter);
+
+    mounted = mount(CodegraphUi, { target: host, props: { adapter } }) as Record<string, unknown>;
+    flushSync();
+    expect(getGraphAdapter()).toBe(adapter);
+    expect(calls).toEqual([]);
+  });
+});
+
+describe('@colbymchenry/codegraph-ui — the seams', () => {
+  it('a host navigation driver replaces every href the components build', () => {
+    const seen: string[] = [];
+    const driver: NavigationDriver = {
+      symbolHref: (id) => `/review/42/symbol/${encodeURIComponent(id)}`,
+      fileHref: (path) => `/review/42/file/${path}`,
+      mapHref: () => '/review/42/map',
+      flowHref: () => '/review/42/flow',
+      entryHref: () => '/review/42',
+      navigate: (href) => seen.push(href),
+      back: () => seen.push('back'),
+    };
+    setNavigationDriver(driver);
+
+    expect(symbolHref('function:x')).toBe('/review/42/symbol/function%3Ax');
+    expect(fileHref('src/a.ts')).toBe('/review/42/file/src/a.ts');
+    expect(mapHref()).toBe('/review/42/map');
+    expect(flowHref()).toBe('/review/42/flow');
+
+    setNavigationDriver(null);
+    // Back to the viewer's own address space, unchanged.
+    expect(symbolHref('function:x')).toBe(hashNavigation.symbolHref('function:x'));
+    expect(symbolHref('function:x')).toBe('#/s/function%3Ax');
+  });
+
+  it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
+    const asked: string[] = [];
+    const adapter = createHttpAdapter({
+      fetch: async (input) => {
+        asked.push(String(input));
+        return new Response(JSON.stringify(STATS), {
+          status: 200,
+          headers: { 'content-type': 'application/json' },
+        });
+      },
+    });
+    await adapter.stats();
+    await adapter.node('function:parse@a.ts:1');
+    await adapter.source({ file: 'src/a.ts', from: 1, to: 4 });
+    await adapter.nodes(['a', 'b']);
+
+    expect(asked[0]).toBe('api/stats');
+    // Ids are encoded per slash-separated segment, so ':' survives and '/' is
+    // still a path separator.
+    expect(asked[1]).toBe('api/node/function%3Aparse%40a.ts%3A1');
+    expect(asked[2]).toBe('api/source?file=src%2Fa.ts&from=1&to=4');
+    // Repeated `id` params, never a comma-joined list.
+    expect(asked[3]).toBe('api/nodes?id=a&id=b');
+  });
+
+  it('an adapter with no live channel never connects and never polls', () => {
+    const { adapter } = mockAdapter();
+    setGraphAdapter(adapter);
+    expect(adapter.events).toBeUndefined();
+    // `live.start()` is a no-op in a jsdom test that never called it; what is
+    // asserted here is the counters a host can still drive by hand.
+    const before = live.indexTick;
+    live.signal('index', { index: { lastIndexedAt: 1, files: 3 } });
+    expect(live.indexTick).toBe(before + 1);
+  });
+});
+
+describe('@colbymchenry/codegraph-ui — the published shape', () => {
+  const manifest = JSON.parse(
+    readFileSync(join(ROOT, 'ui', 'package.json'), 'utf8')
+  ) as Record<string, any>;
+
+  it('is versioned with the engine', () => {
+    const engine = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as {
+      version: string;
+    };
+    expect(manifest.version).toBe(engine.version);
+  });
+
+  it('is named, scoped and not publishable by accident', () => {
+    expect(manifest.name).toBe('@colbymchenry/codegraph-ui');
+    // The package is PREPARED, not published (CG-61). `private` is the guard:
+    // npm refuses to publish it until the maintainer deliberately removes this.
+    expect(manifest.private).toBe(true);
+  });
+
+  it('exports the entry, the theme and nothing else', () => {
+    expect(Object.keys(manifest.exports).sort()).toEqual(['.', './package.json', './theme.css']);
+    expect(manifest.exports['.'].svelte).toBe('./dist/index.js');
+    expect(manifest.exports['.'].types).toBe('./dist/index.d.ts');
+  });
+
+  it('takes svelte as a peer, so a host never gets a second copy', () => {
+    expect(manifest.peerDependencies.svelte).toBeDefined();
+    expect(manifest.dependencies?.svelte).toBeUndefined();
+    // The canvas library is a real dependency: the Map and the Flow strip are
+    // unusable without it and a host must not have to know its version.
+    expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
+  });
+});

+ 21 - 1
docs/design/codegraph-ui-design-spec.md

@@ -334,7 +334,7 @@ with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas).
     roughly double the token count on a dense line.
   - The classification is a class NAME, never a colour, and the viewer paints it from the CSS custom properties above — so **one
     token stream serves light and dark** with no refetch when `prefers-color-scheme` flips, and the ramp lives only in
-    `ui/src/app.css`. `type` is a distinct class painted at plain ink: the colouring is near-monochrome and a type name is not one
+    `ui/src/lib/theme.css`. `type` is a distinct class painted at plain ink: the colouring is near-monochrome and a type name is not one
     of the four things it moves off plain ink.
   - Every code token is split into identifier runs before it goes on the wire, so the graph's call-site overlay claims a token the
     classifier produced rather than re-cutting a line — which is what keeps a link landing on the callee's own name whatever
@@ -350,6 +350,26 @@ with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas).
 - No native modules; no runtime dependency for the UI itself; the CLI serves **`dist/viewer/`** over `node:http`, loopback only.
   (Not `dist/ui/` — `src/ui/` is the engine's *terminal* ui and tsc already compiles it there; see `ui/README.md`.)
 
+### 4.1 The component library (`@colbymchenry/codegraph-ui`, CG-61)
+The same `ui/src` tree builds a second way — `svelte-package` into `ui/dist` — so CodeGraph Pro renders the Symbol view, the Flow
+strip and the Map over its own in-process engine reads without forking a component. One tree, because a fork is a second answer to
+the same question about the same graph.
+- **One seam: `GraphAdapter`** (`ui/src/lib/adapter.ts`) — eleven methods answering the `Wire*` shapes verbatim. `createHttpAdapter()`
+  is the loopback JSON API and is what the CLI's viewer runs on; a host implements the same methods and never makes a request.
+  The shapes live in `ui/src/lib/wire.ts`, which has no imports and no runtime, so a host can depend on the vocabulary alone.
+  `scripts/check-ui-package.mjs` asserts that nothing in the built package but `lib/adapter.js` reaches the network.
+- **`events` is optional.** No live channel means nothing connects and nothing polls; a host that learns of a sync some other way
+  calls `live.signal('index')`, the same code path the stream uses.
+- **Navigation is a driver, not a callback** (`ui/src/lib/navigation.ts`): the components build hrefs, because middle-click and
+  "copy link address" are how people read code. The default is the viewer's hash space; a host installs its own URL space. The
+  app's half — the hash parser and the live route — attaches window listeners at module scope and is **pruned out of the package**.
+- **Theming is colour and type only.** `theme.css` carries the §2.1 tokens and maps Svelte Flow's `--xy-*` variables onto them, so a
+  host never sees library defaults in the pane, controls or minimap. Geometry (34px rail rows, the 300/320px rails, the 20px code
+  line) is not themable: the Symbol view measures those against each other to put a callee row beside the line that calls it.
+- Versioned with the engine (`scripts/sync-ui-version.mjs`), because the payload shapes are versioned with the binary that serves
+  them. **Prepared, not published**: `"private": true` is the guard and `scripts/pack-npm.sh` only packs it under
+  `CODEGRAPH_PACK_UI=1`.
+
 ## 5. Copy rules
 Sentence case; controls say what happens ("Read as flow", "Clear"); counts always visible next to folds; honesty phrases fixed:
 "No test reaches this within 3 caller hops", "Reached by tests · N files within 3 hops", "Uncertain · N name-only matches, confidence < 0.6",

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 662 - 56
package-lock.json


+ 3 - 0
package.json

@@ -22,6 +22,7 @@
   "scripts": {
     "build": "tsc && npm run copy-assets && npm run build:ui && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
     "build:ui": "npm run build --workspace ui && node scripts/check-ui-build.mjs",
+    "build:lib": "npm run build:lib --workspace ui",
     "preuninstall": "node dist/bin/uninstall.js",
     "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
     "dev": "tsc --watch",
@@ -53,9 +54,11 @@
     "web-tree-sitter": "^0.25.3"
   },
   "devDependencies": {
+    "@sveltejs/vite-plugin-svelte": "^4.0.4",
     "@types/better-sqlite3": "^7.6.0",
     "@types/node": "^20.19.30",
     "@types/picomatch": "^4.0.2",
+    "jsdom": "^25.0.1",
     "typescript": "^5.0.0",
     "vitest": "^2.1.9"
   },

+ 189 - 0
scripts/check-ui-package.mjs

@@ -0,0 +1,189 @@
+#!/usr/bin/env node
+/**
+ * Finish and verify the `@colbymchenry/codegraph-ui` build (task CG-61).
+ *
+ * `svelte-package` compiles the whole of `ui/src`, which is the right input —
+ * the components a host imports and the ones `codegraph ui` renders are the
+ * same files, and splitting them into two trees is how the two screens start
+ * to drift. But it means the emitted `dist/` also carries the standalone app's
+ * shell, and one of those files is a hazard rather than dead weight:
+ * `lib/router.svelte.js` attaches `hashchange`/`popstate` listeners at module
+ * scope. A host must never inherit a hash router just by rendering a Symbol
+ * view. So this script does three jobs, in order:
+ *
+ *   1. PRUNE the app-only files from the package.
+ *   2. RESOLVE the extensionless relative specifiers `svelte-package` leaves
+ *      behind, so the package works under Node's own ESM resolution and under
+ *      a consumer on `moduleResolution: node16`, not only inside a bundler.
+ *   3. ASSERT the result: the entry, the theme, every path in `exports`, the
+ *      five named components, and — the one that matters most — that nothing
+ *      outside `lib/adapter.js` talks to the network. The whole point of the
+ *      package is that a host's own adapter is the only way data arrives; a
+ *      stray `fetch` anywhere else is a screen that ignores it.
+ *
+ * Run by `npm run build:lib -w ui`. Exits non-zero on any failure.
+ */
+
+import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const UI = fileURLToPath(new URL('../ui', import.meta.url));
+const DIST = join(UI, 'dist');
+
+/**
+ * The standalone viewer's shell — everything that is only reachable from
+ * `main.ts`. Listed by hand rather than derived, because getting it wrong in
+ * the derived direction (pruning something a component needs) is silent until
+ * a host imports it.
+ */
+const APP_ONLY = [
+  'main.js',
+  'main.d.ts',
+  'App.svelte',
+  'App.svelte.d.ts',
+  'app.css',
+  'components/TopBar.svelte',
+  'components/TopBar.svelte.d.ts',
+  'lib/router.svelte.js',
+  'lib/router.svelte.d.ts',
+];
+
+/** Extensions that already resolve; anything else is rewritten to `<spec>.js`. */
+const RESOLVES = ['.js', '.mjs', '.cjs', '.json', '.css', '.svg', '.png'];
+
+const fail = (message) => {
+  console.error(`[check-ui-package] ${message}`);
+  process.exitCode = 1;
+};
+
+if (!existsSync(DIST)) {
+  fail(`no ${relative(UI, DIST)} — run \`npm run build:lib -w ui\``);
+  process.exit(1);
+}
+
+/* ------------------------------------------------------------------ 1. prune */
+
+for (const entry of APP_ONLY) {
+  const path = join(DIST, entry);
+  if (existsSync(path)) rmSync(path, { recursive: true });
+}
+
+/* ------------------------------------------------------------------ walk it */
+
+function* files(dir) {
+  for (const name of readdirSync(dir)) {
+    const path = join(dir, name);
+    if (statSync(path).isDirectory()) yield* files(path);
+    else yield path;
+  }
+}
+
+const all = [...files(DIST)];
+
+/* ---------------------------------------------------------------- 2. resolve */
+
+/**
+ * `from './lib/adapter'` -> `from './lib/adapter.js'`, and
+ * `from './lib/trail.svelte'` -> `from './lib/trail.svelte.js'` (the emitted
+ * file for a `.svelte.ts` rune module).
+ *
+ * Driven by the filesystem rather than by the extension alone: `.svelte` is a
+ * real file for a component and a compiled `.js` for a rune module, and only
+ * looking is right for both.
+ */
+function resolveSpecifiers(source, fromFile) {
+  return source.replace(
+    /(\bfrom\s*|\bimport\s*\(\s*)(['"])(\.[^'"]*)\2/g,
+    (match, head, quote, spec) => {
+      if (RESOLVES.some((ext) => spec.endsWith(ext))) return match;
+      const target = resolve(dirname(fromFile), spec);
+      if (existsSync(target) && statSync(target).isFile()) return match;
+      if (!existsSync(`${target}.js`)) return match;
+      return `${head}${quote}${spec}.js${quote}`;
+    }
+  );
+}
+
+let rewritten = 0;
+for (const path of all) {
+  if (!/\.(js|d\.ts|svelte)$/.test(path)) continue;
+  const before = readFileSync(path, 'utf8');
+  const after = resolveSpecifiers(before, path);
+  if (after !== before) {
+    writeFileSync(path, after);
+    rewritten += 1;
+  }
+}
+
+/* ----------------------------------------------------------------- 3. assert */
+
+const manifest = JSON.parse(readFileSync(join(UI, 'package.json'), 'utf8'));
+
+// Every path the exports map promises has to be there. A missing one is a
+// package that installs cleanly and then fails at the consumer's first import.
+for (const [name, entry] of Object.entries(manifest.exports ?? {})) {
+  const targets = typeof entry === 'string' ? [entry] : Object.values(entry);
+  for (const target of targets) {
+    if (!target.startsWith('./')) continue;
+    if (!existsSync(join(UI, target))) fail(`exports["${name}"] -> ${target} is missing`);
+  }
+}
+
+// The five components the task names, plus the two seams they are useless
+// without. Checked in the emitted JS, so a rename in index.ts that misses a
+// component fails here rather than in the Pro app.
+const entry = existsSync(join(DIST, 'index.js'))
+  ? readFileSync(join(DIST, 'index.js'), 'utf8')
+  : '';
+for (const name of [
+  'SymbolView',
+  'FlowStrip',
+  'ArchitectureMap',
+  'TrailBar',
+  'SearchPalette',
+  'CodegraphUi',
+  'setGraphAdapter',
+  'createHttpAdapter',
+  'setNavigationDriver',
+]) {
+  if (!new RegExp(`\\b${name}\\b`).test(entry)) fail(`dist/index.js does not export ${name}`);
+}
+
+// Nothing the app dragged in survives. A component still importing one of the
+// pruned modules would resolve to nothing in a host.
+for (const path of all) {
+  if (!existsSync(path)) continue;
+  const text = readFileSync(path, 'utf8');
+  for (const pruned of ['router.svelte', 'TopBar.svelte', 'app.css']) {
+    const importing = new RegExp(`(from|import\\()\\s*['"][^'"]*${pruned}`);
+    if (importing.test(text)) {
+      fail(`${relative(DIST, path)} still imports ${pruned}, which is app-only`);
+    }
+  }
+}
+
+// The data seam. `lib/adapter.js` is the ONE place that may reach the network;
+// anywhere else means a screen that ignores the host's adapter.
+for (const path of all) {
+  if (!existsSync(path) || !path.endsWith('.js')) continue;
+  if (path.endsWith(join('lib', 'adapter.js'))) continue;
+  const text = readFileSync(path, 'utf8')
+    // Comments talk about `fetch` and `EventSource` on purpose; only code counts.
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/(^|\s)\/\/[^\n]*/g, '');
+  if (/\bnew EventSource\b|\bfetch\s*\(/.test(text)) {
+    fail(`${relative(DIST, path)} reaches the network directly — it must go through the adapter`);
+  }
+}
+
+if (process.exitCode) {
+  console.error('[check-ui-package] FAILED');
+  process.exit(1);
+}
+
+const count = [...files(DIST)].length;
+console.log(
+  `[check-ui-package] ok — ${count} files, ${rewritten} rewritten, ` +
+    `${APP_ONLY.length} app-only pruned (v${manifest.version})`
+);

+ 26 - 0
scripts/pack-npm.sh

@@ -125,3 +125,29 @@ VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \
 
 echo "[pack-npm] ${SCOPE}/codegraph@${VERSION} (${#targets[@]} platform packages in optionalDependencies)"
 echo "[pack-npm] output: $NPM"
+
+# ---------------------------------------------------------------------------
+# @colbymchenry/codegraph-ui — the viewer's components as a Svelte library.
+#
+# Staged into release/npm-ui/, NOT release/npm/: the workflow publishes
+# `release/npm/codegraph-*` by glob, and a directory named codegraph-ui in
+# there would be swept into that loop the moment it existed.
+#
+# OFF by default. The package is prepared, versioned with the engine and
+# tested (CG-61), but publishing it is a decision the maintainer has not
+# made — and `ui/package.json` still carries `"private": true`, which is what
+# actually stops an accidental `npm publish`. Set CODEGRAPH_PACK_UI=1 to build
+# the tarball; publishing it additionally means removing that flag.
+# ---------------------------------------------------------------------------
+if [ "${CODEGRAPH_PACK_UI:-0}" = "1" ]; then
+  UIREL="$REL/npm-ui"
+  rm -rf "$UIREL"
+  mkdir -p "$UIREL"
+  ( cd "$ROOT" && npm run build:lib --workspace ui )
+  # `npm pack` honours "files" and works on a private package; `npm publish`
+  # does not, which is exactly the guard we want to keep for now.
+  ( cd "$ROOT/ui" && npm pack --pack-destination "$UIREL" >/dev/null )
+  echo "[pack-npm] ${SCOPE}/codegraph-ui@${VERSION} packed (not published) -> $UIREL"
+else
+  echo "[pack-npm] skipping ${SCOPE}/codegraph-ui (set CODEGRAPH_PACK_UI=1 to pack it)"
+fi

+ 47 - 0
scripts/sync-ui-version.mjs

@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+/**
+ * Keep `@colbymchenry/codegraph-ui` on the engine's version number.
+ *
+ * The component package draws its screens from the engine's own JSON API, and
+ * that API is versioned with the binary that serves it — a payload field can
+ * appear or change shape in any engine release. So the two ship as one number:
+ * `@colbymchenry/codegraph-ui@1.6.0` is the reader for `codegraph@1.6.0`, and a
+ * host can pin them together without a compatibility table.
+ *
+ * This SYNCS rather than asserts, deliberately. The documented release flow is
+ * "edit the version in package.json, run the Release workflow" — often as a
+ * single-file edit in the GitHub web UI — and a check that failed the build
+ * because a second file had not been edited would turn that into a two-step
+ * dance for no gain. The same reasoning the workflow's package-lock sync step
+ * already runs on.
+ *
+ * Idempotent: a re-run with the versions already equal writes nothing.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const root = fileURLToPath(new URL('../package.json', import.meta.url));
+const ui = fileURLToPath(new URL('../ui/package.json', import.meta.url));
+
+const engineVersion = JSON.parse(readFileSync(root, 'utf8')).version;
+const raw = readFileSync(ui, 'utf8');
+const manifest = JSON.parse(raw);
+
+if (manifest.version === engineVersion) {
+  console.log(`[sync-ui-version] ui already at ${engineVersion}`);
+  process.exit(0);
+}
+
+// A targeted replacement, not a re-serialise: rewriting the whole file would
+// reformat a manifest a human maintains and bury the one-line change in noise.
+const next = raw.replace(
+  /("version"\s*:\s*)"[^"]*"/,
+  (_match, prefix) => `${prefix}"${engineVersion}"`
+);
+if (next === raw) {
+  console.error('[sync-ui-version] could not find a "version" field in ui/package.json');
+  process.exit(1);
+}
+writeFileSync(ui, next);
+console.log(`[sync-ui-version] ui ${manifest.version} -> ${engineVersion}`);

+ 124 - 7
ui/README.md

@@ -1,9 +1,20 @@
-# ui/ — the `codegraph ui` viewer
+# ui/ — the `codegraph ui` viewer, and `@colbymchenry/codegraph-ui`
 
-The browser reader for an indexed project: Svelte 5 + Vite, built as static
-files and served by the CLI over loopback. An npm workspace of the engine, so
-`npm ci` at the repo root installs its toolchain; nothing here is a runtime
-dependency of the engine and nothing here is published to npm on its own.
+One source tree, two builds.
+
+- **The app** — the browser reader for an indexed project: Svelte 5 + Vite,
+  built as static files into `../dist/viewer` and served by the CLI over
+  loopback.
+- **The library** — the same components, packaged with `svelte-package` into
+  `dist/` as `@colbymchenry/codegraph-ui`, so a host (CodeGraph Pro) renders
+  the Symbol view, the Flow strip and the Map over its **own** graph reads.
+
+They are one tree on purpose. A forked component is a second answer to the same
+question about the same graph, and sooner or later the two get quoted against
+each other in a review.
+
+An npm workspace of the engine, so `npm ci` at the repo root installs the
+toolchain for both.
 
 Design spec (every token, size and measurement):
 `../docs/design/codegraph-ui-design-spec.md`.
@@ -12,11 +23,16 @@ Design spec (every token, size and measurement):
 
 ```bash
 npm run build          # from the repo root: tsc -> copy-assets -> this app
-npm run build:ui       # just this app, plus the dist assertion
+npm run build:ui       # just the app, plus the dist assertion
+npm run build:lib      # the LIBRARY: svelte-package -> ui/dist, plus its checks
 npm run dev -w ui      # Vite dev server on 127.0.0.1:5174
 npm run check -w ui    # svelte-check
 ```
 
+`build:lib` is deliberately not part of `npm run build`: the CLI does not need
+it, and a release that fails because a component library would not compile is a
+release that failed for the wrong reason.
+
 `npm run build` emits **`dist/viewer/`** (`index.html` + hashed assets).
 `scripts/check-ui-build.mjs` then asserts the tree is complete, so a broken UI
 build fails the release instead of shipping a CLI that serves a 404. The same
@@ -32,12 +48,113 @@ and would also leave the static server handing out compiled engine internals.
 `check-ui-build.mjs` re-asserts the compiled engine is intact after every UI
 build so that mistake cannot land twice.
 
+## `@colbymchenry/codegraph-ui`
+
+```svelte
+<script lang="ts">
+  import { CodegraphUi, SymbolView, FlowStrip, ArchitectureMap }
+    from '@colbymchenry/codegraph-ui';
+  import '@colbymchenry/codegraph-ui/theme.css';
+</script>
+
+<CodegraphUi adapter={myAdapter} nav={myNavigation}>
+  <SymbolView id={symbolId} line={null} />
+</CodegraphUi>
+```
+
+Exports: `SymbolView`, `FlowStrip`, `ArchitectureMap`, `FileView`,
+`FileSourceView`, `EntryPointsView`, `TrailBar`, `SearchPalette`,
+`PalettePanel`, `PaletteRows`, `DriftBanner`, `KindGlyph`, `ExportButtons`,
+`CodegraphUi` — plus every pure model function the screens are built from
+(`buildCalleeRail`, `buildFlowLayout`, `buildMapLayout`, `tokensByLine`, …) and
+the `Wire*` types an adapter answers in.
+
+### The adapter is the only way data arrives
+
+```ts
+interface GraphAdapter {
+  stats(signal?): Promise<WireStats>;
+  search(query, opts?, signal?): Promise<WireSearch>;
+  node(id, signal?): Promise<WireSymbolPayload>;
+  nodes(ids, signal?): Promise<WireNodeRefs>;
+  source(request, signal?): Promise<WireSource>;
+  file(path, signal?): Promise<WireFilePayload>;
+  fileCode(path, signal?): Promise<WireFileCodePayload>;
+  flow(request, signal?): Promise<WireFlowPayload>;
+  map(request?, signal?): Promise<WireMapPayload>;
+  routes(request?, signal?): Promise<WireRoutes>;
+  entryPoints(request?, signal?): Promise<WireEntryPoints>;
+  events?(handlers): () => void;   // optional: the live channel
+}
+```
+
+The shapes are exactly what `src/ui-server/api/` serialises, and they live in
+`src/lib/wire.ts` — no imports, no runtime — so a host can depend on the
+vocabulary without depending on the viewer. The default implementation,
+`createHttpAdapter()`, is the loopback JSON API; a host that already holds the
+index implements the same eleven methods against its own reads and never makes
+an HTTP request. `scripts/check-ui-package.mjs` asserts that no module in the
+built package but `lib/adapter.js` touches the network, because a screen that
+reached past the adapter would be a screen that ignored the host.
+
+`events` is optional. Omit it and nothing connects and nothing polls; a host
+that learns about a sync some other way calls `live.signal('index')` instead,
+which is the same code path the stream uses.
+
+### Three things that will bite
+
+1. **Import `theme.css` once.** Every component paints from the design tokens.
+   Override any variable on a narrower selector — including on a container,
+   since custom properties inherit; `<CodegraphUi theme="light">` uses exactly
+   that to put a light reader inside a dark application.
+2. **The adapter and the navigation driver are module-level, not context.** The
+   pure model modules are plain TypeScript and cannot read a component's
+   context, so one page reads one project. `<CodegraphUi>` installs them during
+   initialisation, once — swapping projects means re-mounting the subtree
+   (`{#key project}`), not swapping the prop.
+3. **Geometry is not themable.** 34px rail rows, the 300/320px rails, the 20px
+   code line: the Symbol view measures these against each other to put a callee
+   row beside the line that calls it. Colour and type are yours.
+
+### Navigation
+
+Every link the components build goes through a `NavigationDriver`
+(`src/lib/navigation.ts`). The default is the viewer's own hash space
+(`#/s/<id>`); a host installs one that addresses its app instead, and the rails,
+breadcrumbs, chips and cards follow. They are hrefs rather than click handlers
+because middle-click, cmd-click and "copy link address" are how people read
+code.
+
+The app's half — parsing the hash, holding the live route — is
+`src/lib/router.svelte.ts`, which attaches `hashchange`/`popstate` listeners at
+module scope and is therefore **pruned out of the published package**. Nothing a
+host imports may drag a hash router into its application.
+
+### Versioning and publishing
+
+The package is versioned with the engine (`scripts/sync-ui-version.mjs` runs on
+every `build:lib`): `@colbymchenry/codegraph-ui@X.Y.Z` is the reader for
+`codegraph@X.Y.Z`, because the payload shapes are versioned with the binary that
+serves them.
+
+It is **prepared, not published.** `"private": true` in `package.json` is the
+guard — npm refuses to publish it — and `scripts/pack-npm.sh` only builds the
+tarball when `CODEGRAPH_PACK_UI=1`, into `release/npm-ui/` (never
+`release/npm/`, whose `codegraph-*` glob the release workflow publishes).
+Publishing is the maintainer's call and takes two deliberate edits.
+
 ## Layout
 
 ```
 src/
+  index.ts                the LIBRARY's entry — everything the package exports
   main.ts                 fonts + tokens, mounts App into index.html's #app
-  app.css                 design tokens (light/dark), reset, shell grid
+  app.css                 the app's reset, shell grid and primitives
+  lib/theme.css           the design tokens (light/dark) + the Svelte Flow map
+  lib/adapter.ts          GraphAdapter, createHttpAdapter, the registry
+  lib/wire.ts             every Wire* payload shape — types only, no runtime
+  lib/api.ts              the screens' calls, one line each, over the adapter
+  lib/navigation.ts       href builders + navigate, behind a driver
   App.svelte              top bar / trail bar / main, global keys
   lib/router.svelte.ts    hash router: #/s/<id>, #/file/<path>, #/map, #/flow, #/entry
   lib/trail.svelte.ts     the walked path; mirrored into the `t` query param

+ 40 - 4
ui/package.json

@@ -1,21 +1,57 @@
 {
-  "name": "codegraph-ui",
+  "name": "@colbymchenry/codegraph-ui",
   "private": true,
-  "version": "0.0.0",
+  "version": "1.6.0",
   "type": "module",
-  "description": "Browser viewer for an indexed CodeGraph project (served by `codegraph ui`).",
+  "description": "The CodeGraph reader as Svelte components: Symbol view, Flow strip and architecture Map behind one data adapter.",
+  "keywords": [
+    "codegraph",
+    "svelte",
+    "code-intelligence",
+    "knowledge-graph"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/colbymchenry/codegraph.git",
+    "directory": "ui"
+  },
   "license": "MIT",
+  "files": [
+    "dist",
+    "README.md"
+  ],
+  "svelte": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "sideEffects": [
+    "**/*.css"
+  ],
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "svelte": "./dist/index.js",
+      "default": "./dist/index.js"
+    },
+    "./theme.css": "./dist/lib/theme.css",
+    "./package.json": "./package.json"
+  },
   "scripts": {
     "build": "vite build",
+    "build:lib": "node ../scripts/sync-ui-version.mjs && svelte-package -i src -o dist && node ../scripts/check-ui-package.mjs",
     "dev": "vite",
     "preview": "vite preview",
     "check": "svelte-check --tsconfig ./tsconfig.json"
   },
+  "peerDependencies": {
+    "svelte": "^5.25.0"
+  },
+  "dependencies": {
+    "@xyflow/svelte": "^1.6.5"
+  },
   "devDependencies": {
     "@fontsource-variable/archivo": "^5.3.0",
     "@fontsource/ibm-plex-mono": "^5.3.0",
+    "@sveltejs/package": "^2.5.8",
     "@sveltejs/vite-plugin-svelte": "^6.2.4",
-    "@xyflow/svelte": "^1.6.5",
     "svelte": "^5.56.10",
     "svelte-check": "^4.7.6",
     "typescript": "^5.0.0",

+ 7 - 98
ui/src/app.css

@@ -1,107 +1,16 @@
 /* =====================================================================
-   codegraph ui — design tokens + global primitives
+   codegraph ui — the app's global primitives
 
-   The engine's paper/ink editorial system (site/src/styles/theme.css),
-   as specified in docs/design/codegraph-ui-design-spec.md §2: flat,
-   hairline rules, square corners everywhere, no shadows, no gradients,
-   sentence case, one oxblood accent, one amber (the "untested" badge).
+   The design tokens themselves live in `lib/theme.css`, which is also
+   what `@colbymchenry/codegraph-ui` exports for a host to import and
+   override. This file is everything ON TOP of them that only the
+   standalone viewer needs: the reset, the shell grid, and the handful of
+   primitives shared across views.
 
    Component-specific rules live in each .svelte file's scoped <style>.
-   Only tokens, resets and cross-view primitives belong here.
    ===================================================================== */
 
-/* ---------- tokens: light / paper (the bare :root set) ---------- */
-:root {
-  --paper: #f7f6f2;
-  --paper-2: #f1efe8;
-  --press: #e8e6dd;
-  --press-2: #dedbd0;
-  --ink: #16150f;
-  --ink-2: #56544a;
-  --ink-3: #87847a;
-  --ink-4: #b4b1a5;
-  --rule: #16150f;
-  --rule-soft: #d6d3c8;
-  --rule-faint: #e6e3d9;
-  --accent: #7a2230;
-  --accent-ink: #5e1a25;
-  --accent-soft: #f0e3e5;
-  --accent-line: #d9b3b9;
-  --amber: #8a5a0b;
-  --amber-soft: #f3e9d2;
-
-  /* The one code colour that is not a plain re-use of the ink ramp.
-     The spec asks for comments at --ink-3; measured against --paper that
-     is 3.46:1 and against the hot-line tint --accent-soft it is 3.00:1,
-     both under the 4.5:1 an AA reading of 12.5px body text needs. This is
-     the smallest step DOWN the same warm-grey ramp that clears 4.5:1 on
-     all three backgrounds a code line can have (paper 5.23, paper-2 4.92,
-     accent-soft 4.53) while staying quieter than --ink-2, which strings
-     and numbers use — so the recession order the spec describes is
-     unchanged, only legible. Dark needed the mirror step UP (4.51 on
-     accent-soft, where --ink-3 was 4.10). */
-  --code-comment: #6a675d;
-
-  --sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
-  --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
-  --code-size: 12.5px;
-  --code-lh: 20px;
-
-  /* App-shell geometry, shared by the grid and by anything that has to
-     offset itself under the bars (sticky rail headers, SVG overlays). */
-  --topbar-h: 48px;
-  --trailbar-h: 34px;
-
-  color-scheme: light dark;
-}
-
-/* ---------- tokens: dark / ink ----------
-   Every colour is defined on the bare :root above; these blocks only
-   redefine. `:not([data-theme="light"])` lets an explicit light choice
-   win over the OS preference. */
-@media (prefers-color-scheme: dark) {
-  :root:not([data-theme='light']) {
-    --paper: #16150f;
-    --paper-2: #1c1a14;
-    --press: #23211a;
-    --press-2: #2c2a22;
-    --ink: #f3f1ea;
-    --ink-2: #b8b5a8;
-    --ink-3: #87847a;
-    --ink-4: #5d5b52;
-    --rule: #f3f1ea;
-    --rule-soft: #34322a;
-    --rule-faint: #26241d;
-    --accent: #d48b96;
-    --accent-ink: #e5a5ae;
-    --accent-soft: #33201f;
-    --accent-line: #6b3a42;
-    --amber: #d9a94a;
-    --amber-soft: #2e2716;
-    --code-comment: #8e8b81;
-  }
-}
-
-:root[data-theme='dark'] {
-  --paper: #16150f;
-  --paper-2: #1c1a14;
-  --press: #23211a;
-  --press-2: #2c2a22;
-  --ink: #f3f1ea;
-  --ink-2: #b8b5a8;
-  --ink-3: #87847a;
-  --ink-4: #5d5b52;
-  --rule: #f3f1ea;
-  --rule-soft: #34322a;
-  --rule-faint: #26241d;
-  --accent: #d48b96;
-  --accent-ink: #e5a5ae;
-  --accent-soft: #33201f;
-  --accent-line: #6b3a42;
-  --amber: #d9a94a;
-  --amber-soft: #2e2716;
-  --code-comment: #8e8b81;
-}
+@import './lib/theme.css';
 
 /* ---------- reset ---------- */
 html,

+ 80 - 0
ui/src/components/CodegraphUi.svelte

@@ -0,0 +1,80 @@
+<script lang="ts">
+  /**
+   * The provider: installs the adapter and the navigation driver, then renders
+   * whatever the host puts inside it.
+   *
+   * It is a convenience, not a boundary. `setGraphAdapter` and
+   * `setNavigationDriver` are module-level (see `lib/adapter.ts` for why: the
+   * pure model modules are plain TypeScript and cannot read a component's
+   * context), so this component's whole job is to call them during
+   * initialisation — before any child's `$effect` has run and asked for data.
+   *
+   * That also means the last one to mount wins. A page shows one project; a
+   * host that needs two at once needs two documents, not two providers.
+   */
+  import { untrack, type Snippet } from 'svelte';
+  import { setGraphAdapter, type GraphAdapter } from '../lib/adapter';
+  import { setNavigationDriver, type NavigationDriver } from '../lib/navigation';
+
+  interface Props {
+    /** Where every screen's data comes from. Omit for the loopback JSON API. */
+    adapter?: GraphAdapter | null;
+    /** Where a click on a symbol, file, flow or module goes. Omit for `#/…`. */
+    nav?: NavigationDriver | null;
+    /**
+     * Force a colour scheme on this subtree.
+     *
+     * `'auto'` (the default) leaves it to the tokens, which follow the OS
+     * unless `:root[data-theme]` says otherwise. The other two set
+     * `data-theme` on this component's own wrapper, so a host can put a light
+     * reader inside a dark application without redefining a single variable.
+     */
+    theme?: 'auto' | 'light' | 'dark';
+    /** Fills the host's box by default; set false to size it yourself. */
+    fill?: boolean;
+    children?: Snippet;
+  }
+
+  let { adapter = null, nav = null, theme = 'auto', fill = true, children }: Props = $props();
+
+  // Init, not $effect: a child's data effect can run before the parent's, so
+  // installing these in an effect would let the first render ask the previous
+  // adapter — or the default HTTP one, against a host that serves no `/api`.
+  //
+  // Once only, and `untrack` says so. Swapping the adapter on a mounted tree
+  // would leave every screen holding answers from the old project until
+  // something happened to refetch; a host that changes project re-mounts the
+  // subtree instead (`{#key project}`), which is honest and one line.
+  setGraphAdapter(untrack(() => adapter));
+  setNavigationDriver(untrack(() => nav));
+</script>
+
+<div class="codegraph-ui" class:fill data-theme={theme === 'auto' ? undefined : theme}>
+  {@render children?.()}
+</div>
+
+<style>
+  /* The tokens are on :root (theme.css); this wrapper only re-establishes the
+     type and the paper, so a component dropped into a host with its own body
+     font does not inherit it. Geometry stays with the components. */
+  .codegraph-ui {
+    background: var(--paper);
+    color: var(--ink);
+    font-family: var(--sans);
+    font-size: 13px;
+    line-height: 1.45;
+  }
+
+  .fill {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+  }
+
+  /* Every screen fills the provider; the view scrolls, not the page. */
+  .fill > :global(*) {
+    flex: 1;
+    min-height: 0;
+  }
+</style>

+ 82 - 0
ui/src/components/PalettePanel.svelte

@@ -0,0 +1,82 @@
+<script lang="ts">
+  /**
+   * The results panel under the search box (design spec §3.7).
+   *
+   * It renders whatever `palette.view` is: the entry points when the box is
+   * empty, the ranked kind groups when it is not. The keyboard lives in
+   * `TopBar` (the keys are pressed in the input, not here) and arrives as the
+   * `selected` index; this component's only job beyond drawing is keeping that
+   * row in view when the selection moves past the panel's edge.
+   */
+  import PaletteRows from './PaletteRows.svelte';
+  import { palette } from '../lib/palette.svelte';
+  import type { PaletteItem } from '../lib/search-model';
+
+  interface Props {
+    onpick: (item: PaletteItem) => void;
+  }
+
+  let { onpick }: Props = $props();
+
+  let panel: HTMLDivElement | null = $state(null);
+  let view = $derived(palette.view);
+
+  $effect(() => {
+    const index = palette.selected;
+    if (!panel) return;
+    const row = panel.querySelector(`[data-palette-row="${index}"]`);
+    row?.scrollIntoView({ block: 'nearest' });
+  });
+</script>
+
+<div class="panel" bind:this={panel} id="palette-panel" role="listbox" aria-label="Search results">
+  {#if view.hint}
+    <p class="hint">{view.hint}</p>
+  {/if}
+
+  <PaletteRows
+    palette={view}
+    selected={palette.selected}
+    rowRole="option"
+    {onpick}
+    onhover={(index) => palette.select(index)}
+  />
+
+  {#if palette.failure}
+    <p class="note">{palette.failure}</p>
+  {:else if palette.pending && view.items.length === 0}
+    <p class="note">Searching…</p>
+  {:else if view.empty}
+    <p class="note">{view.empty}</p>
+  {/if}
+</div>
+
+<style>
+  .panel {
+    position: absolute;
+    z-index: 40;
+    top: 32px;
+    right: 0;
+    left: 0;
+    max-height: 420px;
+    overflow: auto;
+    background: var(--paper);
+    border: 1px solid var(--ink);
+  }
+
+  .hint {
+    margin: 0;
+    padding: 8px 10px;
+    border-bottom: 1px solid var(--rule-faint);
+    background: var(--paper-2);
+    color: var(--ink-2);
+    font-size: 12px;
+  }
+
+  .note {
+    margin: 0;
+    padding: 8px 10px;
+    color: var(--ink-3);
+    font-size: 12px;
+  }
+</style>

+ 154 - 54
ui/src/components/SearchPalette.svelte

@@ -1,82 +1,182 @@
 <script lang="ts">
   /**
-   * The results panel under the search box (design spec §3.7).
+   * The search box and its results panel — one component, because the keyboard
+   * is the point (design spec §3.7).
    *
-   * It renders whatever `palette.view` is: the entry points when the box is
-   * empty, the ranked kind groups when it is not. The keyboard lives in
-   * `TopBar` (the keys are pressed in the input, not here) and arrives as the
-   * `selected` index; this component's only job beyond drawing is keeping that
-   * row in view when the selection moves past the panel's edge.
+   * ↑/↓ move the selection and Enter follows it, and those keys are pressed in
+   * the INPUT, not in the panel. Splitting the two across a host's markup is
+   * what breaks a palette: the box ends up owning a selection index it has to
+   * hand down, and any host that forgets to wire one of the three keys ships a
+   * list you cannot use without a mouse. So the box, the keys and the panel
+   * travel together, and `PalettePanel` below is only the drawing half.
+   *
+   * `onpick` replaces what following a row DOES. Left unset, a row walks the
+   * graph through the installed navigation driver — which is the right default
+   * both for `codegraph ui` and for a host that installed one.
    */
-  import PaletteRows from './PaletteRows.svelte';
+  import PalettePanel from './PalettePanel.svelte';
   import { palette } from '../lib/palette.svelte';
   import type { PaletteItem } from '../lib/search-model';
+  import { fileHref, flowHref, navigate } from '../lib/navigation';
+  import { openEntryTarget, walkTo } from '../lib/walk';
 
   interface Props {
-    onpick: (item: PaletteItem) => void;
+    placeholder?: string;
+    /** Optional label for the input, when a host's own layout needs one. */
+    label?: string;
+    /** Replaces the default "walk the graph" behaviour of following a row. */
+    onpick?: (item: PaletteItem) => void;
+  }
+
+  let {
+    placeholder = 'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus',
+    label = 'Search symbols and files',
+    onpick,
+  }: Props = $props();
+
+  let input: HTMLInputElement | null = $state(null);
+  let box: HTMLDivElement | null = $state(null);
+
+  /** Focus and select the box — what `/` and Cmd-K reach. */
+  export function focus(): void {
+    input?.focus();
+    input?.select();
+    palette.show();
   }
 
-  let { onpick }: Props = $props();
+  /**
+   * Following a result is a `start` hop, never `down` or `up`: nothing on
+   * screen was stepped through to get there, and claiming a direction would
+   * put a `→` in the trail that describes no call.
+   */
+  export function pick(item: PaletteItem): void {
+    palette.reset();
+    input?.blur();
+    if (onpick) {
+      onpick(item);
+      return;
+    }
+    // A flow is not a place in the graph, so it does not join the trail: it is
+    // a question about two symbols, and the Flow strip answers it.
+    if (item.type === 'flow') {
+      navigate(flowHref({ from: item.from, to: item.to }));
+      return;
+    }
+    // An entry-point row already knows where it goes — a handler, a file, a
+    // hub — and it is the one row type that can point at a FILE.
+    if (item.type === 'entry') {
+      if (item.row.target) openEntryTarget(item.row.target);
+      return;
+    }
+    const id = item.type === 'route' ? item.nodeId : item.id;
+    // A route whose handler never resolved to a node has nowhere to go; the
+    // row stays, because "this URL exists and we could not place it" is true.
+    if (!id) return;
+    // A file result opens the File view, not the file node's Symbol view: the
+    // outline is there either way, and only the File view carries the import
+    // rails. (CG-45 routed these at the Symbol view because #/file was a stub.)
+    if (item.type === 'symbol' && item.node.kind === 'file') {
+      navigate(fileHref(item.node.file));
+      return;
+    }
+    walkTo(
+      item.type === 'route'
+        ? { id, name: item.handler, kind: null }
+        : { id, name: item.node.name, kind: item.node.kind },
+      'start'
+    );
+  }
 
-  let panel: HTMLDivElement | null = $state(null);
-  let view = $derived(palette.view);
+  function onkeydown(event: KeyboardEvent) {
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      palette.hide();
+      input?.blur();
+      return;
+    }
+    if (!palette.open) {
+      // Any other key means the box is being used again after a dismissal.
+      if (event.key !== 'Tab') palette.show();
+      return;
+    }
+    switch (event.key) {
+      case 'ArrowDown':
+        event.preventDefault();
+        palette.move(1);
+        break;
+      case 'ArrowUp':
+        event.preventDefault();
+        palette.move(-1);
+        break;
+      case 'Enter': {
+        event.preventDefault();
+        const item = palette.selectedItem;
+        if (item) pick(item);
+        break;
+      }
+    }
+  }
 
-  $effect(() => {
-    const index = palette.selected;
-    if (!panel) return;
-    const row = panel.querySelector(`[data-palette-row="${index}"]`);
-    row?.scrollIntoView({ block: 'nearest' });
-  });
+  /**
+   * A click anywhere else closes the panel. `mousedown` on a row calls
+   * `preventDefault`, so picking a result never races this.
+   */
+  function onpointerdown(event: PointerEvent) {
+    if (!palette.open) return;
+    const target = event.target;
+    if (target instanceof Node && box?.contains(target)) return;
+    palette.hide();
+  }
 </script>
 
-<div class="panel" bind:this={panel} id="palette-panel" role="listbox" aria-label="Search results">
-  {#if view.hint}
-    <p class="hint">{view.hint}</p>
-  {/if}
+<svelte:window {onpointerdown} />
 
-  <PaletteRows
-    palette={view}
-    selected={palette.selected}
-    rowRole="option"
-    {onpick}
-    onhover={(index) => palette.select(index)}
+<div class="search" role="search" bind:this={box}>
+  <input
+    bind:this={input}
+    bind:value={palette.query}
+    {onkeydown}
+    onfocus={() => palette.show()}
+    id="q"
+    type="search"
+    autocomplete="off"
+    spellcheck="false"
+    {placeholder}
+    aria-label={label}
+    role="combobox"
+    aria-expanded={palette.open}
+    aria-controls="palette-panel"
+    aria-autocomplete="list"
+    aria-activedescendant={palette.open ? `palette-row-${palette.selected}` : undefined}
   />
-
-  {#if palette.failure}
-    <p class="note">{palette.failure}</p>
-  {:else if palette.pending && view.items.length === 0}
-    <p class="note">Searching…</p>
-  {:else if view.empty}
-    <p class="note">{view.empty}</p>
+  {#if palette.open}
+    <PalettePanel onpick={pick} />
   {/if}
 </div>
 
 <style>
-  .panel {
-    position: absolute;
-    z-index: 40;
-    top: 32px;
-    right: 0;
-    left: 0;
-    max-height: 420px;
-    overflow: auto;
-    background: var(--paper);
-    border: 1px solid var(--ink);
+  .search {
+    position: relative;
+    width: 100%;
+    max-width: 720px;
   }
 
-  .hint {
-    margin: 0;
-    padding: 8px 10px;
-    border-bottom: 1px solid var(--rule-faint);
+  #q {
+    width: 100%;
+    height: 30px;
+    padding: 0 10px;
+    border: 1px solid var(--rule-soft);
     background: var(--paper-2);
-    color: var(--ink-2);
-    font-size: 12px;
+    color: var(--ink);
+    font: 13px var(--sans);
+  }
+
+  #q:focus {
+    border-color: var(--ink);
+    outline: none;
   }
 
-  .note {
-    margin: 0;
-    padding: 8px 10px;
+  #q::placeholder {
     color: var(--ink-3);
-    font-size: 12px;
   }
 </style>

+ 5 - 151
ui/src/components/TopBar.svelte

@@ -1,18 +1,7 @@
 <script lang="ts">
-  import {
-    router,
-    mapHref,
-    flowHref,
-    entryHref,
-    symbolHref,
-    fileHref,
-    navigate,
-  } from '../lib/router.svelte';
+  import { router, mapHref, flowHref, entryHref, symbolHref } from '../lib/router.svelte';
   import { trail } from '../lib/trail.svelte';
-  import { palette } from '../lib/palette.svelte';
   import SearchPalette from './SearchPalette.svelte';
-  import type { PaletteItem } from '../lib/search-model';
-  import { openEntryTarget, walkTo } from '../lib/walk';
   import { live } from '../lib/live.svelte';
 
   interface Props {
@@ -24,7 +13,7 @@
 
   let { project = null, stats = null }: Props = $props();
 
-  let input: HTMLInputElement | null = $state(null);
+  let search: SearchPalette | null = $state(null);
 
   let view = $derived(router.route.view);
 
@@ -37,99 +26,11 @@
     return current ? symbolHref(current.id) : '#/';
   });
 
+  /** What `/` and Cmd-K reach — the palette owns its own keyboard. */
   export function focusSearch(): void {
-    input?.focus();
-    input?.select();
-    palette.show();
+    search?.focus();
   }
 
-  /**
-   * Following a result is a `start` hop, never `down` or `up`: nothing on
-   * screen was stepped through to get there, and claiming a direction would
-   * put a `→` in the trail that describes no call.
-   */
-  export function pick(item: PaletteItem): void {
-    // A flow is not a place in the graph, so it does not join the trail: it is
-    // a question about two symbols, and the Flow view answers it.
-    if (item.type === 'flow') {
-      palette.reset();
-      input?.blur();
-      navigate(flowHref({ from: item.from, to: item.to }));
-      return;
-    }
-    // An entry-point row already knows where it goes — a handler, a file, a
-    // hub — and it is the one row type that can point at a FILE.
-    if (item.type === 'entry') {
-      if (!item.row.target) return;
-      palette.reset();
-      input?.blur();
-      openEntryTarget(item.row.target);
-      return;
-    }
-    const id = item.type === 'route' ? item.nodeId : item.id;
-    // A route whose handler never resolved to a node has nowhere to go; the
-    // row stays, because "this URL exists and we could not place it" is true.
-    if (!id) return;
-    palette.reset();
-    input?.blur();
-    // A file result opens the File view, not the file node's Symbol view: the
-    // outline is there either way, and only the File view carries the import
-    // rails. (CG-45 routed these at the Symbol view because #/file was a stub.)
-    if (item.type === 'symbol' && item.node.kind === 'file') {
-      navigate(fileHref(item.node.file));
-      return;
-    }
-    walkTo(
-      item.type === 'route'
-        ? { id, name: item.handler, kind: null }
-        : { id, name: item.node.name, kind: item.node.kind },
-      'start'
-    );
-  }
-
-  function onkeydown(event: KeyboardEvent) {
-    if (event.key === 'Escape') {
-      event.preventDefault();
-      palette.hide();
-      input?.blur();
-      return;
-    }
-    if (!palette.open) {
-      // Any other key means the box is being used again after a dismissal.
-      if (event.key !== 'Tab') palette.show();
-      return;
-    }
-    switch (event.key) {
-      case 'ArrowDown':
-        event.preventDefault();
-        palette.move(1);
-        break;
-      case 'ArrowUp':
-        event.preventDefault();
-        palette.move(-1);
-        break;
-      case 'Enter': {
-        event.preventDefault();
-        const item = palette.selectedItem;
-        if (item) pick(item);
-        break;
-      }
-    }
-  }
-
-  /**
-   * A click anywhere else closes the panel. `mousedown` on a row calls
-   * `preventDefault`, so picking a result never races this.
-   */
-  function onpointerdown(event: PointerEvent) {
-    if (!palette.open) return;
-    const target = event.target;
-    if (target instanceof Node && searchBox?.contains(target)) return;
-    palette.hide();
-  }
-
-  let searchBox: HTMLDivElement | null = $state(null);
-
   /**
    * Why this page has stopped updating itself, when it has.
    *
@@ -156,8 +57,6 @@
   });
 </script>
 
-<svelte:window {onpointerdown} />
-
 <header class="topbar">
   <a class="brand" href="#/" aria-label="CodeGraph home">
     <span class="brand-mark" aria-hidden="true"></span>
@@ -172,28 +71,7 @@
     <a href={flowHref()} class:active={view === 'flow'}>Flow</a>
   </nav>
 
-  <div class="search" role="search" bind:this={searchBox}>
-    <input
-      bind:this={input}
-      bind:value={palette.query}
-      {onkeydown}
-      onfocus={() => palette.show()}
-      id="q"
-      type="search"
-      autocomplete="off"
-      spellcheck="false"
-      placeholder={'Search a symbol or file, or ask “how does execute reach getFile” — press / to focus'}
-      aria-label="Search symbols and files"
-      role="combobox"
-      aria-expanded={palette.open}
-      aria-controls="palette-panel"
-      aria-autocomplete="list"
-      aria-activedescendant={palette.open ? `palette-row-${palette.selected}` : undefined}
-    />
-    {#if palette.open}
-      <SearchPalette onpick={pick} />
-    {/if}
-  </div>
+  <SearchPalette bind:this={search} />
 
   <div class="project" title="Indexed project">
     {#if liveNote}<span class="offline" title={liveNote.title}>{liveNote.text}</span>{/if}
@@ -261,30 +139,6 @@
     border-bottom-color: var(--ink);
   }
 
-  .search {
-    position: relative;
-    max-width: 720px;
-  }
-
-  #q {
-    width: 100%;
-    height: 30px;
-    padding: 0 10px;
-    border: 1px solid var(--rule-soft);
-    background: var(--paper-2);
-    color: var(--ink);
-    font: 13px var(--sans);
-  }
-
-  #q:focus {
-    border-color: var(--ink);
-    outline: none;
-  }
-
-  #q::placeholder {
-    color: var(--ink-3);
-  }
-
   .project {
     color: var(--ink-2);
     font-size: 12px;

+ 1 - 1
ui/src/components/TrailBar.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
   import KindGlyph from './KindGlyph.svelte';
   import { trail, hopLabel, encodeTrail } from '../lib/trail.svelte';
-  import { navigate, symbolHref, flowHref } from '../lib/router.svelte';
+  import { navigate, symbolHref, flowHref } from '../lib/navigation';
 
   let hops = $derived(trail.hops);
 

+ 1 - 1
ui/src/components/entry/EntrySection.svelte

@@ -14,7 +14,7 @@
 -->
 <script lang="ts">
   import KindGlyph from '../KindGlyph.svelte';
-  import { fileHref } from '../../lib/router.svelte';
+  import { fileHref } from '../../lib/navigation';
   import type { EntryRow, EntrySection } from '../../lib/entry-model';
 
   interface Props {

+ 1 - 1
ui/src/components/file/FileModeTabs.svelte

@@ -6,7 +6,7 @@
   the same mode, and `?src=1` is how it travels.
 -->
 <script lang="ts">
-  import { fileHref } from '../../lib/router.svelte';
+  import { fileHref } from '../../lib/navigation';
 
   interface Props {
     path: string;

+ 1 - 1
ui/src/components/file/FileRail.svelte

@@ -14,7 +14,7 @@
   read as broken.
 -->
 <script lang="ts">
-  import { fileHref } from '../../lib/router.svelte';
+  import { fileHref } from '../../lib/navigation';
   import { plural } from '../../lib/symbol-model';
   import type { FileRailModel, FileRailRow } from '../../lib/file-model';
 

+ 1 - 1
ui/src/components/map/MapSidePanel.svelte

@@ -12,7 +12,7 @@
 -->
 <script lang="ts">
   import ExportButtons from '../ExportButtons.svelte';
-  import { fileHref } from '../../lib/router.svelte';
+  import { fileHref } from '../../lib/navigation';
   import { plural } from '../../lib/symbol-model';
   import type { WireMapLink, WireMapPayload } from '../../lib/api';
   import type { MapLayout } from '../../lib/map-model';

+ 1 - 1
ui/src/components/symbol/BlastStrip.svelte

@@ -12,7 +12,7 @@
   is a drawing bug, and clamping silently would be a lie about the comparison.
 -->
 <script lang="ts">
-  import { fileHref } from '../../lib/router.svelte';
+  import { fileHref } from '../../lib/navigation';
   import { plural } from '../../lib/symbol-model';
   import type { WireBlastScale, WireBlastSummary } from '../../lib/api';
 

+ 1 - 1
ui/src/components/symbol/CallersRail.svelte

@@ -13,7 +13,7 @@
 -->
 <script lang="ts">
   import KindGlyph from '../KindGlyph.svelte';
-  import { fileHref } from '../../lib/router.svelte';
+  import { fileHref } from '../../lib/navigation';
   import { hot, railFocus } from '../../lib/focus.svelte';
   import { basename, plural, type CallerRailModel, type CallerRow } from '../../lib/symbol-model';
   import type { WireNodeRef } from '../../lib/api';

+ 1 - 1
ui/src/components/symbol/SymbolHeader.svelte

@@ -10,7 +10,7 @@
 -->
 <script lang="ts">
   import KindGlyph from '../KindGlyph.svelte';
-  import { fileHref } from '../../lib/router.svelte';
+  import { fileHref } from '../../lib/navigation';
   import { kindPhrase, plural } from '../../lib/symbol-model';
   import type {
     WireNodeDetail,

+ 221 - 0
ui/src/index.ts

@@ -0,0 +1,221 @@
+/**
+ * `@colbymchenry/codegraph-ui` — the CodeGraph reader as Svelte components.
+ *
+ * The same Symbol view, Flow strip and Map that `codegraph ui` serves, behind
+ * one seam: a {@link GraphAdapter}. The CLI's viewer runs them on
+ * {@link createHttpAdapter} (the read-only JSON API over loopback); a host that
+ * already holds the index — CodeGraph Pro, which opens it in-process — installs
+ * its own adapter and renders the identical components over its own reads.
+ * Nothing is forked, so the two can never draw different answers from the same
+ * graph.
+ *
+ * ```svelte
+ * <script>
+ *   import { CodegraphUi, SymbolView, FlowStrip, ArchitectureMap }
+ *     from '@colbymchenry/codegraph-ui';
+ *   import '@colbymchenry/codegraph-ui/theme.css';
+ * </script>
+ *
+ * <CodegraphUi adapter={myAdapter} nav={myNavigation}>
+ *   <SymbolView id={symbolId} line={null} />
+ * </CodegraphUi>
+ * ```
+ *
+ * Three things a host has to know, all of them in the docs and repeated here
+ * because they are the ones that bite:
+ *
+ * 1. **Import `theme.css` once.** Every component paints from the design
+ *    tokens; without them the screens render as unstyled ink on white. Override
+ *    any variable on a narrower selector.
+ * 2. **The adapter is module-level, not context.** The pure model modules are
+ *    plain TypeScript and cannot read a component's context, so one page reads
+ *    one project. `<CodegraphUi>` installs it during initialisation.
+ * 3. **Geometry is not themable.** 34px rail rows, 300/320px rails, the 20px
+ *    code line: the Symbol view measures these against each other to put a
+ *    callee row beside the line that calls it. Colour and type are yours.
+ */
+
+/* ------------------------------------------------------------ the seams -- */
+
+export { default as CodegraphUi } from './components/CodegraphUi.svelte';
+
+export {
+  ApiFailure,
+  createHttpAdapter,
+  getGraphAdapter,
+  setGraphAdapter,
+} from './lib/adapter';
+export type {
+  EntryPointsRequest,
+  FlowRequest,
+  GraphAdapter,
+  HttpAdapterOptions,
+  LiveHandlers,
+  MapRequest,
+  RoutesRequest,
+  SearchRequest,
+  SourceRequest,
+} from './lib/adapter';
+
+export {
+  back,
+  entryHref,
+  fileHref,
+  flowHref,
+  getNavigationDriver,
+  hashNavigation,
+  mapHref,
+  navigate,
+  setNavigationDriver,
+  symbolHref,
+} from './lib/navigation';
+export type {
+  FileHrefOptions,
+  FlowHrefOptions,
+  MapHrefOptions,
+  NavigationDriver,
+  SymbolHrefOptions,
+} from './lib/navigation';
+
+/** The wire vocabulary an adapter answers in. Types only — no runtime. */
+export * from './lib/wire';
+
+/* ----------------------------------------------------------- the screens -- */
+
+/** Callers | verbatim source with gutter ports | line-anchored callee rail. */
+export { default as SymbolView } from './views/SymbolView.svelte';
+/** How one symbol reaches another, one card per hop, opened at the call line. */
+export { default as FlowStrip } from './views/FlowView.svelte';
+/** The repository at module granularity, layered so dependencies point down. */
+export { default as ArchitectureMap } from './views/MapView.svelte';
+/** One file: the outline in source order between two dependency rails. */
+export { default as FileView } from './views/FileView.svelte';
+/** One file's whole source, with gutter ports and intra-file call arcs. */
+export { default as FileSourceView } from './views/FileCodeView.svelte';
+/** Where a reader starts: routes, files that run something, tests, hubs. */
+export { default as EntryPointsView } from './views/EntryView.svelte';
+
+/* -------------------------------------------------------- the furniture -- */
+
+/** The path walked, with its arrows and its "read as flow". */
+export { default as TrailBar } from './components/TrailBar.svelte';
+/** The search box, its keyboard and its results panel — one component. */
+export { default as SearchPalette } from './components/SearchPalette.svelte';
+/** The results panel alone, for a host that owns the input. */
+export { default as PalettePanel } from './components/PalettePanel.svelte';
+/** The rows inside the panel, for a host that owns the whole shell. */
+export { default as PaletteRows } from './components/PaletteRows.svelte';
+/** "This file changed on disk since it was indexed." */
+export { default as DriftBanner } from './components/DriftBanner.svelte';
+/** The one-letter square that stands for a symbol's kind. */
+export { default as KindGlyph } from './components/KindGlyph.svelte';
+/** Copy image / download SVG for a Flow strip or a Map layout. */
+export { default as ExportButtons } from './components/ExportButtons.svelte';
+
+/* ------------------------------------------------------------- the state -- */
+
+export { trail, resolveTrailNames } from './lib/trail.svelte';
+export { encodeTrail, decodeTrail, hopLabel } from './lib/trail-codec';
+export type { HopDirection, TrailHop } from './lib/trail-codec';
+export { live, liveRefresh, touchesFile } from './lib/live.svelte';
+export type { LiveChanged, LiveHello, LiveIndexEvent, LiveIndexRevision } from './lib/live.svelte';
+export { project } from './lib/project.svelte';
+export { hot, railFocus } from './lib/focus.svelte';
+export type { RailSide } from './lib/focus.svelte';
+export { palette } from './lib/palette.svelte';
+export { toast } from './lib/toast.svelte';
+export { walkTo, arrivedFrom, openEntryTarget } from './lib/walk';
+export type { WalkTarget } from './lib/walk';
+
+/* ------------------------------------------------------------ the models --
+   Pure functions: no DOM, no fetch, no state. A host that wants a different
+   screen over the same answers builds it out of these rather than out of the
+   payloads, so its arithmetic is the arithmetic the shipped screens use. */
+
+export { decodeLine, plainLine, tokenClass, tokensByLine } from './lib/highlight';
+export type { Token, TokenClass, WireHighlight, WireToken } from './lib/highlight';
+
+export {
+  assignRefs,
+  basename,
+  buildCalleeRail,
+  buildCallerRail,
+  buildCodeBlock,
+  buildOutline,
+  edgeWord,
+  graphCallLines,
+  kindPhrase,
+  lastSegment,
+  refsByLine,
+  relationWords,
+  showsBody,
+  synthesizedBy,
+} from './lib/symbol-model';
+export type {
+  CalleeRailModel,
+  CalleeRow,
+  CallerFileGroup,
+  CallerRailModel,
+  CallerRow,
+  CodeBlock,
+  Connector,
+  LineRef,
+  OutlineRow,
+  SourceWindow,
+} from './lib/symbol-model';
+
+export { buildFlowLayout, cardHeight, endCapHeight, endCapText } from './lib/flow-model';
+export type {
+  EndCapSite,
+  EndCapText,
+  FlowCardLayout,
+  FlowEndCapLayout,
+  FlowLayout,
+  FlowLinkLayout,
+} from './lib/flow-model';
+
+export { buildMapLayout, isEdgeVisible, moduleMetaLabel } from './lib/map-model';
+export type {
+  MapEdgeLayout,
+  MapLayerLayout,
+  MapLayout,
+  MapLayoutOptions,
+  MapNodeLayout,
+} from './lib/map-model';
+
+export { buildFileOutline, buildFileRail, fileMetaLine, fileTitle } from './lib/file-model';
+export type { FileRailModel, FileRailRow, OutlineEntryRow } from './lib/file-model';
+
+export {
+  buildFileArcs,
+  buildFileCallRows,
+  buildFileRefs,
+  documentHeight,
+  lineCentre,
+  lineTop,
+  pageFor,
+  visibleLines,
+} from './lib/filecode-model';
+export type { FileArc, FileCallRow, SourcePage } from './lib/filecode-model';
+
+export { buildEntryPanel, flowPair, matchEntries } from './lib/entry-model';
+export type {
+  EntryGroup,
+  EntryPanel,
+  EntryRow,
+  EntrySection,
+  EntryTarget,
+} from './lib/entry-model';
+
+export {
+  buildEntryPalette,
+  buildSearchPalette,
+  moveSelection,
+  parseFlowQuery,
+} from './lib/search-model';
+export type { FlowQuery, Palette, PaletteItem, PaletteSection } from './lib/search-model';
+
+export { exportFilename, flowSvg, mapSvg } from './lib/export-svg';
+export type { ExportOptions, FlowExportOptions, MapExportOptions } from './lib/export-svg';
+export { copyPngToClipboard, downloadSvg, svgToPng } from './lib/export-image';
+export { kindLetter, kindWord } from './lib/kinds';

+ 360 - 0
ui/src/lib/adapter.ts

@@ -0,0 +1,360 @@
+/**
+ * The data seam: everything these components know about a project arrives
+ * through one {@link GraphAdapter} (task CG-61).
+ *
+ * The viewer shipped by `codegraph ui` uses {@link createHttpAdapter}, which is
+ * the read-only JSON API over loopback. A host that already holds the graph —
+ * CodeGraph Pro, which opens the index in-process — implements the same eleven
+ * methods against its own reads and never makes an HTTP request. The components
+ * cannot tell the difference, which is the whole point: one implementation of
+ * the Symbol view, the Flow strip and the Map, drawn from whichever side of the
+ * wire the caller happens to be on.
+ *
+ * ## The shapes are the contract, not the transport
+ *
+ * Every method answers a `Wire*` type from `./wire`, verbatim — the same object
+ * `src/ui-server/api/` serialises. An adapter is therefore allowed to be a
+ * `fetch`, a function call, a cache, or a fixture in a test; what it is not
+ * allowed to do is invent a shape. `./wire` has no imports and no runtime, so a
+ * host can depend on the vocabulary without depending on the viewer.
+ *
+ * ## One adapter per page
+ *
+ * The current adapter is module-level state, not Svelte context. Two reasons:
+ * the pure model modules (`symbol-model`, `flow-model`, the palette store) are
+ * plain TypeScript and cannot read a component's context, and a reader is
+ * looking at one project at a time — the screens are a reading of *a* graph.
+ * A host calls {@link setGraphAdapter} once before it renders, or wraps its
+ * tree in `<CodegraphUi>`, which does it during initialisation.
+ */
+
+import type {
+  WireEntryPoints,
+  WireFilePayload,
+  WireFileCodePayload,
+  WireFlowPayload,
+  WireMapPayload,
+  WireNodeRefs,
+  WireRoutes,
+  WireSearch,
+  WireSource,
+  WireStats,
+  WireSymbolPayload,
+} from './wire';
+
+/* ---------------------------------------------------------------- errors -- */
+
+/**
+ * An error the answering side described.
+ *
+ * The JSON API answers JSON for *every* outcome, including refusals, so a
+ * non-2xx still carries a sentence worth showing — this is what puts the
+ * server's own words on the screen instead of "Failed to fetch". An in-process
+ * adapter should throw the same thing for the same reason: the screens read
+ * `code` (`'not-found'` has its own empty state) and print `guidance`.
+ */
+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;
+}
+
+/* -------------------------------------------------------------- requests -- */
+
+export interface SourceRequest {
+  file: string;
+  /** 1-based, inclusive. */
+  from: number;
+  /** 1-based, inclusive. Omitted means "to the end of the file". */
+  to?: number;
+  /**
+   * What to answer when the file has changed since it was indexed. The default
+   * omits the slice — an indexed range over rewritten bytes can show a
+   * different symbol's code under the right name. `'current'` asks for the
+   * file's current lines instead, and the answer says `showing: 'current'` so
+   * the caller can switch every line-anchored marking off over it.
+   */
+  ondrift?: 'current';
+}
+
+/**
+ * A flow question. Exactly one of the three shapes is asked at a time:
+ * `{ from, to }` ("how does X reach Y"), `{ symbols }` (`codegraph_explore`'s
+ * own question, verbatim) or `{ trail }` (the hops the reader walked, as
+ * `<dir><id>` strings).
+ */
+export interface FlowRequest {
+  from?: string;
+  to?: string;
+  symbols?: string;
+  trail?: readonly string[];
+}
+
+export interface MapRequest {
+  /** The subtree to aggregate — a monorepo's package. Null lets the graph pick. */
+  root?: string | null;
+  /** How many path segments under the root name a module. */
+  depth?: number;
+}
+
+export interface SearchRequest {
+  limit?: number;
+}
+
+export interface EntryPointsRequest {
+  limit?: number;
+  routes?: number;
+}
+
+export interface RoutesRequest {
+  limit?: number;
+}
+
+/* ----------------------------------------------------------------- live -- */
+
+/**
+ * The live channel's events, as the viewer's `live.svelte.ts` consumes them.
+ *
+ * An adapter that has no way to know the graph moved simply omits
+ * {@link GraphAdapter.events}; the screens then render once and stay put, which
+ * is the correct behaviour for a host that re-mounts them itself.
+ */
+export interface LiveHandlers {
+  hello(event: unknown): void;
+  changed(event: unknown): void;
+  index(event: unknown): void;
+  degraded(event: unknown): void;
+  /** The connection dropped. The caller owns the backoff — never retry here. */
+  error(): void;
+}
+
+/* -------------------------------------------------------------- adapter -- */
+
+/**
+ * Everything the components ask of a project.
+ *
+ * Seven of these are the reading surface named in the task — `search`, `node`,
+ * `source`, `file`, `flow`, `map`, `routes` — and the other four are what the
+ * screens around them need: `stats` (the blast bar's denominator and the top
+ * bar's counts), `nodes` (a trail arrives from a URL as bare ids), `fileCode`
+ * (the whole-file view) and `entryPoints` (where a reader starts).
+ */
+export interface GraphAdapter {
+  /** The index's own facts: counts, thresholds, the blast scale. */
+  stats(signal?: AbortSignal): Promise<WireStats>;
+  search(query: string, opts?: SearchRequest, signal?: AbortSignal): Promise<WireSearch>;
+  /** One symbol with its rails, outline, tests, blast radius and drift verdict. */
+  node(id: string, signal?: AbortSignal): Promise<WireSymbolPayload>;
+  /** Names and locations for ids the caller already holds (the trail). */
+  nodes(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs>;
+  /** A slice of an indexed file, classified for highlighting. */
+  source(request: SourceRequest, signal?: AbortSignal): Promise<WireSource>;
+  /** One file: outline, import rails, dependencies, drift. */
+  file(path: string, signal?: AbortSignal): Promise<WireFilePayload>;
+  /** Everything the graph says about the LINES of one file (ports and arcs). */
+  fileCode(path: string, signal?: AbortSignal): Promise<WireFileCodePayload>;
+  /** The call path between symbols — `resolveNamedSymbolFlow`'s own answer. */
+  flow(request: FlowRequest, signal?: AbortSignal): Promise<WireFlowPayload>;
+  /** The repository at module granularity, layered. */
+  map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
+  /** The URL → handler map. */
+  routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
+  /** Where a reader starts: routes, files that run something, tests, hubs. */
+  entryPoints(request?: EntryPointsRequest, signal?: AbortSignal): Promise<WireEntryPoints>;
+  /**
+   * Subscribe to index/disk changes. Optional — a host without a live channel
+   * omits it and nothing polls. Returns a function that closes the stream.
+   */
+  events?(handlers: LiveHandlers): () => void;
+}
+
+/* ------------------------------------------------------------ http impl -- */
+
+export interface HttpAdapterOptions {
+  /**
+   * Where the API lives, ending in a slash. The default is relative (`''`),
+   * which is what the CLI serves: the viewer is mounted at `/` and asks for
+   * `api/stats`, so it survives being mounted under a sub-path.
+   */
+  baseUrl?: string;
+  /** Injectable for tests and for a host that wraps `fetch` with auth. */
+  fetch?: typeof globalThis.fetch;
+}
+
+/** Ids and paths carry ':' and '/', so encode per segment and rejoin. */
+function encodePath(value: string): string {
+  return value.split('/').map(encodeURIComponent).join('/');
+}
+
+function query(params: URLSearchParams): string {
+  const text = params.toString();
+  return text ? `?${text}` : '';
+}
+
+/**
+ * The default adapter: the read-only JSON API `codegraph ui` serves.
+ *
+ * Every failure it can describe comes back as an {@link ApiFailure} carrying
+ * the server's own sentence. The one it cannot describe — the server was
+ * stopped while the tab stayed open — is given a sentence here, because a
+ * network-level `TypeError` says nothing a reader can act on.
+ */
+export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapter {
+  const base = options.baseUrl ?? '';
+  const doFetch = options.fetch ?? ((...args: Parameters<typeof globalThis.fetch>) =>
+    globalThis.fetch(...args));
+
+  async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
+    let response: Response;
+    try {
+      response = await doFetch(`${base}${path}`, {
+        signal,
+        headers: { accept: 'application/json' },
+      });
+    } catch (cause) {
+      if (signal?.aborted) throw cause;
+      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;
+  }
+
+  return {
+    stats: (signal) => getJson<WireStats>('api/stats', signal),
+
+    search(text, opts = {}, signal) {
+      const params = new URLSearchParams({ q: text });
+      if (opts.limit) params.set('limit', String(opts.limit));
+      return getJson<WireSearch>(`api/search${query(params)}`, signal);
+    },
+
+    node: (id, signal) => getJson<WireSymbolPayload>(`api/node/${encodePath(id)}`, signal),
+
+    nodes(ids, signal) {
+      const params = new URLSearchParams();
+      // Repeated `id` params, never one comma-joined list: a node id can be a
+      // file path and a file path can contain a comma.
+      for (const id of ids) params.append('id', id);
+      return getJson<WireNodeRefs>(`api/nodes${query(params)}`, signal);
+    },
+
+    source(request, signal) {
+      const params = new URLSearchParams({
+        file: request.file,
+        from: String(request.from),
+      });
+      // Absent `to` means "to the end of the file"; sending 0 for that would be
+      // out of range, not a synonym.
+      if (request.to !== undefined && request.to > 0) params.set('to', String(request.to));
+      if (request.ondrift) params.set('ondrift', request.ondrift);
+      return getJson<WireSource>(`api/source${query(params)}`, signal);
+    },
+
+    file: (path, signal) => getJson<WireFilePayload>(`api/file/${encodePath(path)}`, signal),
+
+    fileCode: (path, signal) =>
+      getJson<WireFileCodePayload>(`api/filecode/${encodePath(path)}`, signal),
+
+    flow(request, signal) {
+      const params = new URLSearchParams();
+      if (request.from) params.set('from', request.from);
+      if (request.to) params.set('to', request.to);
+      if (request.symbols) params.set('symbols', request.symbols);
+      for (const hop of request.trail ?? []) params.append('hop', hop);
+      return getJson<WireFlowPayload>(`api/flow${query(params)}`, signal);
+    },
+
+    map(request = {}, signal) {
+      const params = new URLSearchParams();
+      if (request.root !== undefined && request.root !== null) params.set('root', request.root);
+      if (request.depth) params.set('depth', String(request.depth));
+      return getJson<WireMapPayload>(`api/map${query(params)}`, signal);
+    },
+
+    routes(request = {}, signal) {
+      const params = new URLSearchParams();
+      if (request.limit) params.set('limit', String(request.limit));
+      return getJson<WireRoutes>(`api/routes${query(params)}`, signal);
+    },
+
+    entryPoints(request = {}, signal) {
+      const params = new URLSearchParams();
+      if (request.limit) params.set('limit', String(request.limit));
+      if (request.routes) params.set('routes', String(request.routes));
+      return getJson<WireEntryPoints>(`api/entrypoints${query(params)}`, signal);
+    },
+
+    events(handlers) {
+      if (typeof EventSource === 'undefined') return () => {};
+      const stream = new EventSource(`${base}api/events`);
+      stream.addEventListener('hello', (event) => handlers.hello(parse(event)));
+      stream.addEventListener('changed', (event) => handlers.changed(parse(event)));
+      stream.addEventListener('index', (event) => handlers.index(parse(event)));
+      stream.addEventListener('degraded', (event) => handlers.degraded(parse(event)));
+      // The backoff belongs to the caller, not here: an adapter that retried on
+      // its own would race the one that already does and double the requests.
+      stream.addEventListener('error', () => handlers.error());
+      return () => stream.close();
+    },
+  };
+}
+
+function parse(event: Event): unknown {
+  const data = (event as MessageEvent<string>).data;
+  if (typeof data !== 'string') return null;
+  try {
+    return JSON.parse(data) as unknown;
+  } catch {
+    return null;
+  }
+}
+
+/* ------------------------------------------------------------- registry -- */
+
+let current: GraphAdapter | null = null;
+
+/**
+ * Install the adapter every screen reads through.
+ *
+ * Call once, before anything renders. Passing `null` restores the default HTTP
+ * adapter, which is what the standalone viewer runs on.
+ */
+export function setGraphAdapter(adapter: GraphAdapter | null): void {
+  current = adapter;
+}
+
+/** The installed adapter, defaulting to the HTTP one on first use. */
+export function getGraphAdapter(): GraphAdapter {
+  if (current === null) current = createHttpAdapter();
+  return current;
+}

+ 56 - 645
ui/src/lib/api.ts

@@ -1,619 +1,51 @@
 /**
- * The viewer's side of the read-only JSON API (`src/ui-server/api/`, CG-42).
+ * The screens' side of the graph API.
  *
- * 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.
+ * Every function here is one call on the installed {@link GraphAdapter}
+ * (`adapter.ts`). Nothing in this file knows about HTTP: the standalone viewer
+ * runs on `createHttpAdapter`, and a host that already holds the index — the
+ * Pro app — installs its own and these same functions read from it.
  *
- * 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".
+ * The wire types live in `./wire` (types only, no runtime) and are re-exported
+ * here so that a screen can keep asking one module for both the call and the
+ * shape it answers with.
  */
 
-import type { WireHighlight } from './highlight';
-
-/* ---------------------------------------------------------------- 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;
-  /**
-   * Which numbering `lines` belong to. `'indexed'` — the file matches the
-   * index. `'current'` — it drifted and we asked for the bytes anyway
-   * (`ondrift: 'current'`), so nothing the graph holds about this file lines up
-   * with them. `'none'` — it drifted and no slice came back.
-   */
-  showing: 'indexed' | 'current' | 'none';
-  contentHash: string;
-  indexedAt: number;
-  generated: boolean;
-  totalLines: number | null;
-  from?: number;
-  to?: number;
-  /** Absent when the file drifted and `ondrift` was left at its default. */
-  lines?: string[];
-  truncated?: boolean;
-  reason?: string;
-  /**
-   * The same lines, classified by the server's tree-sitter parse — one entry
-   * per line, each a list of `[classId, text]` pairs indexed into `classes`.
-   * Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
-   * covers the file. See `lib/highlight.ts`.
-   */
-  highlight?: WireHighlight;
-}
-
-/* ------------------------------------------------------------- file view -- */
-
-/** A row in the file outline — a symbol, its nesting and its edge counts. */
-export interface WireOutlineEntry extends WireNodeRef {
-  /** Containing symbol within this file, or null for a top-level one. */
-  parentId: string | null;
-  /** Nesting depth from the top level of the file, starting at 0. */
-  depth: number;
-  fanIn: number;
-  fanOut: number;
-}
-
-/** One file at the far end of an import rail, with the symbols the edges name. */
-export interface WireImportRow {
-  file: string;
-  test: boolean;
-  symbols: Array<{ id: string; name: string; kind: string; line: number }>;
-  symbolCount: number;
-}
-
-export interface WireFilePayload {
-  file: {
-    path: string;
-    language: string;
-    size: number;
-    modifiedAt: number;
-    indexedAt: number;
-    contentHash: string;
-    nodeCount: number;
-    generated: boolean;
-    test: boolean;
-    errors: string[];
-    /** The file node's own id, so the viewer can open the file AS a symbol. */
-    id: string | null;
-  };
-  /** Calls made outside every definition — module-level code. */
-  topLevel: { calls: number };
-  /** The file changed on disk since it was indexed; the outline's lines shifted. */
-  drift: boolean;
-  outline: WireList<WireOutlineEntry>;
-  /** `imports` edges only — a subset of `dependencies`, with symbol names. */
-  imports: WireList<WireImportRow>;
-  importedBy: WireList<WireImportRow>;
-  /** Import statements that resolved to nothing indexed: packages, builtins. */
-  unresolvedImports: Array<{ name: string; line: number }>;
-  /** Every file this one reaches by any cross-file edge — `getFileDependencies`. */
-  dependencies: string[];
-  /** Every file that reaches into this one — `getFileDependents`. */
-  dependents: string[];
-}
-
-/* ------------------------------------------------ whole-file source view -- */
-
-/** A reference the resolver never landed: a gutter port with no destination. */
-export interface WireFileOutsideRef {
-  line: number;
-  col: number;
-  name: string;
-  kind: string;
-}
-
-/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
-export interface WireFileCall {
-  /** The symbol making the calls — the file node itself for top-level code. */
-  ownerId: string;
-  ownerLine: number;
-  relation: WireRelation;
-}
-
-export interface WireFileCodePayload {
-  file: {
-    path: string;
-    language: string;
-    size: number;
-    indexedAt: number;
-    contentHash: string;
-    generated: boolean;
-    test: boolean;
-    errors: string[];
-    id: string | null;
-    /** Lines on disk now — the height of the scrolling document. */
-    totalLines: number | null;
-  };
-  drift: boolean;
-  reason?: string;
-  outline: WireList<WireOutlineEntry>;
-  calls: WireList<WireFileCall>;
-  outside: WireList<WireFileOutsideRef>;
-  /** Calls landing on a definition in this same file — the arc diagram's total. */
-  intraFileCalls: number;
-  timing: { elapsedMs: number };
-}
-
-export interface WireBlastScale {
-  maxDirect: number;
-  maxWithinHops: number;
-  hops: number;
-  sampled: number;
-  estimated: boolean;
-}
-
-/* ------------------------------------------------------- search palette -- */
-
-/** How a result's text matched the query — the server's primary sort key. */
-export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
-
-export interface WireSearchResult extends WireNodeRef {
-  matchKind: MatchKind;
-}
-
-export interface WireSearchGroup {
-  kind: NodeKind;
-  count: number;
-  items: WireSearchResult[];
-}
-
-export interface WireSearch {
-  query: string;
-  /** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
-  text: string;
-  filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
-  results: WireList<WireSearchResult>;
-  /** Kind buckets in ranked order — flattening them reproduces the ranking. */
-  groups: WireSearchGroup[];
-}
-
-export interface WireNodeRefs {
-  items: WireNodeRef[];
-  /** Ids that name nothing in this index — a stale link, not an error. */
-  missing: string[];
-}
-
-/* ---------------------------------------------------------- entry points -- */
-
-export interface WireEntryRoute {
-  /** The route node's name, verbatim: "POST /v1/users/{id}". */
-  url: string;
-  /** The verb, when the name leads with one. Null for a file-routed page. */
-  method: string | null;
-  /** The URL without the verb — the same string as `url` when there is none. */
-  path: string;
-  handler: string;
-  handlerKind: string;
-  /** Where the request is SERVED. */
-  file: string;
-  line: number;
-  handlerId: string | null;
-  /** Where the URL is REGISTERED — the router file, which is how routes group. */
-  routeFile: string;
-  routeLine: number;
-  routeId: string;
-}
-
-export interface WireEntryFile extends WireNodeRef {
-  /** Calls and instantiations made at the top level of the file. */
-  calls: number;
-  /** Distinct other files this one's symbols reach. */
-  reaches: number;
-  /** Other files reaching into this one. Zero means nothing imports it. */
-  dependents: number;
-}
-
-export interface WireEntryHub extends WireNodeRef {
-  dependents: number;
-}
-
-export interface WireEntryTest extends WireNodeRef {
-  /** Distinct other files this test reaches — what it exercises. */
-  reaches: number;
-  /** References behind that reach. */
-  refs: number;
-}
-
-export interface WireEntryPoints {
-  /** Frameworks the resolver detected — named in the Routes header. */
-  frameworks: string[];
-  routes: {
-    routed: boolean;
-    /** Every `route` node in the graph, resolved handler or not. */
-    routeCount: number;
-    items: WireList<WireEntryRoute>;
-  };
-  /** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
-  files: WireList<WireEntryFile>;
-  tests: WireList<WireEntryTest>;
-  hubs: WireList<WireEntryHub>;
-  index: { lastIndexedAt: number | null; files: number };
-  timing: { elapsedMs: number; cached: 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;
-}
-
-/* ------------------------------------------------------------- flow strip -- */
-
-export interface WireFlowEdge extends WireEdge {
-  /** The link's label: "calls", "via callback · registered at file:line". */
-  label: string;
-  /** This hop reads callee → caller — the reader stepped UP into it. */
-  upward: boolean;
-  /** Confidence below 0.6: the link is dashed `2 3`. */
-  uncertain: boolean;
-  /** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
-  synthesized: boolean;
-}
-
-export interface WireFlowSource {
-  file: string;
-  language: string;
-  from: number;
-  to: number;
-  /** Absent when `drift` — a mis-sliced window is worse than an empty card. */
-  lines?: string[];
-  highlight?: WireHighlight;
-  drift: boolean;
-  reason?: string;
-}
-
-/** The call site a card is opened at — the identifier drawn as an accent link. */
-export interface WireFlowCallRef {
-  line: number;
-  col: number | null;
-  name: string;
-  targetId: string;
-  /** The link points back at the previous card, not on to the next one. */
-  backwards: boolean;
-}
-
-export interface WireFlowHop {
-  node: WireNodeRef;
-  /** The edge from the PREVIOUS hop into this one; null on the first. */
-  edge: WireFlowEdge | null;
-  callRef: WireFlowCallRef | null;
-  source: WireFlowSource | null;
-}
-
-/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
-export interface WireBoundaryCandidate {
-  node: WireNodeRef;
-  display: string;
-  named: boolean;
-}
-
-/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
-export interface WireBoundarySite {
-  form: string;
-  label: string;
-  snippet: string;
-  line: number;
-  key: string | null;
-  keyIsType: boolean;
-  moreSites: number;
-  candidates: WireBoundaryCandidate[];
-  candidateNote: string | null;
-}
-
-export interface WireFlowContinuation {
-  node: WireNodeRef;
-  line: number | null;
-  confidence: number | null;
-}
-
-/** Where the graph stops — the strip's end cap (design spec §3.5). */
-export interface WireFlowBoundary {
-  node: WireNodeRef;
-  sites: WireBoundarySite[];
-  uncertain: WireList<WireFlowContinuation>;
-  further: WireList<WireFlowContinuation>;
-  missed: WireNodeRef[];
-}
-
-export interface WireFlow {
-  id: string;
-  /** "execute → rowToFileRecord", for the header's flow picker. */
-  label: string;
-  hops: WireFlowHop[];
-  /** Null on a flow that reaches everything it was asked about. */
-  boundary: WireFlowBoundary | null;
-  /** One card at the dispatch site, not a path: the answer ran out here. */
-  partial: boolean;
-}
-
-export interface WireFlowAmbiguity {
-  token: string;
-  chosen: WireNodeRef | null;
-  others: WireNodeRef[];
-}
-
-export interface WireFlowPayload {
-  query: {
-    kind: 'directed' | 'symbols' | 'trail';
-    from: string | null;
-    to: string | null;
-    symbols: string[];
-  };
-  flows: WireFlow[];
-  ambiguous: WireFlowAmbiguity[];
-  /** Tokens that named nothing in this index. */
-  unresolved: string[];
-  /** Why there is no flow, when there is none. */
-  reason: string | null;
-  index: { lastIndexedAt: number | null; edges: number; files: number };
-  timing: { elapsedMs: number };
-}
-
-/* -------------------------------------------------------------- the map -- */
-
-export interface WireMapModule {
-  /** Directory path, the `(root files)` bucket, or a façade file's own path. */
-  id: string;
-  label: string;
-  files: number;
-  symbols: number;
-  languages: Array<{ language: string; files: number }>;
-  /** More than half its files are tests. */
-  test: boolean;
-  /** A single file kept out of the root bucket because it is the façade. */
-  facade: boolean;
-  /** Its files, capped — the side panel's list when the module is selected. */
-  fileList: { total: number; shown: number; truncated: boolean; items: string[] };
-}
-
-export interface WireMapLink {
-  source: string;
-  target: string;
-  /** Every confident cross-module edge behind this link. */
-  count: number;
-  /**
-   * The subset resolved through an import, a qualified name, an inheritance
-   * clause or a typed receiver — what the layering trusts.
-   */
-  declared: number;
-  byKind: Array<{ kind: EdgeKind; count: number }>;
-  topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
-}
-
-export interface WireMapCycle {
-  size: number;
-  files: string[];
-  modules: string[];
-}
-
-export interface WireMapPayload {
-  root: string;
-  depth: number;
-  roots: Array<{ root: string; label: string; files: number }>;
-  modules: WireMapModule[];
-  links: WireMapLink[];
-  cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
-  excluded: { uncertainEdges: number; confidenceBelow: number };
-  index: { lastIndexedAt: number | null; edges: number; files: number };
-  timing: { elapsedMs: number; cached: boolean };
-}
+import { getGraphAdapter } from './adapter';
+import type {
+  WireEntryPoints,
+  WireFilePayload,
+  WireFileCodePayload,
+  WireFlowPayload,
+  WireMapPayload,
+  WireNodeRefs,
+  WireRoutes,
+  WireSearch,
+  WireSource,
+  WireStats,
+  WireSymbolPayload,
+} from './wire';
+
+export * from './wire';
+export { ApiFailure } from './adapter';
+export type {
+  GraphAdapter,
+  EntryPointsRequest,
+  FlowRequest,
+  HttpAdapterOptions,
+  LiveHandlers,
+  MapRequest,
+  RoutesRequest,
+  SearchRequest,
+  SourceRequest,
+} from './adapter';
 
 export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
-  return getJson<WireStats>('api/stats', signal);
+  return getGraphAdapter().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);
+  return getGraphAdapter().node(id, signal);
 }
 
 export function fetchSearch(
@@ -621,34 +53,31 @@ export function fetchSearch(
   opts: { limit?: number } = {},
   signal?: AbortSignal
 ): Promise<WireSearch> {
-  const params = new URLSearchParams({ q: query });
-  if (opts.limit) params.set('limit', String(opts.limit));
-  return getJson<WireSearch>(`api/search?${params}`, signal);
+  return getGraphAdapter().search(query, opts, signal);
 }
 
 /** Names and locations for ids you already have — what the trail redraws with. */
 export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
-  const params = new URLSearchParams();
-  for (const id of ids) params.append('id', id);
-  return getJson<WireNodeRefs>(`api/nodes?${params}`, signal);
+  return getGraphAdapter().nodes(ids, signal);
 }
 
 export function fetchEntryPoints(
   opts: { limit?: number; routes?: number } = {},
   signal?: AbortSignal
 ): Promise<WireEntryPoints> {
-  const params = new URLSearchParams();
-  if (opts.limit) params.set('limit', String(opts.limit));
-  if (opts.routes) params.set('routes', String(opts.routes));
-  const query = params.toString();
-  return getJson<WireEntryPoints>(`api/entrypoints${query ? `?${query}` : ''}`, signal);
+  return getGraphAdapter().entryPoints(opts, signal);
+}
+
+/** The URL → handler map. The palette reads routes through `fetchEntryPoints`. */
+export function fetchRoutes(
+  opts: { limit?: number } = {},
+  signal?: AbortSignal
+): Promise<WireRoutes> {
+  return getGraphAdapter().routes(opts, signal);
 }
 
 export function fetchFile(path: string, signal?: AbortSignal): Promise<WireFilePayload> {
-  // Paths carry '/'; encode per segment so `api/file/src/mcp/tools.ts` stays
-  // readable and a segment with a reserved character still round-trips.
-  const encoded = path.split('/').map(encodeURIComponent).join('/');
-  return getJson<WireFilePayload>(`api/file/${encoded}`, signal);
+  return getGraphAdapter().file(path, signal);
 }
 
 /**
@@ -661,8 +90,7 @@ export function fetchFileCode(
   path: string,
   signal?: AbortSignal
 ): Promise<WireFileCodePayload> {
-  const encoded = path.split('/').map(encodeURIComponent).join('/');
-  return getJson<WireFileCodePayload>(`api/filecode/${encoded}`, signal);
+  return getGraphAdapter().fileCode(path, signal);
 }
 
 /**
@@ -683,29 +111,19 @@ export function fetchSource(
   signal?: AbortSignal,
   ondrift?: 'current'
 ): Promise<WireSource> {
-  const params = new URLSearchParams({ file, from: String(from) });
-  // `to` is 1-based on the wire and absent means "to the end of the file" —
-  // sending 0 for that would be out of range, not a synonym.
-  if (to > 0) params.set('to', String(to));
-  if (ondrift) params.set('ondrift', ondrift);
-  return getJson<WireSource>(`api/source?${params}`, signal);
+  return getGraphAdapter().source({ file, from, to, ondrift }, signal);
 }
 
-
 /**
  * The module map. `root` selects the subtree (a monorepo's package); `depth`
  * is how many path segments under it name a module. Omitting `root` lets the
- * server pick the repository's source directory.
+ * adapter pick the repository's source directory.
  */
 export function fetchMap(
   opts: { root?: string | null; depth?: number } = {},
   signal?: AbortSignal
 ): Promise<WireMapPayload> {
-  const params = new URLSearchParams();
-  if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
-  if (opts.depth) params.set('depth', String(opts.depth));
-  const query = params.toString();
-  return getJson<WireMapPayload>(`api/map${query ? `?${query}` : ''}`, signal);
+  return getGraphAdapter().map(opts, signal);
 }
 
 /**
@@ -713,18 +131,11 @@ export function fetchMap(
  *
  * - `{ from, to }` — "how does X reach Y", from the search box.
  * - `{ symbols }` — `codegraph_explore`'s own question, verbatim.
- * - `{ trail }` — the hops the reader walked, as `<dir><id>` strings. Each one
- *   is its own parameter, because a node id can be a file path and a file path
- *   can contain a comma.
+ * - `{ trail }` — the hops the reader walked, as `<dir><id>` strings.
  */
 export function fetchFlow(
   spec: { from?: string; to?: string; symbols?: string; trail?: readonly string[] },
   signal?: AbortSignal
 ): Promise<WireFlowPayload> {
-  const params = new URLSearchParams();
-  if (spec.from) params.set('from', spec.from);
-  if (spec.to) params.set('to', spec.to);
-  if (spec.symbols) params.set('symbols', spec.symbols);
-  for (const hop of spec.trail ?? []) params.append('hop', hop);
-  return getJson<WireFlowPayload>(`api/flow?${params}`, signal);
+  return getGraphAdapter().flow(spec, signal);
 }

+ 118 - 63
ui/src/lib/live.svelte.ts

@@ -16,13 +16,17 @@
  *
  * ## Nothing polls, and nothing loops
  *
- * `EventSource` is the transport, but its own reconnect is not: left alone it
- * retries forever at a fixed interval, so a viewer left open against a stopped
+ * The transport is `GraphAdapter.events` — an `EventSource` on `/api/events`
+ * under `codegraph ui`, whatever a host already has under a host — but the
+ * reconnect is NOT the transport's. Left to itself an `EventSource` retries
+ * forever at a fixed interval, so a viewer left open against a stopped
  * `codegraph ui` becomes a request every three seconds until the tab is closed.
  * So each `error` closes the stream and schedules ONE reconnect on a backoff
  * that ends: after {@link MAX_ATTEMPTS} consecutive failures the connection
  * gives up and says so, and only a deliberate signal — the tab coming back to
- * the foreground, or the window regaining focus — starts it again.
+ * the foreground, or the window regaining focus — starts it again. An adapter
+ * with no `events` at all leaves every counter at zero and nothing connects;
+ * a host can still move them by hand with `live.signal`.
  *
  * The same rule covers the server's own bad day: a `degraded` event means live
  * watching has stopped for good on that side. The client records it and shows
@@ -31,6 +35,7 @@
  */
 
 import { untrack } from 'svelte';
+import { getGraphAdapter } from './adapter';
 
 /* ----------------------------------------------------------- wire shapes -- */
 
@@ -58,6 +63,21 @@ export interface LiveChanged {
   at: number;
 }
 
+/**
+ * What a host passes to `live.signal` — every field optional, because the
+ * counters are what the screens read and the detail is only there for the
+ * disk case, where "which files" decides whether a drift banner appears.
+ */
+export interface LiveSignalDetail {
+  files?: string[];
+  total?: number;
+  truncated?: boolean;
+  /** The change could not be described file by file — assume any file is hit. */
+  scan?: boolean;
+  index?: LiveIndexRevision;
+  at?: number;
+}
+
 export interface LiveIndexEvent {
   type: 'index';
   index: LiveIndexRevision;
@@ -86,10 +106,13 @@ let diskTick = $state(0);
 let lastIndex = $state<LiveIndexEvent | null>(null);
 let lastChanged = $state<LiveChanged | null>(null);
 
-let source: EventSource | null = null;
+/** Closes the current subscription, or null when there is none open. */
+let close: (() => void) | null = null;
 let retry: ReturnType<typeof setTimeout> | null = null;
 let attempts = 0;
 let started = false;
+/** The installed adapter has no live channel. Nothing to connect, ever. */
+let unsupported = false;
 
 /**
  * Ticks that arrived while the tab was in the background.
@@ -138,73 +161,68 @@ function flushDeferred(): void {
 /* ------------------------------------------------------------ connection -- */
 
 function open(): void {
-  if (source || typeof EventSource === 'undefined') return;
+  if (close !== null) return;
   if (retry !== null) {
     clearTimeout(retry);
     retry = null;
   }
   stopped = false;
 
-  const es = new EventSource('api/events');
-  source = es;
-
-  es.addEventListener('open', () => {
-    connected = true;
-  });
-
-  es.addEventListener('hello', (event) => {
-    const hello = parse<LiveHello>(event);
-    if (!hello) return;
-    // A hello is the only proof the stream is really working: `open` fires on
-    // the response headers, and a server that answered and then died would
-    // otherwise reset the backoff it should have been paying.
-    attempts = 0;
-    connected = true;
-    watching = hello.watching;
-    degraded = hello.degraded;
-  });
-
-  es.addEventListener('changed', (event) => {
-    const changed = parse<LiveChanged>(event);
-    if (changed) bumpDisk(changed);
-  });
-
-  es.addEventListener('index', (event) => {
-    const moved = parse<LiveIndexEvent>(event);
-    if (moved) bumpIndex(moved);
-  });
-
-  es.addEventListener('degraded', (event) => {
-    const note = parse<{ reason: string }>(event);
-    if (note) degraded = note.reason;
-  });
+  // The transport belongs to the adapter, not to this module: `codegraph ui`
+  // answers it with an EventSource on `/api/events`, and a host that already
+  // knows when its index moved answers it with whatever it already has. An
+  // adapter with no live channel simply omits `events` — and then nothing here
+  // ever runs, which is the correct behaviour and not a degraded one.
+  const subscribe = getGraphAdapter().events;
+  if (!subscribe) {
+    unsupported = true;
+    return;
+  }
 
-  es.addEventListener('error', () => {
-    connected = false;
-    es.close();
-    if (source === es) source = null;
-    attempts += 1;
-    if (attempts >= MAX_ATTEMPTS) {
-      // Out of attempts. Nothing on a timer from here — the tab coming back to
-      // the foreground is the only thing that tries again.
-      stopped = true;
-      return;
-    }
-    const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
-    retry = setTimeout(open, delay);
+  close = subscribe({
+    hello(event) {
+      const hello = event as LiveHello | null;
+      if (!hello) return;
+      // A hello is the only proof the stream is really working: a connection
+      // opens on the response headers, and a server that answered and then
+      // died would otherwise reset the backoff it should have been paying.
+      attempts = 0;
+      connected = true;
+      watching = hello.watching;
+      degraded = hello.degraded;
+    },
+    changed(event) {
+      const changed = event as LiveChanged | null;
+      if (changed) bumpDisk(changed);
+    },
+    index(event) {
+      const moved = event as LiveIndexEvent | null;
+      if (moved) bumpIndex(moved);
+    },
+    degraded(event) {
+      const note = event as { reason: string } | null;
+      if (note) degraded = note.reason;
+    },
+    error() {
+      connected = false;
+      // Take the closer before calling it: a transport that calls `error`
+      // again from inside its own teardown must not re-enter this.
+      const closer = close;
+      close = null;
+      closer?.();
+      attempts += 1;
+      if (attempts >= MAX_ATTEMPTS) {
+        // Out of attempts. Nothing on a timer from here — the tab coming back
+        // to the foreground is the only thing that tries again.
+        stopped = true;
+        return;
+      }
+      const delay = BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] ?? 30_000;
+      retry = setTimeout(open, delay);
+    },
   });
 }
 
-function parse<T>(event: Event): T | null {
-  const data = (event as MessageEvent<string>).data;
-  if (typeof data !== 'string') return null;
-  try {
-    return JSON.parse(data) as T;
-  } catch {
-    return null;
-  }
-}
-
 /** Connect, once, for the life of the page. */
 function start(): void {
   if (started || typeof window === 'undefined') return;
@@ -227,8 +245,8 @@ function start(): void {
     open();
   });
   window.addEventListener('pagehide', () => {
-    source?.close();
-    source = null;
+    close?.();
+    close = null;
   });
 
   open();
@@ -263,7 +281,44 @@ export const live = {
   get lastChanged(): LiveChanged | null {
     return lastChanged;
   },
+  /** The installed adapter has no live channel — this page never was live. */
+  get unsupported(): boolean {
+    return unsupported;
+  },
   start,
+
+  /**
+   * Move a counter from outside.
+   *
+   * A host that learns about a sync through its own machinery — a websocket, a
+   * webhook, a store it already owns — calls this instead of implementing
+   * `GraphAdapter.events`, and every mounted screen refetches exactly as it
+   * does under `codegraph ui`. It is the same code path the stream uses, so
+   * there is no second way for a screen to go stale.
+   */
+  signal(kind: 'index' | 'disk', detail: LiveSignalDetail = {}): void {
+    if (kind === 'index') {
+      bumpIndex({
+        type: 'index',
+        index: detail.index ?? { lastIndexedAt: null, files: 0 },
+        files: detail.files ?? [],
+        total: detail.total ?? detail.files?.length ?? 0,
+        truncated: detail.truncated ?? false,
+        at: detail.at ?? 0,
+      });
+      return;
+    }
+    bumpDisk({
+      type: 'changed',
+      files: detail.files ?? [],
+      total: detail.total ?? detail.files?.length ?? 0,
+      truncated: detail.truncated ?? false,
+      // No named files and no scan flag would mean "nothing changed", which is
+      // not what a caller asking for a disk tick means.
+      scan: detail.scan ?? (detail.files === undefined || detail.files.length === 0),
+      at: detail.at ?? 0,
+    });
+  },
 };
 
 /**

+ 209 - 0
ui/src/lib/navigation.ts

@@ -0,0 +1,209 @@
+/**
+ * The navigation seam: where a click on a symbol, a file, a flow or the map
+ * takes the reader (task CG-61).
+ *
+ * The standalone viewer is a hash app — `#/s/<id>`, `#/file/<path>`, `#/map` —
+ * and that is the default driver below. A host embedding these components has
+ * its own router and its own URL space (a review page, a PR, a workspace), so
+ * it installs a {@link NavigationDriver} and every rail row, breadcrumb, chip
+ * and card in the package addresses *its* app instead.
+ *
+ * Two reasons the components go through href builders rather than through a
+ * single `onNavigate` callback:
+ *
+ * - **A row is a link.** Middle-click, cmd-click and "copy link address" are
+ *   how people read code, and they only work if the `<a>` really carries an
+ *   href. A callback-only design turns every row into a `<div>` with an
+ *   onclick, which is a worse screen.
+ * - **The trail travels in the address.** The walk is part of the URL, so
+ *   building one is a thing the components must be able to do, not just ask
+ *   for.
+ *
+ * The live route (`router.svelte.ts`) is the *app's* half and is deliberately
+ * not imported here: it attaches `hashchange`/`popstate` listeners at module
+ * scope, which a host must never inherit just by rendering a Symbol view.
+ */
+
+export interface SymbolHrefOptions {
+  /** A line to highlight and scroll to in the destination. */
+  line?: number;
+  /** The encoded trail, so a reload or a shared link reproduces the walk. */
+  trail?: string;
+}
+
+export interface FileHrefOptions {
+  line?: number;
+  /** The whole-file source view rather than the outline. */
+  source?: boolean;
+}
+
+export interface MapHrefOptions {
+  root?: string | null;
+  depth?: number;
+  tests?: boolean;
+}
+
+export interface FlowHrefOptions {
+  from?: string;
+  to?: string;
+  symbols?: string;
+  trail?: string;
+}
+
+/**
+ * Where the components send the reader.
+ *
+ * Implement all of it: a half-implemented driver produces a screen where some
+ * rows navigate the host and others silently jump to a hash the host does not
+ * serve.
+ */
+export interface NavigationDriver {
+  symbolHref(id: string, opts?: SymbolHrefOptions): string;
+  fileHref(path: string, opts?: FileHrefOptions): string;
+  mapHref(opts?: MapHrefOptions): string;
+  flowHref(opts?: FlowHrefOptions): string;
+  entryHref(): string;
+  /** Go to an href this driver built. */
+  navigate(href: string, opts?: { replace?: boolean }): void;
+  /** Back one entry in the host's history. */
+  back(): void;
+}
+
+/* ------------------------------------------------------- the hash driver -- */
+
+/**
+ * Node ids are opaque engine strings shaped `<kind>:<hash>` or
+ * `<kind>:<relative/path>`, so they can contain both ':' and '/'. Encoding per
+ * slash-separated segment keeps the URL readable (`#/file/src/mcp/tools.ts`)
+ * and still round-trips a segment that itself contains a reserved character.
+ */
+function encodePath(value: string): string {
+  return value.split('/').map(encodeURIComponent).join('/');
+}
+
+function query(params: URLSearchParams): string {
+  const text = params.toString();
+  return text ? `?${text}` : '';
+}
+
+/** The `codegraph ui` address space: the hash is the route. */
+export const hashNavigation: NavigationDriver = {
+  symbolHref(id, opts = {}) {
+    const params = new URLSearchParams();
+    if (opts.trail) params.set('t', opts.trail);
+    if (opts.line) params.set('hl', String(opts.line));
+    return `#/s/${encodePath(id)}${query(params)}`;
+  },
+
+  fileHref(path, opts = {}) {
+    const params = new URLSearchParams();
+    // `src` before `hl` so the two file URLs a reader shares differ in their
+    // first character after the path, not somewhere in the middle.
+    if (opts.source) params.set('src', '1');
+    if (opts.line) params.set('hl', String(opts.line));
+    return `#/file/${encodePath(path)}${query(params)}`;
+  },
+
+  mapHref(opts = {}) {
+    const params = new URLSearchParams();
+    if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
+    if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
+    if (opts.tests) params.set('tests', '1');
+    return `#/map${query(params)}`;
+  },
+
+  flowHref(opts = {}) {
+    const params = new URLSearchParams();
+    if (opts.from) params.set('from', opts.from);
+    if (opts.to) params.set('to', opts.to);
+    if (opts.symbols) params.set('symbols', opts.symbols);
+    // `t`, not `trail`: the trail already travels under that name everywhere
+    // else, and a flow read from one is the same walk under a different lens.
+    if (opts.trail) params.set('t', opts.trail);
+    return `#/flow${query(params)}`;
+  },
+
+  entryHref() {
+    return '#/entry';
+  },
+
+  navigate(href, opts = {}) {
+    const target = href.startsWith('#') ? href : `#${href}`;
+    if (opts.replace) {
+      history.replaceState(history.state, '', target);
+      onHashWritten();
+      return;
+    }
+    if (location.hash === target) return;
+    location.hash = target;
+    // hashchange fires asynchronously; the sync is idempotent, so calling it
+    // now keeps a navigate() immediately followed by a read consistent.
+    onHashWritten();
+  },
+
+  back() {
+    history.back();
+  },
+};
+
+/**
+ * The live route's re-read hook, registered by `router.svelte.ts`.
+ *
+ * The driver has to tell the route store that the hash moved, and the store
+ * has to attach window listeners — but a component importing the driver must
+ * not drag those listeners in. So the dependency runs this way round: the store
+ * registers itself with the driver, and a page that never loads the store gets
+ * a driver that simply writes the hash.
+ */
+let onHashWritten: () => void = () => {};
+
+export function registerHashSync(sync: () => void): void {
+  onHashWritten = sync;
+}
+
+/* ------------------------------------------------------------- registry -- */
+
+let driver: NavigationDriver = hashNavigation;
+
+/**
+ * Install the driver every link in the package is built with.
+ *
+ * Call once, before anything renders. Passing `null` restores the hash driver.
+ */
+export function setNavigationDriver(next: NavigationDriver | null): void {
+  driver = next ?? hashNavigation;
+}
+
+export function getNavigationDriver(): NavigationDriver {
+  return driver;
+}
+
+/* --------------------------- what the components actually call ----------- */
+
+export function symbolHref(id: string, opts: SymbolHrefOptions = {}): string {
+  return driver.symbolHref(id, opts);
+}
+
+export function fileHref(path: string, opts: FileHrefOptions = {}): string {
+  return driver.fileHref(path, opts);
+}
+
+export function mapHref(opts: MapHrefOptions = {}): string {
+  return driver.mapHref(opts);
+}
+
+export function flowHref(opts: FlowHrefOptions = {}): string {
+  return driver.flowHref(opts);
+}
+
+export function entryHref(): string {
+  return driver.entryHref();
+}
+
+export function navigate(href: string, opts: { replace?: boolean } = {}): void {
+  driver.navigate(href, opts);
+}
+
+export function back(): void {
+  driver.back();
+}

+ 33 - 74
ui/src/lib/router.svelte.ts

@@ -18,8 +18,37 @@
  * encoded *per slash-separated segment* and rejoined on the way out: the URL
  * stays readable (`#/file/src/mcp/tools.ts`) and still round-trips a segment
  * that itself contains a reserved character.
+ *
+ * This module is the APP's half — it parses the hash and holds the live route,
+ * and it attaches window listeners to do it. The href builders and `navigate`
+ * live in `./navigation`, behind a driver a host can replace, and the shared
+ * components import them from there: rendering a Symbol view inside somebody
+ * else's app must not install a hash router in it. They are re-exported below
+ * so this file stays the app's one-stop import.
  */
 
+import { registerHashSync } from './navigation';
+
+export {
+  back,
+  entryHref,
+  fileHref,
+  flowHref,
+  getNavigationDriver,
+  hashNavigation,
+  mapHref,
+  navigate,
+  setNavigationDriver,
+  symbolHref,
+} from './navigation';
+export type {
+  FileHrefOptions,
+  FlowHrefOptions,
+  MapHrefOptions,
+  NavigationDriver,
+  SymbolHrefOptions,
+} from './navigation';
+
 export type Route =
   | { view: 'home' }
   | { view: 'symbol'; id: string; line: number | null }
@@ -63,10 +92,6 @@ function decodeSegment(segment: string): string {
   }
 }
 
-function encodePath(value: string): string {
-  return value.split('/').map(encodeURIComponent).join('/');
-}
-
 function parseLine(params: URLSearchParams): number | null {
   const raw = params.get('hl');
   if (raw === null) return null;
@@ -120,58 +145,6 @@ export function parseHash(hash: string): RouterLocation {
   return { route, params, raw };
 }
 
-/* ---------- href builders (the only place hashes are assembled) ---------- */
-
-export function symbolHref(id: string, opts: { line?: number; trail?: string } = {}): string {
-  const params = new URLSearchParams();
-  if (opts.trail) params.set('t', opts.trail);
-  if (opts.line) params.set('hl', String(opts.line));
-  const query = params.toString();
-  return `#/s/${encodePath(id)}${query ? `?${query}` : ''}`;
-}
-
-export function fileHref(
-  path: string,
-  opts: { line?: number; source?: boolean } = {}
-): string {
-  const params = new URLSearchParams();
-  // `src` before `hl` so the two file URLs a reader shares differ in their
-  // first character after the path, not somewhere in the middle.
-  if (opts.source) params.set('src', '1');
-  if (opts.line) params.set('hl', String(opts.line));
-  const query = params.toString();
-  return `#/file/${encodePath(path)}${query ? `?${query}` : ''}`;
-}
-
-export function mapHref(
-  opts: { root?: string | null; depth?: number; tests?: boolean } = {}
-): string {
-  const params = new URLSearchParams();
-  if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
-  if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
-  if (opts.tests) params.set('tests', '1');
-  const query = params.toString();
-  return `#/map${query ? `?${query}` : ''}`;
-}
-
-export function entryHref(): string {
-  return '#/entry';
-}
-
-export function flowHref(
-  opts: { from?: string; to?: string; symbols?: string; trail?: string } = {}
-): string {
-  const params = new URLSearchParams();
-  if (opts.from) params.set('from', opts.from);
-  if (opts.to) params.set('to', opts.to);
-  if (opts.symbols) params.set('symbols', opts.symbols);
-  // `t`, not `trail`: the trail already travels under that name everywhere
-  // else, and a flow read from one is the same walk under a different lens.
-  if (opts.trail) params.set('t', opts.trail);
-  const query = params.toString();
-  return `#/flow${query ? `?${query}` : ''}`;
-}
-
 /* ---------- the live route ---------- */
 
 const initial = parseHash(typeof location === 'undefined' ? '' : location.hash);
@@ -187,6 +160,10 @@ if (typeof window !== 'undefined') {
   // popstate too: `navigate(…, { replace: true })` and history.back() across
   // a replaced entry both move the hash without firing hashchange.
   window.addEventListener('popstate', sync);
+  // The hash driver writes `location.hash` directly; this is how it tells the
+  // route store to re-read. Registered here rather than imported there, so a
+  // host that never loads this module gets no window listeners at all.
+  registerHashSync(sync);
 }
 
 export const router = {
@@ -200,21 +177,3 @@ export const router = {
     return current.params;
   },
 };
-
-export function navigate(href: string, opts: { replace?: boolean } = {}): void {
-  const target = href.startsWith('#') ? href : `#${href}`;
-  if (opts.replace) {
-    history.replaceState(history.state, '', target);
-    sync();
-    return;
-  }
-  if (location.hash === target) return;
-  location.hash = target;
-  // hashchange fires asynchronously; sync() is idempotent, so calling it now
-  // keeps a navigate() immediately followed by a read consistent.
-  sync();
-}
-
-export function back(): void {
-  history.back();
-}

+ 192 - 0
ui/src/lib/theme.css

@@ -0,0 +1,192 @@
+/* =====================================================================
+   @colbymchenry/codegraph-ui — design tokens
+
+   The engine's paper/ink editorial system, as specified in
+   docs/design/codegraph-ui-design-spec.md §2.2: flat, hairline rules,
+   square corners everywhere, no shadows, no gradients, sentence case,
+   one oxblood accent, one amber (the "untested" badge).
+
+   This file is the package's whole theming surface. Import it once and
+   override any variable on a narrower selector — the components read
+   nothing else. What is NOT themable is geometry: 34px rail rows, the
+   300/320px rails, the 20px code line. Those are measured against each
+   other by the Symbol view's layout pass, and a host that moves one of
+   them moves a callee row away from the line it points at.
+
+   Colour and type only. Every token is defined once on the bare :root
+   below and only REDEFINED in the two dark blocks, so a host that sets
+   `--accent` on its own container gets it in both schemes.
+   ===================================================================== */
+
+/* ---------- tokens: light / paper (the bare :root set) ---------- */
+:root {
+  --paper: #f7f6f2;
+  --paper-2: #f1efe8;
+  --press: #e8e6dd;
+  --press-2: #dedbd0;
+  --ink: #16150f;
+  --ink-2: #56544a;
+  --ink-3: #87847a;
+  --ink-4: #b4b1a5;
+  --rule: #16150f;
+  --rule-soft: #d6d3c8;
+  --rule-faint: #e6e3d9;
+  --accent: #7a2230;
+  --accent-ink: #5e1a25;
+  --accent-soft: #f0e3e5;
+  --accent-line: #d9b3b9;
+  --amber: #8a5a0b;
+  --amber-soft: #f3e9d2;
+
+  /* The one code colour that is not a plain re-use of the ink ramp.
+     The spec asks for comments at --ink-3; measured against --paper that
+     is 3.46:1 and against the hot-line tint --accent-soft it is 3.00:1,
+     both under the 4.5:1 an AA reading of 12.5px body text needs. This is
+     the smallest step DOWN the same warm-grey ramp that clears 4.5:1 on
+     all three backgrounds a code line can have (paper 5.23, paper-2 4.92,
+     accent-soft 4.53) while staying quieter than --ink-2, which strings
+     and numbers use — so the recession order the spec describes is
+     unchanged, only legible. Dark needed the mirror step UP (4.51 on
+     accent-soft, where --ink-3 was 4.10). */
+  --code-comment: #6a675d;
+
+  --sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
+  --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+  --code-size: 12.5px;
+  --code-lh: 20px;
+
+  /* App-shell geometry, shared by the grid and by anything that has to
+     offset itself under the bars (sticky rail headers, SVG overlays). */
+  --topbar-h: 48px;
+  --trailbar-h: 34px;
+
+  color-scheme: light dark;
+}
+
+/* ---------- tokens: dark / ink ----------
+   Every colour is defined on the bare :root above; these blocks only
+   redefine. `:not([data-theme="light"])` lets an explicit light choice
+   win over the OS preference. */
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme='light']) {
+    --paper: #16150f;
+    --paper-2: #1c1a14;
+    --press: #23211a;
+    --press-2: #2c2a22;
+    --ink: #f3f1ea;
+    --ink-2: #b8b5a8;
+    --ink-3: #87847a;
+    --ink-4: #5d5b52;
+    --rule: #f3f1ea;
+    --rule-soft: #34322a;
+    --rule-faint: #26241d;
+    --accent: #d48b96;
+    --accent-ink: #e5a5ae;
+    --accent-soft: #33201f;
+    --accent-line: #6b3a42;
+    --amber: #d9a94a;
+    --amber-soft: #2e2716;
+    --code-comment: #8e8b81;
+  }
+}
+
+/* An explicit choice, wherever it is made. `:root[data-theme]` is the viewer's
+   own switch; the bare attribute selector is what `<CodegraphUi theme="dark">`
+   sets on its wrapper — custom properties inherit, so redefining them on a
+   container re-themes that subtree without touching :root. A host can therefore
+   put a light reader inside a dark application, and the reverse. */
+:root[data-theme='dark'],
+[data-theme='dark'] {
+  --paper: #16150f;
+  --paper-2: #1c1a14;
+  --press: #23211a;
+  --press-2: #2c2a22;
+  --ink: #f3f1ea;
+  --ink-2: #b8b5a8;
+  --ink-3: #87847a;
+  --ink-4: #5d5b52;
+  --rule: #f3f1ea;
+  --rule-soft: #34322a;
+  --rule-faint: #26241d;
+  --accent: #d48b96;
+  --accent-ink: #e5a5ae;
+  --accent-soft: #33201f;
+  --accent-line: #6b3a42;
+  --amber: #d9a94a;
+  --amber-soft: #2e2716;
+  --code-comment: #8e8b81;
+  color-scheme: dark;
+}
+
+/* The light values again, for a container that asks for light inside a page
+   the OS is painting dark. `:root[data-theme='light']` needs no block — the
+   media query above already excludes it. */
+[data-theme='light'] {
+  --paper: #f7f6f2;
+  --paper-2: #f1efe8;
+  --press: #e8e6dd;
+  --press-2: #dedbd0;
+  --ink: #16150f;
+  --ink-2: #56544a;
+  --ink-3: #87847a;
+  --ink-4: #b4b1a5;
+  --rule: #16150f;
+  --rule-soft: #d6d3c8;
+  --rule-faint: #e6e3d9;
+  --accent: #7a2230;
+  --accent-ink: #5e1a25;
+  --accent-soft: #f0e3e5;
+  --accent-line: #d9b3b9;
+  --amber: #8a5a0b;
+  --amber-soft: #f3e9d2;
+  --code-comment: #6a675d;
+  color-scheme: light;
+}
+
+/* ---------- Svelte Flow ----------
+   The Map and the Flow strip draw on @xyflow/svelte, which ships its own
+   blue-on-white palette in `--xy-*` variables. Mapping them onto the ink
+   ramp here — rather than in each canvas — is what stops a host from
+   seeing library defaults in the gaps our custom node and edge
+   components do not paint: the pane behind the cards, the controls, the
+   minimap, the selection ring, the attribution.
+
+   Set on :root so it reaches the portalled panes too. A host that wants
+   the library's own look overrides these after importing this file. */
+:root {
+  --xy-background-color: var(--paper);
+  --xy-background-pattern-color: var(--rule-faint);
+
+  --xy-edge-stroke: var(--ink-3);
+  --xy-edge-stroke-selected: var(--accent);
+  --xy-edge-stroke-width: 1;
+  --xy-connectionline-stroke: var(--ink-3);
+
+  --xy-node-color: var(--ink);
+  --xy-node-background-color: var(--paper);
+  --xy-node-border: 1px solid var(--rule-soft);
+  --xy-node-boxshadow-hover: none;
+  --xy-node-boxshadow-selected: none;
+  --xy-selection-background-color: var(--accent-soft);
+  --xy-selection-border: 1px solid var(--accent-line);
+
+  --xy-handle-background-color: transparent;
+  --xy-handle-border-color: transparent;
+
+  --xy-controls-button-background-color: var(--paper);
+  --xy-controls-button-background-color-hover: var(--press);
+  --xy-controls-button-color: var(--ink-2);
+  --xy-controls-button-color-hover: var(--ink);
+  --xy-controls-button-border-color: var(--rule-soft);
+  --xy-controls-box-shadow: none;
+
+  --xy-minimap-background-color: var(--paper-2);
+  --xy-minimap-mask-background-color: var(--paper);
+  --xy-minimap-node-background-color: var(--rule-soft);
+  --xy-minimap-node-stroke-color: var(--ink-3);
+
+  --xy-attribution-background-color: transparent;
+
+  --xy-resize-background-color: var(--accent);
+  --xy-error-color: var(--accent);
+}

+ 1 - 1
ui/src/lib/walk.ts

@@ -13,7 +13,7 @@
  * link reproduces the walk rather than starting a fresh one at the same symbol.
  */
 
-import { fileHref, navigate, symbolHref } from './router.svelte';
+import { fileHref, navigate, symbolHref } from './navigation';
 import { encodeTrail, trail, type HopDirection } from './trail.svelte';
 import type { EntryTarget } from './entry-model';
 

+ 589 - 0
ui/src/lib/wire.ts

@@ -0,0 +1,589 @@
+/**
+ * The wire shapes of the graph API — types only, no runtime.
+ *
+ * These mirror the server's payloads (`src/ui-server/api/`, CG-42) 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.
+ *
+ * They are also the vocabulary of {@link GraphAdapter} (`adapter.ts`): a host
+ * embedding these components answers in exactly these shapes, whether it is
+ * reading them over HTTP from `codegraph ui` or building them in-process from
+ * its own engine. Keeping them in a file with no imports and no side effects is
+ * what lets a host depend on the vocabulary without pulling in the transport.
+ */
+
+import type { WireHighlight } from './highlight';
+
+/* ---------------------------------------------------------------- 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;
+  /**
+   * Which numbering `lines` belong to. `'indexed'` — the file matches the
+   * index. `'current'` — it drifted and we asked for the bytes anyway
+   * (`ondrift: 'current'`), so nothing the graph holds about this file lines up
+   * with them. `'none'` — it drifted and no slice came back.
+   */
+  showing: 'indexed' | 'current' | 'none';
+  contentHash: string;
+  indexedAt: number;
+  generated: boolean;
+  totalLines: number | null;
+  from?: number;
+  to?: number;
+  /** Absent when the file drifted and `ondrift` was left at its default. */
+  lines?: string[];
+  truncated?: boolean;
+  reason?: string;
+  /**
+   * The same lines, classified by the server's tree-sitter parse — one entry
+   * per line, each a list of `[classId, text]` pairs indexed into `classes`.
+   * Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
+   * covers the file. See `lib/highlight.ts`.
+   */
+  highlight?: WireHighlight;
+}
+
+/* ------------------------------------------------------------- file view -- */
+
+/** A row in the file outline — a symbol, its nesting and its edge counts. */
+export interface WireOutlineEntry extends WireNodeRef {
+  /** Containing symbol within this file, or null for a top-level one. */
+  parentId: string | null;
+  /** Nesting depth from the top level of the file, starting at 0. */
+  depth: number;
+  fanIn: number;
+  fanOut: number;
+}
+
+/** One file at the far end of an import rail, with the symbols the edges name. */
+export interface WireImportRow {
+  file: string;
+  test: boolean;
+  symbols: Array<{ id: string; name: string; kind: string; line: number }>;
+  symbolCount: number;
+}
+
+export interface WireFilePayload {
+  file: {
+    path: string;
+    language: string;
+    size: number;
+    modifiedAt: number;
+    indexedAt: number;
+    contentHash: string;
+    nodeCount: number;
+    generated: boolean;
+    test: boolean;
+    errors: string[];
+    /** The file node's own id, so the viewer can open the file AS a symbol. */
+    id: string | null;
+  };
+  /** Calls made outside every definition — module-level code. */
+  topLevel: { calls: number };
+  /** The file changed on disk since it was indexed; the outline's lines shifted. */
+  drift: boolean;
+  outline: WireList<WireOutlineEntry>;
+  /** `imports` edges only — a subset of `dependencies`, with symbol names. */
+  imports: WireList<WireImportRow>;
+  importedBy: WireList<WireImportRow>;
+  /** Import statements that resolved to nothing indexed: packages, builtins. */
+  unresolvedImports: Array<{ name: string; line: number }>;
+  /** Every file this one reaches by any cross-file edge — `getFileDependencies`. */
+  dependencies: string[];
+  /** Every file that reaches into this one — `getFileDependents`. */
+  dependents: string[];
+}
+
+/* ------------------------------------------------ whole-file source view -- */
+
+/** A reference the resolver never landed: a gutter port with no destination. */
+export interface WireFileOutsideRef {
+  line: number;
+  col: number;
+  name: string;
+  kind: string;
+}
+
+/** Every edge from ONE symbol in a file to ONE symbol anywhere. */
+export interface WireFileCall {
+  /** The symbol making the calls — the file node itself for top-level code. */
+  ownerId: string;
+  ownerLine: number;
+  relation: WireRelation;
+}
+
+export interface WireFileCodePayload {
+  file: {
+    path: string;
+    language: string;
+    size: number;
+    indexedAt: number;
+    contentHash: string;
+    generated: boolean;
+    test: boolean;
+    errors: string[];
+    id: string | null;
+    /** Lines on disk now — the height of the scrolling document. */
+    totalLines: number | null;
+  };
+  drift: boolean;
+  reason?: string;
+  outline: WireList<WireOutlineEntry>;
+  calls: WireList<WireFileCall>;
+  outside: WireList<WireFileOutsideRef>;
+  /** Calls landing on a definition in this same file — the arc diagram's total. */
+  intraFileCalls: number;
+  timing: { elapsedMs: number };
+}
+
+export interface WireBlastScale {
+  maxDirect: number;
+  maxWithinHops: number;
+  hops: number;
+  sampled: number;
+  estimated: boolean;
+}
+
+/* ------------------------------------------------------- search palette -- */
+
+/** How a result's text matched the query — the server's primary sort key. */
+export type MatchKind = 'exact' | 'prefix' | 'substring' | 'qualified' | 'file' | 'related';
+
+export interface WireSearchResult extends WireNodeRef {
+  matchKind: MatchKind;
+}
+
+export interface WireSearchGroup {
+  kind: NodeKind;
+  count: number;
+  items: WireSearchResult[];
+}
+
+export interface WireSearch {
+  query: string;
+  /** The free-text part, with any `kind:` / `lang:` / `path:` filters removed. */
+  text: string;
+  filters: { kinds: string[]; languages: string[]; paths: string[]; names: string[] };
+  results: WireList<WireSearchResult>;
+  /** Kind buckets in ranked order — flattening them reproduces the ranking. */
+  groups: WireSearchGroup[];
+}
+
+export interface WireNodeRefs {
+  items: WireNodeRef[];
+  /** Ids that name nothing in this index — a stale link, not an error. */
+  missing: string[];
+}
+
+/* --------------------------------------------------------------- routes -- */
+
+/** One row of the URL -> handler map (`/api/routes`). */
+export interface WireRoute {
+  /** The route node's name, verbatim: "POST /v1/users/{id}". */
+  url: string;
+  /** The verb, when the name leads with one. Null for a file-routed page. */
+  method: string | null;
+  /** The URL without the verb — the same string as `url` when there is none. */
+  path: string;
+  handler: string;
+  handlerKind: string;
+  /** Where the request is SERVED. */
+  file: string;
+  line: number;
+  handlerId: string | null;
+  /** Where the URL is REGISTERED — the router file, which is how routes group. */
+  routeFile: string;
+  routeLine: number;
+  routeId: string;
+}
+
+export interface WireRoutes {
+  routed: boolean;
+  /** Every URL the index holds, whether or not its handler resolved. */
+  routeCount: number;
+  /** Rows in `entries` — the ones whose handler the manifest could name. */
+  shown: number;
+  truncated: boolean;
+  topHandlerFile: string | null;
+  topHandlerFileCount: number;
+  entries: WireRoute[];
+}
+
+/* ---------------------------------------------------------- entry points -- */
+
+export interface WireEntryRoute {
+  /** The route node's name, verbatim: "POST /v1/users/{id}". */
+  url: string;
+  /** The verb, when the name leads with one. Null for a file-routed page. */
+  method: string | null;
+  /** The URL without the verb — the same string as `url` when there is none. */
+  path: string;
+  handler: string;
+  handlerKind: string;
+  /** Where the request is SERVED. */
+  file: string;
+  line: number;
+  handlerId: string | null;
+  /** Where the URL is REGISTERED — the router file, which is how routes group. */
+  routeFile: string;
+  routeLine: number;
+  routeId: string;
+}
+
+export interface WireEntryFile extends WireNodeRef {
+  /** Calls and instantiations made at the top level of the file. */
+  calls: number;
+  /** Distinct other files this one's symbols reach. */
+  reaches: number;
+  /** Other files reaching into this one. Zero means nothing imports it. */
+  dependents: number;
+}
+
+export interface WireEntryHub extends WireNodeRef {
+  dependents: number;
+}
+
+export interface WireEntryTest extends WireNodeRef {
+  /** Distinct other files this test reaches — what it exercises. */
+  reaches: number;
+  /** References behind that reach. */
+  refs: number;
+}
+
+export interface WireEntryPoints {
+  /** Frameworks the resolver detected — named in the Routes header. */
+  frameworks: string[];
+  routes: {
+    routed: boolean;
+    /** Every `route` node in the graph, resolved handler or not. */
+    routeCount: number;
+    items: WireList<WireEntryRoute>;
+  };
+  /** `total` is a floor on `files` and `hubs`; on `tests` it is exact. */
+  files: WireList<WireEntryFile>;
+  tests: WireList<WireEntryTest>;
+  hubs: WireList<WireEntryHub>;
+  index: { lastIndexedAt: number | null; files: number };
+  timing: { elapsedMs: number; cached: 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;
+}
+
+/* ------------------------------------------------------------- flow strip -- */
+
+export interface WireFlowEdge extends WireEdge {
+  /** The link's label: "calls", "via callback · registered at file:line". */
+  label: string;
+  /** This hop reads callee → caller — the reader stepped UP into it. */
+  upward: boolean;
+  /** Confidence below 0.6: the link is dashed `2 3`. */
+  uncertain: boolean;
+  /** A synthesized dynamic-dispatch bridge: dashed `5 3`. */
+  synthesized: boolean;
+}
+
+export interface WireFlowSource {
+  file: string;
+  language: string;
+  from: number;
+  to: number;
+  /** Absent when `drift` — a mis-sliced window is worse than an empty card. */
+  lines?: string[];
+  highlight?: WireHighlight;
+  drift: boolean;
+  reason?: string;
+}
+
+/** The call site a card is opened at — the identifier drawn as an accent link. */
+export interface WireFlowCallRef {
+  line: number;
+  col: number | null;
+  name: string;
+  targetId: string;
+  /** The link points back at the previous card, not on to the next one. */
+  backwards: boolean;
+}
+
+export interface WireFlowHop {
+  node: WireNodeRef;
+  /** The edge from the PREVIOUS hop into this one; null on the first. */
+  edge: WireFlowEdge | null;
+  callRef: WireFlowCallRef | null;
+  source: WireFlowSource | null;
+}
+
+/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
+export interface WireBoundaryCandidate {
+  node: WireNodeRef;
+  display: string;
+  named: boolean;
+}
+
+/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
+export interface WireBoundarySite {
+  form: string;
+  label: string;
+  snippet: string;
+  line: number;
+  key: string | null;
+  keyIsType: boolean;
+  moreSites: number;
+  candidates: WireBoundaryCandidate[];
+  candidateNote: string | null;
+}
+
+export interface WireFlowContinuation {
+  node: WireNodeRef;
+  line: number | null;
+  confidence: number | null;
+}
+
+/** Where the graph stops — the strip's end cap (design spec §3.5). */
+export interface WireFlowBoundary {
+  node: WireNodeRef;
+  sites: WireBoundarySite[];
+  uncertain: WireList<WireFlowContinuation>;
+  further: WireList<WireFlowContinuation>;
+  missed: WireNodeRef[];
+}
+
+export interface WireFlow {
+  id: string;
+  /** "execute → rowToFileRecord", for the header's flow picker. */
+  label: string;
+  hops: WireFlowHop[];
+  /** Null on a flow that reaches everything it was asked about. */
+  boundary: WireFlowBoundary | null;
+  /** One card at the dispatch site, not a path: the answer ran out here. */
+  partial: boolean;
+}
+
+export interface WireFlowAmbiguity {
+  token: string;
+  chosen: WireNodeRef | null;
+  others: WireNodeRef[];
+}
+
+export interface WireFlowPayload {
+  query: {
+    kind: 'directed' | 'symbols' | 'trail';
+    from: string | null;
+    to: string | null;
+    symbols: string[];
+  };
+  flows: WireFlow[];
+  ambiguous: WireFlowAmbiguity[];
+  /** Tokens that named nothing in this index. */
+  unresolved: string[];
+  /** Why there is no flow, when there is none. */
+  reason: string | null;
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
+/* -------------------------------------------------------------- the map -- */
+
+export interface WireMapModule {
+  /** Directory path, the `(root files)` bucket, or a façade file's own path. */
+  id: string;
+  label: string;
+  files: number;
+  symbols: number;
+  languages: Array<{ language: string; files: number }>;
+  /** More than half its files are tests. */
+  test: boolean;
+  /** A single file kept out of the root bucket because it is the façade. */
+  facade: boolean;
+  /** Its files, capped — the side panel's list when the module is selected. */
+  fileList: { total: number; shown: number; truncated: boolean; items: string[] };
+}
+
+export interface WireMapLink {
+  source: string;
+  target: string;
+  /** Every confident cross-module edge behind this link. */
+  count: number;
+  /**
+   * The subset resolved through an import, a qualified name, an inheritance
+   * clause or a typed receiver — what the layering trusts.
+   */
+  declared: number;
+  byKind: Array<{ kind: EdgeKind; count: number }>;
+  topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
+}
+
+export interface WireMapCycle {
+  size: number;
+  files: string[];
+  modules: string[];
+}
+
+export interface WireMapPayload {
+  root: string;
+  depth: number;
+  roots: Array<{ root: string; label: string; files: number }>;
+  modules: WireMapModule[];
+  links: WireMapLink[];
+  cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
+  excluded: { uncertainEdges: number; confidenceBelow: number };
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number; cached: boolean };
+}

+ 1 - 1
ui/src/views/EntryView.svelte

@@ -19,7 +19,7 @@
   import EntrySection from '../components/entry/EntrySection.svelte';
   import { palette } from '../lib/palette.svelte';
   import { buildEntryPanel, flowPair, type EntryRow } from '../lib/entry-model';
-  import { flowHref, navigate } from '../lib/router.svelte';
+  import { flowHref, navigate } from '../lib/navigation';
   import { openEntryTarget } from '../lib/walk';
 
   interface Props {

+ 1 - 1
ui/src/views/FileView.svelte

@@ -29,7 +29,7 @@
     buildFileRail,
     fileMetaLine,
   } from '../lib/file-model';
-  import { fileHref, navigate } from '../lib/router.svelte';
+  import { fileHref, navigate } from '../lib/navigation';
   import { liveRefresh } from '../lib/live.svelte';
   import { plural } from '../lib/symbol-model';
   import { walkTo } from '../lib/walk';

+ 1 - 1
ui/src/views/FlowView.svelte

@@ -23,7 +23,7 @@
   import { exportFilename, flowSvg } from '../lib/export-svg';
   import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
   import { live } from '../lib/live.svelte';
-  import { navigate, symbolHref } from '../lib/router.svelte';
+  import { navigate, symbolHref } from '../lib/navigation';
   import { trail, encodeTrail, type TrailHop } from '../lib/trail.svelte';
   import { decodeTrail } from '../lib/trail-codec';
   import { buildFlowLayout, type FlowCardLayout, type FlowLayout } from '../lib/flow-model';

+ 1 - 1
ui/src/views/HomeView.svelte

@@ -16,7 +16,7 @@
   import PaletteRows from '../components/PaletteRows.svelte';
   import { palette } from '../lib/palette.svelte';
   import { buildEntryPalette, type PaletteItem } from '../lib/search-model';
-  import { entryHref, fileHref, flowHref, navigate } from '../lib/router.svelte';
+  import { entryHref, fileHref, flowHref, navigate } from '../lib/navigation';
   import { openEntryTarget, walkTo } from '../lib/walk';
 
   interface Props {

+ 1 - 1
ui/src/views/MapView.svelte

@@ -21,7 +21,7 @@
   import { exportFilename, mapSvg } from '../lib/export-svg';
   import { fetchMap, type WireMapPayload } from '../lib/api';
   import { live } from '../lib/live.svelte';
-  import { mapHref, navigate } from '../lib/router.svelte';
+  import { mapHref, navigate } from '../lib/navigation';
   import {
     buildMapLayout,
     isEdgeVisible,

+ 1 - 1
ui/src/views/SymbolView.svelte

@@ -50,7 +50,7 @@
   } from '../lib/symbol-model';
   import { encodeTrail, trail } from '../lib/trail.svelte';
   import { liveRefresh } from '../lib/live.svelte';
-  import { fileHref, navigate, symbolHref } from '../lib/router.svelte';
+  import { fileHref, navigate, symbolHref } from '../lib/navigation';
   import { arrivedFrom, walkTo } from '../lib/walk';
 
   interface Props {

+ 6 - 0
vitest.config.ts → vitest.config.mts

@@ -1,5 +1,11 @@
 import { defineConfig } from 'vitest/config';
 
+/**
+ * The SHARED base. `vitest.workspace.mts` extends it twice — once for the
+ * engine's node-environment suites and once for the viewer package's jsdom
+ * one — so the environment, the plugins and the module-resolution conditions
+ * a browser test needs cannot leak into the other 200-odd suites.
+ */
 export default defineConfig({
   test: {
     globals: true,

+ 63 - 0
vitest.workspace.mts

@@ -0,0 +1,63 @@
+import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+import { defineWorkspace } from 'vitest/config';
+
+/**
+ * Two projects, one command (`npm test` still runs everything).
+ *
+ * The split exists because of exactly one suite. `ui-package.test.ts` mounts
+ * `@colbymchenry/codegraph-ui`'s components against a mock adapter (task
+ * CG-61), and to do that it needs three things the engine's suites must never
+ * see:
+ *
+ *   - the **Svelte plugin**, to compile `.svelte` and `.svelte.ts` modules;
+ *   - **jsdom**, because a component without a document is not a render;
+ *   - `resolve.conditions: ['browser']`, so `svelte` resolves to its client
+ *     build rather than its server one (`mount()` throws on the server).
+ *
+ * That last one is why this is a workspace rather than one config with a
+ * couple of extra fields. `browser` is a package-resolution condition, not a
+ * test setting: applied globally it would also hand the engine's suites the
+ * browser builds of `web-tree-sitter` and friends, and the failures that
+ * causes look nothing like their cause.
+ *
+ * The engine project `extends` the shared base, so the env vars and Node guard
+ * in `vitest.config.mts` still apply to every engine test. The ui project does
+ * not — see the note on it.
+ */
+export default defineWorkspace([
+  {
+    extends: './vitest.config.mts',
+    test: {
+      name: 'engine',
+      include: ['__tests__/**/*.test.ts'],
+      exclude: ['**/node_modules/**', '**/dist/**', '__tests__/ui-package.test.ts'],
+    },
+  },
+  {
+    // Deliberately NOT `extends`: a workspace project CONCATENATES the base's
+    // `include` with its own, so extending here would run all 200-odd engine
+    // suites a second time inside jsdom (and two of them fail there, for
+    // reasons that have nothing to do with anything). This project stands
+    // alone, and it needs none of the base's spawn-related env anyway.
+    plugins: [
+      // The same preprocessor `ui/svelte.config.js` builds with, so the test
+      // compiles what the package ships.
+      svelte({ preprocess: vitePreprocess() }),
+    ],
+    resolve: { conditions: ['browser'] },
+    test: {
+      name: 'ui',
+      globals: true,
+      include: ['__tests__/ui-package.test.ts'],
+      environment: 'jsdom',
+      server: {
+        deps: {
+          // `@xyflow/svelte` ships uncompiled `.svelte` files, so it has to go
+          // through the plugin above rather than be externalised to Node,
+          // which has no idea what a `.svelte` file is.
+          inline: [/@xyflow\/svelte/],
+        },
+      },
+    },
+  },
+]);

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.