Răsfoiți Sursa

feat(expo-router): add Expo Router support for screens and navigates

Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
Colby McHenry 1 săptămână în urmă
părinte
comite
70fd5fefc2
42 a modificat fișierele cu 4257 adăugiri și 33 ștergeri
  1. 8 0
      CHANGELOG.md
  2. 1 0
      README.md
  3. 240 0
      __tests__/branch-guards.test.ts
  4. 536 0
      __tests__/expo-router.test.ts
  5. 2 1
      codegraph-kernel/src/buffers.rs
  6. 1 1
      src/context/index.ts
  7. 603 0
      src/graph/branch-guards.ts
  8. 1 1
      src/graph/dynamic-boundary-report.ts
  9. 14 4
      src/graph/named-symbol-flow.ts
  10. 2 2
      src/graph/traversal.ts
  11. 34 3
      src/mcp/tools.ts
  12. 3 0
      src/resolution/callback-synthesizer.ts
  13. 184 0
      src/resolution/expo-router-synthesizer.ts
  14. 673 0
      src/resolution/frameworks/expo-router.ts
  15. 4 0
      src/resolution/frameworks/index.ts
  16. 3 1
      src/resolution/index.ts
  17. 10 1
      src/resolution/types.ts
  18. 1 0
      src/types.ts
  19. 34 2
      src/ui-server/api/flow.ts
  20. 12 4
      src/ui-server/api/index.ts
  21. 13 3
      src/ui-server/api/map.ts
  22. 9 1
      src/ui-server/api/node.ts
  23. 500 0
      src/ui-server/api/screens.ts
  24. 75 0
      src/ui-server/api/when.ts
  25. 7 0
      src/ui-server/api/wire.ts
  26. 14 1
      ui/src/App.svelte
  27. 6 3
      ui/src/components/TopBar.svelte
  28. 113 0
      ui/src/components/screens/ScreenEdge.svelte
  29. 137 0
      ui/src/components/screens/ScreenNode.svelte
  30. 7 0
      ui/src/components/symbol/CalleeRail.svelte
  31. 15 0
      ui/src/components/symbol/CallersRail.svelte
  32. 7 0
      ui/src/lib/adapter.ts
  33. 5 0
      ui/src/lib/api.ts
  34. 3 1
      ui/src/lib/entry-model.ts
  35. 28 3
      ui/src/lib/map-model.ts
  36. 9 0
      ui/src/lib/navigation.ts
  37. 5 0
      ui/src/lib/router.svelte.ts
  38. 277 0
      ui/src/lib/screens-model.ts
  39. 3 1
      ui/src/lib/search-model.ts
  40. 18 0
      ui/src/lib/symbol-model.ts
  41. 52 0
      ui/src/lib/wire.ts
  42. 588 0
      ui/src/views/ScreensView.svelte

+ 8 - 0
CHANGELOG.md

@@ -14,6 +14,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **A Screens tab in `codegraph ui` — the app the way its user meets it.** One box per screen, an arrow for every way of getting from one to another, and on each arrow the condition under which it happens: `/home → /object-detail when !isUploading && isCollected`. Hover an arrow for the chain the tap travels through (`HomeSearchResults → ItemCard → openObjectDetail`), click a screen for everything that opens it and everything it opens, with a link to each navigation call. Shared chrome (a top bar rendered on ten screens) collapses to one node rather than the same arrows from every box; a helper that chooses the destination after login shows its fork. Projects whose graph holds screen navigation land on this tab. Expo Router apps today.
+
+- **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root.
+
+- **Every call now says when it happens.** In `codegraph ui`, a symbol's callee and caller rails and the Flow strip's connectors carry the branch conditions the call site sits under — `when !isUploading && isCollected` — and `codegraph_explore`'s Flow section prints the same on each hop (`↓ calls (when isCollected)`). The conditions come from the `if` / `else` / ternary / `switch` / `&&` branches around the call, the early returns before it (`if (busy) return` reads as `!busy`), and Swift's `guard`; an inline callback inherits the conditions of the place it is defined. Read from the source as it is now, never stored: nothing about your index changes. TypeScript, JavaScript and Swift today.
+
+- **Expo Router apps: screens and navigation are in the graph.** Every screen file under `app/` (or `src/app/`) is now a route node named by its path — `/object-detail`, `/item/[id]`, with `(group)` folders stripped — linked to the component it renders. Calls like `router.push('/object-detail?…')`, `router.navigate({ pathname: '/item/[id]', params })`, template-literal hrefs, an href held in a local `const`, and `router.push(await pickRoute())` where the helper returns screen paths (one edge per screen it can return) resolve to the screen they open as a new `navigates` edge that remembers the href, so "where does tapping this go" and "who opens this screen" are one hop in `codegraph_explore`, `callers`, and the viewer's Flow strip instead of a dead end at a string. Re-index after upgrading to pick the new edges up.
+
 - **Read your graph in a browser: `codegraph ui`.** Point it at a project you've already indexed and it opens a viewer for it on your own machine. Pick a symbol and you see who calls it on the left, its real source in the middle with a marker on every line that calls something, and what it calls on the right, each one drawn level with the line that calls it. Hover either end and both light up; click anything to step into it. Test callers fold into a single line so real callers stay in view, edges CodeGraph isn't confident about are folded away as "uncertain" rather than shown as fact, and a symbol no test reaches within three caller hops says so on a badge. A blast-radius strip counts what a change would reach. Search with `/` or Cmd-K across every symbol and file, start from suggested entry points (routes, hubs, files that run code when imported), and follow a trail of the path you walked that lives in the URL, so you can send someone the exact route you took. Click any file path for that file's outline in source order between everything it depends on and everything that depends on it.
 
   Run `codegraph ui` in an indexed project, or `codegraph ui /path/to/project` for one indexed elsewhere (`codegraph web` is an alias). It takes port 4747, or the next free one; `--port <n>` pins a specific port and `--no-open` just prints the URL for a headless box or an SSH session. Set `CODEGRAPH_BROWSER=<command>` to choose the browser, or `CODEGRAPH_BROWSER=none` to never open one.

+ 1 - 0
README.md

@@ -392,6 +392,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
 | **ASP.NET** | `[HttpGet("/x")]` attributes on action methods |
 | **Vapor** | `app.get("x", use: handler)` |
 | **React Router** / **SvelteKit** | Route component nodes |
+| **Expo Router** | Every screen file under `app/` (`app/item/[id].tsx` → `/item/[id]`, groups stripped) becomes a route node bound to its default-export component; `router.push/replace/navigate('/path')`, template hrefs, and `{ pathname }` objects become `navigates` edges to the screen — so "where does tapping this go" is one hop in the graph |
 | **Vue Router** / **Nuxt** | `pages/` file-based routes, `server/api/` endpoints, route middleware |
 | **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) |
 

+ 240 - 0
__tests__/branch-guards.test.ts

@@ -0,0 +1,240 @@
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars } from '../src/extraction/grammars';
+import { guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards';
+import { buildNode } from '../src/ui-server/api/node';
+import { buildFlow } from '../src/ui-server/api/flow';
+
+beforeAll(async () => {
+  await initGrammars();
+});
+
+/** Line (1-based) of the first line containing `needle`. */
+function lineOf(src: string, needle: string): number {
+  const i = src.split('\n').findIndex((l) => l.includes(needle));
+  if (i < 0) throw new Error(`no line contains ${needle}`);
+  return i + 1;
+}
+
+async function labelAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return guardLabel(await guardsInSource(src, language, line, column));
+}
+
+describe('branch guards: JS/TS', () => {
+  const handlePress = `
+export function ItemCard(props) {
+  const handlePress = useCallback(() => {
+    if (isUploading) return
+    if (isCollected) {
+      openObjectDetail(item, folderName)
+      return
+    }
+    if (queueHasItems) {
+      handleAddToQueue()
+      return
+    }
+    handleStartCapture()
+  }, [])
+  return null
+}
+`;
+
+  it('reads an if branch and the early-return guards before it', async () => {
+    expect(await labelAt(handlePress, 'openObjectDetail(')).toBe('!isUploading && isCollected');
+  });
+
+  it('turns each earlier early-return into a negated guard, in source order', async () => {
+    expect(await labelAt(handlePress, 'handleAddToQueue(')).toBe('!isUploading && !isCollected && queueHasItems');
+    expect(await labelAt(handlePress, 'handleStartCapture(')).toBe('!isUploading && !isCollected && !queueHasItems');
+  });
+
+  it('does not climb past a function that is declared or assigned to a name', async () => {
+    const src = `
+function outer() {
+  if (outerCond) {
+    const cb = () => {
+      if (inner) run()
+    }
+    function named() { if (deep) walk() }
+  }
+}`;
+    expect(await labelAt(src, 'run()')).toBe('inner');
+    expect(await labelAt(src, 'walk()')).toBe('deep');
+  });
+
+  it('an inline callback inherits the conditions its definition sits under', async () => {
+    const src = `
+function verify(total) {
+  if (selectedHasBarcode) {
+    if (total > 1) {
+      return { proceed: () => router.navigate('/barcode-matches') }
+    }
+    return { ok: true, proceed: () => captureObject(item) }
+  }
+  list.forEach((x) => { if (x.ok) keep(x) })
+}`;
+    expect(await labelAt(src, 'captureObject(item)')).toBe('selectedHasBarcode && !(total > 1)');
+    expect(await labelAt(src, "router.navigate(")).toBe('selectedHasBarcode && total > 1');
+    expect(await labelAt(src, 'keep(x)')).toBe('!selectedHasBarcode && x.ok');
+  });
+
+  it('reads else, else-if, and the arms of a ternary', async () => {
+    const src = `
+function f() {
+  if (a) { one() } else if (b) { two() } else { three() }
+  const x = ready ? go() : wait()
+}`;
+    expect(await labelAt(src, 'one()')).toBe('a');
+    expect(await labelAt(src, 'two()')).toBe('!a && b');
+    expect(await labelAt(src, 'three()')).toBe('!a && !b');
+    expect(await labelAt(src, 'go()')).toBe('ready');
+    expect(await labelAt(src, 'wait()')).toBe('!ready');
+  });
+
+  it('reads switch cases, && / || short-circuits, and catch', async () => {
+    const src = `
+function f() {
+  switch (mode) {
+    case 'verify': scan(); break
+    default: capture()
+  }
+  ok && fire()
+  ok || fallback()
+  try { risky() } catch (e) { report(e) }
+}`;
+    expect(await labelAt(src, 'scan()')).toBe("mode === 'verify'");
+    expect(await labelAt(src, 'capture()')).toBe('mode: default');
+    expect(await labelAt(src, 'fire()')).toBe('ok');
+    expect(await labelAt(src, 'fallback()')).toBe('!ok');
+    expect(await labelAt(src, 'report(e)')).toBe('on error');
+    expect(await labelAt(src, 'risky()')).toBe('');
+  });
+
+  it('negates readably: a bare !x guard reads as x, a compound one is parenthesised', async () => {
+    const src = `
+function f() {
+  if (!ready) return
+  if (a && b) { } else { alt() }
+  if (count > 0) go()
+  if (options?.verify !== false && (item.barcodes?.length ?? 0) > 0) verify()
+}`;
+    expect(await labelAt(src, 'alt()')).toBe('ready && !(a && b)');
+    expect(await labelAt(src, 'go()')).toBe('ready && count > 0');
+    expect(await labelAt(src, 'verify()')).toBe('ready && options?.verify !== false && (item.barcodes?.length ?? 0) > 0');
+  });
+
+  it('a call inside a condition is not guarded by that condition', async () => {
+    const src = `
+function f() {
+  if (isReady()) run()
+}`;
+    expect(await labelAt(src, 'isReady()')).toBe('');
+    expect(await labelAt(src, 'run()')).toBe('isReady()');
+  });
+
+  it('an if whose body does not always exit is not a guard', async () => {
+    const src = `
+function f() {
+  if (x) { log() }
+  go()
+}`;
+    expect(await labelAt(src, 'go()')).toBe('');
+  });
+
+  it('caps a very long condition', async () => {
+    const cond = 'a'.repeat(120);
+    const src = `function f() {\n  if (${cond}) go()\n}`;
+    const label = await labelAt(src, 'go()');
+    expect(label.length).toBeLessThan(90);
+    expect(label.endsWith('…')).toBe(true);
+  });
+});
+
+describe('branch guards: Swift', () => {
+  it('reads guard, if/else, ternary and switch', async () => {
+    const src = `
+func decide() {
+  guard ready else { bail(); return }
+  if isCollected { open() } else if other { two() } else { close() }
+  let x = flag ? a() : b()
+  switch mode { case .verify: scan() default: capture() }
+}`;
+    expect(await labelAt(src, 'bail()', 'swift')).toBe('!ready');
+    expect(await labelAt(src, 'open()', 'swift')).toBe('ready && isCollected');
+    expect(await labelAt(src, 'two()', 'swift')).toBe('ready && !isCollected && other');
+    expect(await labelAt(src, 'close()', 'swift')).toBe('ready && !isCollected && !other');
+    expect(await labelAt(src, 'a()', 'swift')).toBe('ready && flag');
+    expect(await labelAt(src, 'b()', 'swift')).toBe('ready && !flag');
+    expect(await labelAt(src, 'scan()', 'swift')).toBe('ready && mode == .verify');
+    expect(await labelAt(src, 'capture()', 'swift')).toBe('ready && mode: default');
+  });
+
+  it('joins multi-clause conditions and treats an early return as a guard', async () => {
+    const src = `
+func f() {
+  if let item = current, item.count > 0 { use(item) }
+  if busy { return }
+  go()
+}`;
+    expect(await labelAt(src, 'use(item)', 'swift')).toBe('let item = current, item.count > 0');
+    expect(await labelAt(src, 'go()', 'swift')).toBe('!busy');
+  });
+});
+
+describe('branch guards: unsupported', () => {
+  it('reports no guards for a language without rules', async () => {
+    expect(supportsBranchGuards('python')).toBe(false);
+    expect(await guardsInSource('def f():\n  if x:\n    go()\n', 'python', 3, 4)).toEqual([]);
+  });
+});
+
+describe('branch guards: on the wire', () => {
+  let dir: string | undefined;
+  afterEach(() => {
+    if (dir) fs.rmSync(dir, { recursive: true, force: true });
+    dir = undefined;
+  });
+
+  it('labels symbol-view rails and flow connectors with the call site\'s conditions', async () => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-when-'));
+    fs.mkdirSync(path.join(dir, 'src'));
+    fs.writeFileSync(
+      path.join(dir, 'src', 'app.ts'),
+      'export function helper() { return 1 }\n' +
+        'export function other() { return 2 }\n' +
+        'export function run(ready: boolean, busy: boolean) {\n' +
+        '  if (busy) return\n' +
+        '  if (ready) {\n' +
+        '    helper()\n' +
+        '  } else {\n' +
+        '    other()\n' +
+        '  }\n' +
+        '}\n'
+    );
+    const cg = CodeGraph.initSync(dir);
+    await cg.indexAll();
+    const run = cg.getNodesByName('run')[0]!;
+    const helper = cg.getNodesByName('helper')[0]!;
+
+    type Rel = { node: { name: string }; edges: Array<{ when?: string }> };
+    const view = (await buildNode(cg, dir, run.id)) as { outgoing: { items: Rel[] } };
+    const byName = new Map(view.outgoing.items.map((r) => [r.node.name, r]));
+    expect(byName.get('helper')?.edges[0]?.when).toBe('!busy && ready');
+    expect(byName.get('other')?.edges[0]?.when).toBe('!busy && !ready');
+
+    const callee = (await buildNode(cg, dir, helper.id)) as { incoming: { items: Rel[] } };
+    expect(callee.incoming.items.find((r) => r.node.name === 'run')?.edges[0]?.when).toBe('!busy && ready');
+
+    const flow = await buildFlow(cg, dir, new URLSearchParams('from=run&to=helper'));
+    const hop = flow.flows[0]!.hops[1]!;
+    expect(hop.edge?.when).toBe('!busy && ready');
+    expect(hop.edge?.label).toBe('calls · when !busy && ready');
+
+    cg.close();
+  });
+});

+ 536 - 0
__tests__/expo-router.test.ts

@@ -0,0 +1,536 @@
+import { describe, it, expect, beforeAll, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildScreens } from '../src/ui-server/api/screens';
+import {
+  expoRouterResolver,
+  routePathForFile,
+  defaultExportName,
+  readHrefArgument,
+  readHrefViaLocal,
+  normalizeHrefPath,
+} from '../src/resolution/frameworks/expo-router';
+import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
+import type { Node } from '../src/types';
+
+// =============================================================================
+// Route paths from file names
+// =============================================================================
+
+describe('expo-router: routePathForFile', () => {
+  it.each([
+    ['app/index.tsx', '/'],
+    ['src/app/index.tsx', '/'],
+    ['src/app/object-detail.tsx', '/object-detail'],
+    ['src/app/capture/index.tsx', '/capture'],
+    ['src/app/capture/review/index.tsx', '/capture/review'],
+    ['src/app/sheets/need-help.tsx', '/sheets/need-help'],
+    ['src/app/item/[id].tsx', '/item/[id]'],
+    ['src/app/docs/[...slug].tsx', '/docs/[...slug]'],
+    ['src/app/(tabs)/home.tsx', '/home'],
+    ['src/app/(auth)/(stack)/login.tsx', '/login'],
+    ['src/app/+not-found.tsx', '/+not-found'],
+    ['src/app/settings.ios.tsx', '/settings'],
+    ['src/app/legacy.js', '/legacy'],
+    ['apps/mobile/src/app/home.tsx', '/home'],
+  ])('%s → %s', (file, route) => {
+    expect(routePathForFile(file)).toBe(route);
+  });
+
+  it.each([
+    'src/app/_layout.tsx',
+    'src/app/(tabs)/_layout.tsx',
+    'src/app/+html.tsx',
+    'src/app/+native-intent.tsx',
+    'src/app/_private-helper.ts',
+    'src/app/home.test.tsx',
+    'src/app/__tests__/home.tsx',
+    'src/app/types.d.ts',
+    'src/app/styles.css',
+    'src/components/app/thing.tsx'.replace('components/app/', 'components/'), // no app dir
+    'src/appearance/theme.tsx',
+  ])('%s is not a screen', (file) => {
+    expect(routePathForFile(file)).toBeNull();
+  });
+});
+
+// =============================================================================
+// Default export → screen name
+// =============================================================================
+
+describe('expo-router: defaultExportName', () => {
+  it.each([
+    ['export default function ObjectDetail() {}', 'ObjectDetail'],
+    ['export default async function Screen() {}', 'Screen'],
+    ['export default class Legacy extends React.Component {}', 'Legacy'],
+    ['function Home() {}\nexport default Home', 'Home'],
+    ['function Home() {}\nexport default Home;', 'Home'],
+    ['export default memo(Home)', 'Home'],
+    ['export default React.memo(Home)', 'Home'],
+    ['export default observer(Home, opts)', 'Home'],
+    ['export { Home as default }', 'Home'],
+  ])('%s → %s', (src, name) => {
+    expect(defaultExportName(src)?.name).toBe(name);
+  });
+
+  it('yields null for an anonymous default export', () => {
+    expect(defaultExportName('export default () => null')).toBeNull();
+    expect(defaultExportName('export default function () {}')).toBeNull();
+  });
+});
+
+// =============================================================================
+// Reading the href argument
+// =============================================================================
+
+describe('expo-router: readHrefArgument', () => {
+  const read = (src: string, method = 'push', line = 1, column = 0) =>
+    readHrefArgument(src.split('\n'), line, column, method);
+
+  it('reads a plain string', () => {
+    expect(read("router.push('/capture-queue')")).toEqual({
+      path: '/capture-queue',
+      display: '/capture-queue',
+    });
+  });
+
+  it('drops the query string and hash from the path but keeps them for display', () => {
+    expect(read('router.push("/sheets/setup-guide?kind=lighting")')).toEqual({
+      path: '/sheets/setup-guide',
+      display: '/sheets/setup-guide?kind=lighting',
+    });
+  });
+
+  it('reads a template literal, keeping the static prefix and marking holes', () => {
+    const src =
+      'router.navigate(\n' +
+      '  `/object-detail?detectionItem=${encodeParam(JSON.stringify(item))}${folderParam}` as any\n' +
+      ')';
+    expect(read(src, 'navigate')).toEqual({
+      path: '/object-detail',
+      display: '/object-detail?detectionItem=${…}${…}',
+    });
+  });
+
+  it('keeps a hole that sits in the path itself', () => {
+    const r = read('router.push(`/terms-of-service/term/${id}`)');
+    expect(r?.display).toBe('/terms-of-service/term/${…}');
+    expect(r?.path.startsWith('/terms-of-service/term/')).toBe(true);
+  });
+
+  it('reads pathname out of an Href object', () => {
+    const src =
+      "router.push({\n  pathname: '/detection/result/[id]',\n  params: { id: result.id },\n})";
+    expect(read(src)).toEqual({
+      path: '/detection/result/[id]',
+      display: '/detection/result/[id]',
+    });
+  });
+
+  it('reads both arms of a conditional argument', () => {
+    const src =
+      'router.navigate(\n' +
+      '  (folder.id\n' +
+      "    ? `/sheets/create-detection-item?folderId=${folder.id}`\n" +
+      "    : '/sheets/create-detection-item') as any\n" +
+      ')';
+    const r = read(src, 'navigate');
+    expect(r?.path).toBe('/sheets/create-detection-item');
+    expect(r?.display).toBe('/sheets/create-detection-item?folderId=${…}');
+    expect(r?.alternate?.path).toBe('/sheets/create-detection-item');
+  });
+
+  it('returns null when one arm of a conditional is not a literal', () => {
+    expect(read("router.push(ready ? '/home' : fallback)")).toBeNull();
+  });
+
+  it('reads only the first argument', () => {
+    expect(read("router.push('/home', { withAnchor: true })")?.path).toBe('/home');
+  });
+
+  it('starts scanning at the column so an earlier call on the line is skipped', () => {
+    const src = "list.push(x); router.push('/home')";
+    expect(read(src, 'push', 1, src.indexOf('router'))?.path).toBe('/home');
+  });
+
+  it('returns null for a non-literal argument', () => {
+    expect(read('router.push(href)')).toBeNull();
+    expect(read('router.push(buildHref(item))')).toBeNull();
+    expect(read('router.push({ pathname, params })')).toBeNull();
+    expect(read('router.push()')).toBeNull();
+  });
+
+  it('does not run past the call: a later literal is not this call\'s argument', () => {
+    expect(read("router.back()\nrouter.push('/home')", 'back')).toBeNull();
+  });
+});
+
+describe('expo-router: readHrefViaLocal', () => {
+  const viaLocal = (src: string, method = 'navigate') => {
+    const lines = src.split('\n');
+    const line = lines.findIndex((l) => l.includes(`.${method}(`)) + 1;
+    return readHrefViaLocal(lines, line, 0, method, 1);
+  };
+
+  it('reads a local const assigned a literal', () => {
+    expect(viaLocal("function f() {\n  const href = '/home'\n  router.navigate(href as any)\n}")?.path).toBe('/home');
+  });
+
+  it('reads a multi-line ternary initializer whose arms are literals', () => {
+    const src =
+      'function f(params) {\n' +
+      '  const href = params.length\n' +
+      '    ? `/barcode-scan?${params.join("&")}`\n' +
+      "    : '/barcode-scan'\n" +
+      '  if (options?.replace) {\n' +
+      '    router.navigate(href as any)\n' +
+      '  }\n}';
+    const r = viaLocal(src);
+    expect(r?.path).toBe('/barcode-scan');
+    expect(r?.alternate?.path).toBe('/barcode-scan');
+  });
+
+  it('reads a typed declaration and an Href object initializer', () => {
+    expect(viaLocal("const href: Href = '/home'\nrouter.navigate(href)")?.path).toBe('/home');
+    expect(viaLocal("const href = { pathname: '/item/[id]', params: { id } }\nrouter.navigate(href)")?.path).toBe('/item/[id]');
+  });
+
+  it('refuses a computed initializer, a reassignment, and a non-identifier argument', () => {
+    expect(viaLocal("const href = build()\nrouter.navigate(href)")).toBeNull();
+    expect(viaLocal("const href = '/home'\nhref = other\nrouter.navigate(href)")).toBeNull();
+    expect(viaLocal("router.navigate(a.b)")).toBeNull();
+  });
+
+  it('is not confused by ?. and ?? in an initializer', () => {
+    expect(viaLocal("const href = options?.href ?? '/home'\nrouter.navigate(href)")).toBeNull();
+  });
+});
+
+// =============================================================================
+// Href normalization
+// =============================================================================
+
+describe('expo-router: normalizeHrefPath', () => {
+  it('strips trailing slash and group segments, decodes segments', () => {
+    expect(normalizeHrefPath('/capture/', 'src/services/nav.ts')).toEqual(['capture']);
+    expect(normalizeHrefPath('/(tabs)/home', 'src/services/nav.ts')).toEqual(['home']);
+    expect(normalizeHrefPath('/a%20b', 'src/services/nav.ts')).toEqual(['a b']);
+    expect(normalizeHrefPath('/', 'src/services/nav.ts')).toEqual([]);
+  });
+
+  it('resolves a relative href against the screen the call is in', () => {
+    expect(normalizeHrefPath('./review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
+    expect(normalizeHrefPath('review', 'src/app/capture/index.tsx')).toEqual(['capture', 'review']);
+    expect(normalizeHrefPath('../home', 'src/app/capture/review.tsx')).toEqual(['home']);
+  });
+
+  it('refuses a relative href from a non-screen file', () => {
+    expect(normalizeHrefPath('./review', 'src/services/nav.ts')).toBeNull();
+  });
+});
+
+// =============================================================================
+// extract(): route node + screen ref
+// =============================================================================
+
+describe('expo-router: extract', () => {
+  it('emits a route node named by path and a calls ref to the default export', () => {
+    const src = "import React from 'react'\n\nexport default function ObjectDetail() {\n  return null\n}\n";
+    const { nodes, references } = expoRouterResolver.extract!('src/app/object-detail.tsx', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0]!.kind).toBe('route');
+    expect(nodes[0]!.name).toBe('/object-detail');
+    expect(nodes[0]!.language).toBe('tsx');
+    expect(references).toHaveLength(1);
+    expect(references[0]!.fromNodeId).toBe(nodes[0]!.id);
+    expect(references[0]!.referenceName).toBe('ObjectDetail');
+    expect(references[0]!.referenceKind).toBe('calls');
+    expect(references[0]!.line).toBe(3);
+  });
+
+  it('emits nothing for a layout or a non-app file', () => {
+    expect(expoRouterResolver.extract!('src/app/_layout.tsx', 'export default function L() {}')).toEqual({
+      nodes: [],
+      references: [],
+    });
+    expect(expoRouterResolver.extract!('src/services/nav.ts', 'export default function x() {}')).toEqual({
+      nodes: [],
+      references: [],
+    });
+  });
+});
+
+// =============================================================================
+// resolve(): a navigation call → the route node, as a navigates edge
+// =============================================================================
+
+describe('expo-router: resolve', () => {
+  const route = (filePath: string): Node => expoRouterResolver.extract!(filePath, '').nodes[0]!;
+  const routes = [
+    route('src/app/index.tsx'),
+    route('src/app/object-detail.tsx'),
+    route('src/app/capture/index.tsx'),
+    route('src/app/item/[id].tsx'),
+    route('src/app/docs/[...slug].tsx'),
+  ];
+  const files: Record<string, string> = {
+    'src/services/nav.ts':
+      "import { router } from 'expo-router'\n" +
+      "export function openDetail(item) {\n" +
+      '  router.navigate(\n' +
+      '    `/object-detail?detectionItem=${encode(item)}` as any\n' +
+      '  )\n' +
+      '}\n' +
+      "export function openItem(id) { router.push(`/item/${id}`) }\n" +
+      "export function openDoc() { router.push({ pathname: '/docs/[...slug]', params: { slug: ['a'] } }) }\n" +
+      "export function openCapture() { router.replace('/capture/') }\n" +
+      "export function missing() { router.push('/nowhere') }\n" +
+      "export function computed(h) { router.push(h) }\n" +
+      "export function notNav(list) { list.push('/capture') }\n" +
+      "export function fork(x) { router.push(x ? '/capture' : '/object-detail') }\n" +
+      "export function sameScreen(x) { router.push(x ? '/capture?x=1' : '/capture/') }\n" +
+      "export function viaWrapper() { safePush('/capture/') }\n",
+  };
+  const context = {
+    getNodesByKind: (kind: Node['kind']) => (kind === 'route' ? routes : []),
+    getProjectRoot: () => '/proj',
+    readFile: (p: string) => files[p] ?? null,
+    getFileLines: (p: string) => files[p]?.split('\n') ?? null,
+    getAllFiles: () => Object.keys(files),
+    getNodesInFile: () => [],
+    getNodesByName: () => [],
+    getNodesByQualifiedName: () => [],
+    getNodesByLowerName: () => [],
+    fileExists: () => true,
+    getImportMappings: () => [],
+  } as unknown as ResolutionContext;
+
+  const ref = (referenceName: string, line: number, column = 0): UnresolvedRef => ({
+    fromNodeId: 'function:src',
+    referenceName,
+    referenceKind: 'calls',
+    line,
+    column,
+    filePath: 'src/services/nav.ts',
+    language: 'typescript',
+  });
+
+  it('claims router navigation method names through the name pre-filter', () => {
+    expect(expoRouterResolver.claimsReference!('router.push')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('nav.navigate')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('safePush')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('guardedNavigate')).toBe(true);
+    expect(expoRouterResolver.claimsReference!('router.back')).toBe(false);
+    expect(expoRouterResolver.claimsReference!('fetch')).toBe(false);
+    expect(expoRouterResolver.claimsReference!('Push')).toBe(false);
+  });
+
+  it('binds a project wrapper named for the verb, remembering the wrapper', () => {
+    const r = expoRouterResolver.resolve(ref('safePush', 15, 27), context);
+    expect(r?.targetNodeId).toBe(routes[2]!.id);
+    expect(r?.metadata).toEqual({ href: '/capture/', navMethod: 'push', via: 'safePush' });
+  });
+
+  it('binds a multi-line template href to its route as a navigates edge with the href', () => {
+    const r = expoRouterResolver.resolve(ref('router.navigate', 3, 2), context);
+    expect(r).not.toBeNull();
+    expect(r!.targetNodeId).toBe(routes[1]!.id);
+    expect(r!.edgeKind).toBe('navigates');
+    expect(r!.resolvedBy).toBe('framework');
+    expect(r!.metadata).toEqual({ href: '/object-detail?detectionItem=${…}', navMethod: 'navigate' });
+  });
+
+  it('matches an interpolated segment against a [param] route', () => {
+    const r = expoRouterResolver.resolve(ref('router.push', 7, 29), context);
+    expect(r?.targetNodeId).toBe(routes[3]!.id);
+  });
+
+  it('matches a pathname object against a catch-all route', () => {
+    const r = expoRouterResolver.resolve(ref('router.push', 8, 28), context);
+    expect(r?.targetNodeId).toBe(routes[4]!.id);
+  });
+
+  it('normalizes a trailing slash onto an index route', () => {
+    const r = expoRouterResolver.resolve(ref('router.replace', 9, 32), context);
+    expect(r?.targetNodeId).toBe(routes[2]!.id);
+  });
+
+  it('returns null for a path with no screen and for a computed href', () => {
+    expect(expoRouterResolver.resolve(ref('router.push', 10, 28), context)).toBeNull();
+    expect(expoRouterResolver.resolve(ref('router.push', 11, 30), context)).toBeNull();
+  });
+
+  it('gates on the string naming a real screen, not on the receiver being called router', () => {
+    // `const nav = useRouter(); nav.push('/x')` must bind, so the receiver is
+    // not consulted; a non-router `push` of a real screen path binds too.
+    expect(expoRouterResolver.resolve(ref('list.push', 12, 32), context)?.targetNodeId).toBe(routes[2]!.id);
+  });
+
+  it('binds a conditional whose arms name the same screen, refuses one that forks', () => {
+    expect(expoRouterResolver.resolve(ref('router.push', 14, 33), context)?.targetNodeId).toBe(routes[2]!.id);
+    expect(expoRouterResolver.resolve(ref('router.push', 13, 27), context)).toBeNull();
+  });
+
+  it('ignores refs that are not calls or not JS/TS', () => {
+    expect(
+      expoRouterResolver.resolve({ ...ref('router.push', 9, 32), referenceKind: 'references' }, context)
+    ).toBeNull();
+    expect(expoRouterResolver.resolve({ ...ref('router.push', 9, 32), language: 'swift' }, context)).toBeNull();
+  });
+});
+
+// =============================================================================
+// End to end: index a small Expo app and walk tap → screen
+// =============================================================================
+
+describe('expo-router: end-to-end', () => {
+  beforeAll(async () => {
+    await initGrammars();
+    await loadAllGrammars();
+  });
+
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  function write(rel: string, content: string) {
+    const full = path.join(tmpDir!, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  it('connects a component tap to the screen it navigates to', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-router-'));
+    write(
+      'package.json',
+      JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } })
+    );
+    write('src/app/_layout.tsx', "export default function Layout() { return null }\n");
+    write(
+      'src/app/index.tsx',
+      "import { ItemCard } from '../components/item-card'\n" +
+        'export default function Home() {\n' +
+        "  return <ItemCard item={{ id: '1' }} collected />\n" +
+        '}\n'
+    );
+    write(
+      'src/app/object-detail.tsx',
+      "export default function ObjectDetail() {\n  return null\n}\n"
+    );
+    write('src/app/item/[id].tsx', "export default function Item() { return null }\n");
+    write(
+      'src/services/nav.ts',
+      "import { router } from 'expo-router'\n" +
+        'export function openObjectDetail(item: { id: string }) {\n' +
+        '  router.navigate(\n' +
+        '    `/object-detail?detectionItem=${JSON.stringify(item)}` as any\n' +
+        '  )\n' +
+        '}\n' +
+        'export function openItem(id: string) {\n' +
+        "  router.push({ pathname: '/item/[id]', params: { id } })\n" +
+        '}\n'
+    );
+    write(
+      'src/app/welcome.tsx',
+      "export default function Welcome() { return null }\n"
+    );
+    write(
+      'src/services/post-login.ts',
+      'export const resolvePostLoginRoute = async (): Promise<string> => {\n' +
+        "  return (await seen()) ? '/' : '/welcome/'\n" +
+        '}\n' +
+        'async function seen() { return true }\n' +
+        "export function apiPath() { return '/api/users' }\n"
+    );
+    write(
+      'src/services/login.ts',
+      "import { router } from 'expo-router'\n" +
+        "import { resolvePostLoginRoute, apiPath } from './post-login'\n" +
+        'export async function finishLogin() {\n' +
+        '  router.replace(await resolvePostLoginRoute())\n' +
+        '}\n' +
+        'export function fetchUsers() { return fetch(apiPath()) }\n'
+    );
+    write(
+      'src/components/item-card.tsx',
+      "import { openObjectDetail } from '../services/nav'\n" +
+        'export function ItemCard(props: { item: { id: string }; collected: boolean }) {\n' +
+        '  const handlePress = () => {\n' +
+        '    if (props.collected) openObjectDetail(props.item)\n' +
+        '  }\n' +
+        '  return handlePress\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const routes = cg.getNodesByKind('route');
+    expect(routes.map((r) => r.name).sort()).toEqual(['/', '/item/[id]', '/object-detail', '/welcome']);
+    const detailRoute = routes.find((r) => r.name === '/object-detail')!;
+
+    // route → its screen component
+    const screen = cg.getNodesByName('ObjectDetail').find((n) => n.kind !== 'route')!;
+    expect(screen).toBeDefined();
+    const toScreen = cg.getOutgoingEdges(detailRoute.id).find((e) => e.target === screen.id);
+    expect(toScreen?.kind).toBe('calls');
+
+    // navigation call → route, as a navigates edge that remembers the href
+    const opener = cg.getNodesByName('openObjectDetail')[0]!;
+    const nav = cg.getOutgoingEdges(opener.id).find((e) => e.target === detailRoute.id);
+    expect(nav?.kind).toBe('navigates');
+    expect(nav?.metadata?.href).toBe('/object-detail?detectionItem=${…}');
+    expect(nav?.metadata?.navMethod).toBe('navigate');
+    expect(nav?.metadata?.refKind).toBe('calls');
+
+    // the pathname-object form binds the dynamic route
+    const itemRoute = routes.find((r) => r.name === '/item/[id]')!;
+    const openItem = cg.getNodesByName('openItem')[0]!;
+    expect(cg.getOutgoingEdges(openItem.id).some((e) => e.target === itemRoute.id && e.kind === 'navigates')).toBe(true);
+
+    // the route's callers are the navigators — what "who opens this screen" asks
+    const callers = cg.getCallers(detailRoute.id);
+    expect(callers.map((c) => c.node.name)).toContain('openObjectDetail');
+
+    // `router.replace(await resolvePostLoginRoute())`: the helper's return
+    // literals become heuristic navigates edges FROM THE HELPER, one per screen,
+    // remembering the push site; the plain `calls` edge from the pusher closes
+    // the chain. A helper nothing navigates with (`apiPath`) is never read.
+    const helper = cg.getNodesByName('resolvePostLoginRoute')[0]!;
+    const fromHelper = cg.getOutgoingEdges(helper.id).filter((e) => e.kind === 'navigates');
+    expect(fromHelper.map((e) => routes.find((r) => r.id === e.target)?.name).sort()).toEqual(['/', '/welcome']);
+    expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
+    expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
+    expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
+    const finishLogin = cg.getNodesByName('finishLogin')[0]!;
+    expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
+    const apiPath = cg.getNodesByName('apiPath')[0]!;
+    expect(cg.getOutgoingEdges(apiPath.id).some((e) => e.kind === 'navigates')).toBe(false);
+
+    // The Screens payload: the tap on ItemCard is attributed back to the Home
+    // screen through the JSX-render hop, with the chain and its condition.
+    const screens = await buildScreens(cg, tmpDir);
+    expect(screens.routed).toBe(true);
+    const home = screens.screens.find((s) => s.path === '/')!;
+    expect(screens.entry).toBe(home.id);
+    const detail = screens.screens.find((s) => s.path === '/object-detail')!;
+    const tap = screens.links.find((l) => l.from === home.id && l.to === detail.id)!;
+    expect(tap).toBeDefined();
+    expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'openObjectDetail']);
+    expect(tap.when).toBe('props.collected');
+    expect(tap.sites[0]!.href).toBe('/object-detail?detectionItem=${…}');
+    // Navigation nothing on a screen reaches is an origin, not dropped: the
+    // post-login helper, and `openItem`, which the fixture never calls.
+    const fromOrigins = screens.links.filter((l) => l.fromOrigin);
+    expect(fromOrigins.map((l) => screens.screens.find((s) => s.id === l.to)!.path).sort()).toEqual(['/', '/item/[id]', '/welcome']);
+    expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
+    expect(screens.dropped).toBe(0);
+
+    cg.close();
+  });
+});

+ 2 - 1
codegraph-kernel/src/buffers.rs

@@ -104,7 +104,7 @@ pub const NODE_KINDS: [&str; 23] = [
 ];
 
 /// Mirror of EDGE_KINDS in src/types.ts — order is the wire contract.
-pub const EDGE_KINDS: [&str; 12] = [
+pub const EDGE_KINDS: [&str; 13] = [
     "contains",
     "calls",
     "imports",
@@ -117,6 +117,7 @@ pub const EDGE_KINDS: [&str; 12] = [
     "instantiates",
     "overrides",
     "decorates",
+    "navigates",
 ];
 
 /// ReferenceKind code for the internal-only `function_ref` (#756).

+ 1 - 1
src/context/index.ts

@@ -1208,7 +1208,7 @@ export class ContextBuilder {
 
     // Edge recovery: BFS with many entry points leaves most nodes disconnected.
     // Discover edges between already-selected nodes to recover connectivity.
-    const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides'];
+    const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides', 'navigates'];
     const recoveredEdges = this.queries.findEdgesBetweenNodes(
       [...finalNodes.keys()],
       recoveryKinds,

+ 603 - 0
src/graph/branch-guards.ts

@@ -0,0 +1,603 @@
+/**
+ * Branch guards — the conditions under which a call site runs.
+ *
+ * An edge says `handlePress → openObjectDetail`. What a reader wants to know
+ * is that it happens **when `isCollected`** and **not while `isUploading`**:
+ *
+ *   if (isUploading) return            ← early-return guard: !isUploading
+ *   if (isCollected) {                 ← if: isCollected
+ *     openObjectDetail(item)           ← the call site
+ *
+ * This module derives that from the AST at query time. Given a file, its
+ * language and a call site (line, column), it walks from the innermost node at
+ * that position up to the enclosing function boundary and records every
+ * branch it passes through: `if` / `else` / `else if`, the arms of a ternary,
+ * `switch` cases, the right side of `&&` / `||`, a `catch`, and — at each
+ * statement block on the way — the early exits that precede the site
+ * (`if (x) return`, Swift `guard x else { return }`).
+ *
+ * Nothing is stored in the index. The viewer and `codegraph_explore` already
+ * re-read source per request (drift checks, source windows, highlighting), the
+ * grammars are loaded in both processes, and a file parses in about a
+ * millisecond — so labels are computed where they are shown, from the source
+ * as it is now, and the index schema and the native kernel are untouched. A
+ * small LRU keeps the last few parsed trees so a Symbol view that asks about
+ * forty call sites in one file parses it once.
+ *
+ * Only what the AST states is reported. Loops are not conditions and are not
+ * listed; a condition that cannot be read (a language without rules here, a
+ * file that will not parse) yields no label rather than a wrong one.
+ */
+
+import * as fs from 'fs';
+import type { Node as SyntaxNode, Tree } from 'web-tree-sitter';
+import type { Language } from '../types';
+import { getParser, loadGrammarsForLanguages } from '../extraction/grammars';
+
+// =============================================================================
+// Public shape
+// =============================================================================
+
+export type GuardForm = 'if' | 'else' | 'ternary' | 'case' | 'guard' | 'and' | 'or' | 'catch';
+
+export interface BranchGuard {
+  /** The condition's source, whitespace-collapsed, outer parens dropped, capped in length. */
+  text: string;
+  /** The site runs when the condition is FALSE (an else arm, an early-return guard, `||`). */
+  negated: boolean;
+  form: GuardForm;
+  /** Line of the condition (1-based). */
+  line: number;
+}
+
+/** Longest condition text kept before it is cut with an ellipsis. */
+const MAX_TEXT = 80;
+
+const JS_FAMILY: ReadonlySet<Language> = new Set(['typescript', 'javascript', 'tsx', 'jsx']);
+
+/** Languages with walk rules below. Others yield no guards (never a wrong one). */
+export function supportsBranchGuards(language: Language | string | undefined | null): boolean {
+  return !!language && (JS_FAMILY.has(language as Language) || language === 'swift');
+}
+
+/**
+ * The label a rail or a flow connector prints: the conditions in execution
+ * order, joined with `&&`, each negated one written as `!x`. Empty when the
+ * site is unconditional.
+ */
+export function guardLabel(guards: readonly BranchGuard[]): string {
+  return guards.map(renderGuard).join(' && ');
+}
+
+function renderGuard(g: BranchGuard): string {
+  if (g.form === 'catch') return g.text;
+  if (!g.negated) return g.text;
+  // `!x` negated reads back as `x`; a simple operand takes a bare `!`;
+  // anything with operators is parenthesised so the negation is unambiguous.
+  if (/^!(?![=])/.test(g.text) && isSimpleOperand(g.text.slice(1))) return g.text.slice(1);
+  return isSimpleOperand(g.text) ? `!${g.text}` : `!(${g.text})`;
+}
+
+function isSimpleOperand(text: string): boolean {
+  return /^[\w$.?!]+(?:\([^()]*\))?$/.test(text) && !/[=<>]/.test(text);
+}
+
+// =============================================================================
+// Trees, cached per file version
+// =============================================================================
+
+interface CachedTree {
+  key: string;
+  tree: Tree;
+  source: string;
+}
+
+const TREE_CACHE_SIZE = 8;
+const treeCache = new Map<string, CachedTree>();
+
+/**
+ * Files above this size are not parsed for labels. A 300 KB source file costs
+ * tens of milliseconds to parse, and a Symbol view is budgeted at 100 ms end
+ * to end; a call site in such a file simply shows no `when`.
+ */
+export const MAX_PARSE_BYTES = 256 * 1024;
+
+/** The `web-tree-sitter` trees held above are native memory: evict explicitly. */
+function remember(path: string, entry: CachedTree): void {
+  const old = treeCache.get(path);
+  if (old) old.tree.delete();
+  treeCache.delete(path);
+  treeCache.set(path, entry);
+  if (treeCache.size > TREE_CACHE_SIZE) {
+    const oldest = treeCache.keys().next().value as string;
+    treeCache.get(oldest)?.tree.delete();
+    treeCache.delete(oldest);
+  }
+}
+
+async function treeFor(absPath: string, language: Language): Promise<CachedTree | null> {
+  let stat: fs.Stats;
+  try {
+    stat = fs.statSync(absPath);
+  } catch {
+    return null;
+  }
+  const key = `${language}:${stat.mtimeMs}:${stat.size}`;
+  const hit = treeCache.get(absPath);
+  if (hit && hit.key === key) return hit;
+  if (stat.size > MAX_PARSE_BYTES) return null;
+  let source: string;
+  try {
+    source = fs.readFileSync(absPath, 'utf8');
+  } catch {
+    return null;
+  }
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  const entry = { key, tree, source };
+  remember(absPath, entry);
+  return entry;
+}
+
+async function parse(source: string, language: Language): Promise<Tree | null> {
+  try {
+    await loadGrammarsForLanguages([language]);
+    const parser = getParser(language);
+    if (!parser) return null;
+    return parser.parse(source) ?? null;
+  } catch {
+    return null;
+  }
+}
+
+// =============================================================================
+// Entry points
+// =============================================================================
+
+export interface CallSite {
+  line: number;
+  /** 0-based; null/undefined = the first non-blank column of the line. */
+  column?: number | null;
+}
+
+export function siteKey(site: CallSite): string {
+  return `${site.line}:${typeof site.column === 'number' ? site.column : ''}`;
+}
+
+/**
+ * Guards for many call sites in one file, keyed by {@link siteKey}. The file
+ * is parsed once (and cached across requests until it changes on disk). A
+ * language without rules, or a file that cannot be read or parsed, yields an
+ * empty map.
+ */
+export async function guardsForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, BranchGuard[]>> {
+  const out = new Map<string, BranchGuard[]>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    out.set(key, guardsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null));
+  }
+  return out;
+}
+
+/**
+ * Synchronous twin of {@link guardsForFile} for callers that cannot await
+ * (the explore text builder). It only serves languages whose grammar is
+ * ALREADY loaded — see {@link warmBranchGuardGrammars} — and yields an empty
+ * map otherwise, never a wrong label.
+ */
+export function guardsForFileSync(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Map<string, BranchGuard[]> {
+  const out = new Map<string, BranchGuard[]>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  let stat: fs.Stats;
+  try {
+    stat = fs.statSync(absPath);
+  } catch {
+    return out;
+  }
+  const key = `${language}:${stat.mtimeMs}:${stat.size}`;
+  let cached = treeCache.get(absPath);
+  if (!cached || cached.key !== key) {
+    if (stat.size > MAX_PARSE_BYTES) return out;
+    const parser = getParser(language);
+    if (!parser) return out;
+    let source: string;
+    try {
+      source = fs.readFileSync(absPath, 'utf8');
+    } catch {
+      return out;
+    }
+    const tree = parser.parse(source);
+    if (!tree) return out;
+    cached = { key, tree, source };
+    remember(absPath, cached);
+  }
+  for (const site of sites) {
+    const k = siteKey(site);
+    if (!out.has(k)) out.set(k, guardsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null));
+  }
+  return out;
+}
+
+/** The languages with rules here — what {@link warmBranchGuardGrammars} loads. */
+export const BRANCH_GUARD_LANGUAGES: readonly Language[] = ['typescript', 'tsx', 'javascript', 'jsx', 'swift'];
+
+/** Load the grammars {@link guardsForFileSync} needs; a no-op once loaded, never throws. */
+export async function warmBranchGuardGrammars(only?: readonly Language[]): Promise<void> {
+  const wanted = BRANCH_GUARD_LANGUAGES.filter((l) => !only || only.includes(l));
+  if (wanted.length === 0) return;
+  try {
+    await loadGrammarsForLanguages(wanted);
+  } catch {
+    // Explore prints no `when` for that language; nothing else changes.
+  }
+}
+
+/** Guards for one site in source text — the test seam; production reads files. */
+export async function guardsInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null = null
+): Promise<BranchGuard[]> {
+  if (!supportsBranchGuards(language)) return [];
+  const tree = await parse(source, language);
+  if (!tree) return [];
+  try {
+    return guardsInTree(tree.rootNode, source, language, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+/**
+ * The walk. `line` is 1-based, `column` 0-based (null → first non-blank).
+ * Returns the guards outermost first — execution order, the way a reader
+ * would list them.
+ */
+export function guardsInTree(
+  root: SyntaxNode,
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): BranchGuard[] {
+  const row = line - 1;
+  if (row < 0) return [];
+  let col = column ?? 0;
+  if (column === null) {
+    const text = source.split('\n')[row] ?? '';
+    const first = text.search(/\S/);
+    col = first < 0 ? 0 : first;
+  }
+  let node: SyntaxNode | null = innermostAt(root, row, col);
+  if (!node) return [];
+  const rules: Rules = language === 'swift' ? SWIFT : JS;
+  const found: BranchGuard[] = [];
+
+  // Innermost → outermost. `found` is reversed at the end, so within one level
+  // anything meant to read as OUTER must be pushed LATER.
+  while (node) {
+    const parent: SyntaxNode | null = node.parent;
+    if (!parent || rules.boundaries.has(parent.type)) break;
+    if (rules.inlineFunctions.has(parent.type)) {
+      const holder = parent.parent?.type ?? '';
+      if (rules.bindingParents.has(holder)) break;
+      node = parent;
+      continue;
+    }
+    rules.enclosing(parent, node, found);
+    if (rules.blocks.has(parent.type)) rules.earlyExits(parent, node, found);
+    node = parent;
+  }
+  found.reverse();
+  return found;
+}
+
+/**
+ * The innermost named node containing (row, col). `descendantForPosition` is
+ * the fast path, but some grammars (Swift's `statements`) answer with the
+ * container, so the result is refined by descending while a named child still
+ * contains the point.
+ */
+function innermostAt(root: SyntaxNode, row: number, col: number): SyntaxNode | null {
+  let node: SyntaxNode | null = root.descendantForPosition({ row, column: col });
+  if (!node) return null;
+  for (;;) {
+    let next: SyntaxNode | null = null;
+    const here: SyntaxNode = node;
+    for (let i = 0; i < here.namedChildCount; i++) {
+      const c: SyntaxNode = here.namedChild(i)!;
+      const s = c.startPosition;
+      const e = c.endPosition;
+      const afterStart = s.row < row || (s.row === row && s.column <= col);
+      const beforeEnd = e.row > row || (e.row === row && e.column > col);
+      if (afterStart && beforeEnd) {
+        next = c;
+        break;
+      }
+    }
+    if (!next) return node;
+    node = next;
+  }
+}
+
+// =============================================================================
+// Language rules
+// =============================================================================
+
+interface Rules {
+  /**
+   * Node types the walk never climbs past: the function the site belongs to.
+   * An INLINE function — an arrow passed as an argument, a closure in an
+   * object literal, a trailing closure — is not a boundary: the conditions
+   * around its definition are the conditions under which it exists at all,
+   * which is what a reader asking "when does this run" wants. A function that
+   * is declared, or assigned to a name, starts its own story.
+   */
+  boundaries: ReadonlySet<string>;
+  /** Function-expression types that are boundaries only when named/assigned. */
+  inlineFunctions: ReadonlySet<string>;
+  /** Parent types under which an inline function counts as named/assigned. */
+  bindingParents: ReadonlySet<string>;
+  /** Statement containers whose earlier children may be early exits. */
+  blocks: ReadonlySet<string>;
+  /** `parent` encloses `child` (the node the walk came up through): record any branch. */
+  enclosing(parent: SyntaxNode, child: SyntaxNode, out: BranchGuard[]): void;
+  /** `child` is a statement of block `parent`: record the exits before it. */
+  earlyExits(parent: SyntaxNode, child: SyntaxNode, out: BranchGuard[]): void;
+}
+
+function condText(node: SyntaxNode | null | undefined): string {
+  if (!node) return '';
+  let n: SyntaxNode = node;
+  // `(x)` — the parens are the statement's, not the condition's.
+  while (n.type === 'parenthesized_expression' && n.namedChildCount === 1) n = n.namedChild(0)!;
+  const text = n.text.replace(/\s+/g, ' ').trim();
+  return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text;
+}
+
+function guard(form: GuardForm, cond: SyntaxNode | null | undefined, negated: boolean, text?: string): BranchGuard | null {
+  const t = text ?? condText(cond);
+  if (!t) return null;
+  return { text: t, negated, form, line: (cond ?? null) ? cond!.startPosition.row + 1 : 0 };
+}
+
+function push(out: BranchGuard[], g: BranchGuard | null): void {
+  if (g) out.push(g);
+}
+
+function isField(parent: SyntaxNode, field: string, child: SyntaxNode): boolean {
+  const f = parent.childForFieldName(field);
+  return !!f && f.id === child.id;
+}
+
+function lastNamed(node: SyntaxNode): SyntaxNode | null {
+  return node.namedChildCount > 0 ? node.namedChild(node.namedChildCount - 1) : null;
+}
+
+/** The named children of `parent` that come before `child`, in source order. */
+function precedingSiblings(parent: SyntaxNode, child: SyntaxNode): SyntaxNode[] {
+  const out: SyntaxNode[] = [];
+  for (let i = 0; i < parent.namedChildCount; i++) {
+    const s = parent.namedChild(i)!;
+    if (s.id === child.id) break;
+    out.push(s);
+  }
+  return out;
+}
+
+// ----------------------------------------------------------------------- JS --
+
+const JS_EXITS = new Set(['return_statement', 'throw_statement', 'break_statement', 'continue_statement']);
+
+/** A statement that always leaves the block: an exit, or a block ending in one. */
+function jsAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (JS_EXITS.has(node.type)) return true;
+  if (node.type === 'statement_block') return jsAlwaysExits(lastNamed(node));
+  return false;
+}
+
+const JS: Rules = {
+  boundaries: new Set([
+    'function_declaration',
+    'method_definition',
+    'generator_function_declaration',
+    'class_declaration',
+    'class_body',
+    'class',
+    'program',
+  ]),
+  inlineFunctions: new Set(['arrow_function', 'function_expression', 'function', 'generator_function']),
+  bindingParents: new Set([
+    'variable_declarator',
+    'assignment_expression',
+    'export_statement',
+    'public_field_definition',
+    'field_definition',
+    'lexical_declaration',
+  ]),
+  blocks: new Set(['statement_block', 'program', 'switch_case', 'switch_default']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
+        return;
+      }
+      case 'ternary_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_case':
+      case 'switch_default': {
+        // `child` is one of the case's body statements (not its value).
+        if (parent.type === 'switch_case' && isField(parent, 'value', child)) return;
+        const body = parent.parent; // switch_body
+        const stmt = body?.parent; // switch_statement
+        const subject = condText(stmt?.childForFieldName('value'));
+        if (parent.type === 'switch_default') push(out, guard('case', stmt?.childForFieldName('value'), false, subject ? `${subject}: default` : 'default'));
+        else {
+          const value = condText(parent.childForFieldName('value'));
+          push(out, guard('case', parent.childForFieldName('value'), false, subject ? `${subject} === ${value}` : value));
+        }
+        return;
+      }
+      case 'binary_expression': {
+        if (!isField(parent, 'right', child)) return;
+        const op = parent.childForFieldName('operator')?.text;
+        const left = parent.childForFieldName('left');
+        if (op === '&&') push(out, guard('and', left, false));
+        else if (op === '||') push(out, guard('or', left, true));
+        return;
+      }
+      case 'catch_clause':
+        if (!isField(parent, 'parameter', child)) push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    // Outer-most last (the list is reversed once at the end): walk the
+    // preceding statements backwards so the FIRST guard in the source ends up
+    // first in the final order.
+    const before = precedingSiblings(parent, child);
+    for (let i = before.length - 1; i >= 0; i--) {
+      const s = before[i]!;
+      if (s.type !== 'if_statement' || s.childForFieldName('alternative')) continue;
+      if (!jsAlwaysExits(s.childForFieldName('consequence'))) continue;
+      push(out, guard('guard', s.childForFieldName('condition'), true));
+    }
+  },
+};
+
+// -------------------------------------------------------------------- Swift --
+
+function swiftAlwaysExits(node: SyntaxNode | null): boolean {
+  if (!node) return false;
+  if (node.type === 'control_transfer_statement') return true;
+  if (node.type === 'statements') return swiftAlwaysExits(lastNamed(node));
+  return false;
+}
+
+/** For a Swift `if`: is `child` after the `else` keyword? */
+function afterElse(parent: SyntaxNode, child: SyntaxNode): boolean {
+  let seenElse = false;
+  for (let i = 0; i < parent.childCount; i++) {
+    const c = parent.child(i)!;
+    if (c.id === child.id) return seenElse;
+    if (c.type === 'else') seenElse = true;
+  }
+  return false;
+}
+
+/** All `condition` fields of a Swift `if`/`guard`, joined — `if let x, y > 0`. */
+function swiftConditions(node: SyntaxNode): { node: SyntaxNode | null; text: string } {
+  // The grammar labels several tokens of `if let x = y, z > 0` as `condition`
+  // (the binding's own pieces included), so the readable text is the SPAN from
+  // the first to the last of them, not the pieces joined.
+  const parts: SyntaxNode[] = [];
+  for (let i = 0; i < node.childCount; i++) {
+    if (node.fieldNameForChild(i) === 'condition') parts.push(node.child(i)!);
+  }
+  if (parts.length === 0) return { node: null, text: '' };
+  const first = parts[0]!;
+  const last = parts[parts.length - 1]!;
+  const raw = node.text.slice(first.startIndex - node.startIndex, last.endIndex - node.startIndex);
+  const text = raw.replace(/\s+/g, ' ').trim();
+  return { node: first, text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text };
+}
+
+const SWIFT: Rules = {
+  boundaries: new Set([
+    'function_declaration',
+    'init_declaration',
+    'deinit_declaration',
+    'class_declaration',
+    'protocol_declaration',
+    'computed_property',
+    'source_file',
+  ]),
+  inlineFunctions: new Set(['lambda_literal']),
+  bindingParents: new Set(['property_declaration', 'assignment']),
+  blocks: new Set(['statements', 'function_body']),
+
+  enclosing(parent, child, out) {
+    switch (parent.type) {
+      case 'if_statement': {
+        const c = swiftConditions(parent);
+        if (parent.fieldNameForChild(indexOf(parent, child)) === 'condition') return;
+        push(out, guard(afterElse(parent, child) ? 'else' : 'if', c.node, afterElse(parent, child), c.text));
+        return;
+      }
+      case 'guard_statement': {
+        // Inside the guard's body the condition FAILED.
+        if (parent.fieldNameForChild(indexOf(parent, child)) === 'condition') return;
+        const c = swiftConditions(parent);
+        push(out, guard('else', c.node, true, c.text));
+        return;
+      }
+      case 'ternary_expression': {
+        const cond = parent.childForFieldName('condition');
+        if (isField(parent, 'if_true', child)) push(out, guard('ternary', cond, false));
+        else if (isField(parent, 'if_false', child)) push(out, guard('ternary', cond, true));
+        return;
+      }
+      case 'switch_entry': {
+        const stmt = parent.parent;
+        const subject = condText(stmt?.childForFieldName('expr'));
+        const pattern = parent.namedChildren.find((n) => n.type === 'switch_pattern');
+        if (pattern && pattern.id === child.id) return;
+        const isDefault = parent.children.some((n) => n.type === 'default_keyword');
+        const value = pattern ? condText(pattern) : '';
+        const text = isDefault ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
+        push(out, guard('case', pattern ?? stmt?.childForFieldName('expr'), false, text));
+        return;
+      }
+      case 'catch_block':
+        push(out, guard('catch', null, false, 'on error'));
+        return;
+      default:
+        return;
+    }
+  },
+
+  earlyExits(parent, child, out) {
+    const before = precedingSiblings(parent, child);
+    for (let i = before.length - 1; i >= 0; i--) {
+      const s = before[i]!;
+      if (s.type === 'guard_statement') {
+        const c = swiftConditions(s);
+        push(out, guard('guard', c.node, false, c.text));
+      } else if (s.type === 'if_statement' && !s.children.some((n) => n.type === 'else')) {
+        const body = s.namedChildren.find((n) => n.type === 'statements') ?? null;
+        if (!swiftAlwaysExits(body)) continue;
+        const c = swiftConditions(s);
+        push(out, guard('guard', c.node, true, c.text));
+      }
+    }
+  },
+};
+
+function indexOf(parent: SyntaxNode, child: SyntaxNode): number {
+  for (let i = 0; i < parent.childCount; i++) if (parent.child(i)!.id === child.id) return i;
+  return -1;
+}

+ 1 - 1
src/graph/dynamic-boundary-report.ts

@@ -303,7 +303,7 @@ function handlerMethodOf(cg: CodeGraph, cls: Node): Node | null {
 // Continuations
 // =============================================================================
 
-const CONTINUATION_KINDS = new Set(['calls', 'instantiates']);
+const CONTINUATION_KINDS = new Set(['calls', 'instantiates', 'navigates']);
 
 /**
  * The calls recorded out of a symbol, minus the ones already on the path.

+ 14 - 4
src/graph/named-symbol-flow.ts

@@ -193,8 +193,16 @@ export const FLOW_CALLABLE_KINDS: ReadonlySet<string> = new Set([
   'function',
   'component',
   'constructor',
+  'route',
 ]);
 
+/**
+ * Edge kinds a flow may ride. `navigates` is a screen transition (Expo Router
+ * `router.push('/x')` → the route node) — a hop in the user's flow exactly as
+ * a call is a hop in the program's.
+ */
+export const FLOW_EDGE_KINDS: ReadonlySet<string> = new Set(['calls', 'navigates']);
+
 /**
  * Node kinds that can be an endpoint of a SYNTHESIZED edge without being
  * callable. An RTK thunk is `const X = createAsyncThunk(...)`, so a thunk →
@@ -458,8 +466,10 @@ function walkCalls(
     if (id !== seed.id && named.has(id)) reached.push(id);
     if (depth >= maxHops - 1) continue;
     for (const c of cg.getCallees(id)) {
-      if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
-      const newStreak = named.has(c.node.id) ? 0 : streak + 1;
+      if (!FLOW_EDGE_KINDS.has(c.edge.kind) || parent.has(c.node.id)) continue;
+      // A route node is a connector, not a symbol the reader would have named:
+      // crossing one costs no bridge budget.
+      const newStreak = named.has(c.node.id) ? 0 : c.node.kind === 'route' ? streak : streak + 1;
       if (newStreak > maxBridge) continue;
       parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
       queue.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
@@ -530,7 +540,7 @@ function walkBidirectional(
       const next: Node[] = [];
       for (const node of frontF) {
         for (const c of cg.getCallees(node.id)) {
-          if (c.edge.kind !== 'calls' || forward.has(c.node.id)) continue;
+          if (!FLOW_EDGE_KINDS.has(c.edge.kind) || forward.has(c.node.id)) continue;
           forward.set(c.node.id, { prev: node.id, edge: c.edge, node: c.node });
           next.push(c.node);
         }
@@ -542,7 +552,7 @@ function walkBidirectional(
       const next: Node[] = [];
       for (const node of frontB) {
         for (const c of cg.getCallers(node.id)) {
-          if (c.edge.kind !== 'calls' || backward.has(c.node.id)) continue;
+          if (!FLOW_EDGE_KINDS.has(c.edge.kind) || backward.has(c.node.id)) continue;
           backward.set(c.node.id, { next: node.id, edge: c.edge });
           backNodes.set(c.node.id, c.node);
           next.push(c.node);

+ 2 - 2
src/graph/traversal.ts

@@ -292,7 +292,7 @@ export class GraphTraverser {
     // caller of the class. Without it, `callers <Class>` surfaced only the
     // importing file (via `imports`) and missed every construction site —
     // the opposite of "what breaks if I change this class?" (#774).
-    const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
+    const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
     if (incomingEdges.length === 0) return;
 
     // Batch-fetch all caller nodes in one round-trip instead of one
@@ -347,7 +347,7 @@ export class GraphTraverser {
     // (`Foo(...)` / `new Foo()`) has that class as a callee, so callers and
     // callees stay inverses of each other and `trace` can cross the
     // instantiation boundary (function → class → its methods) (#774).
-    const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
+    const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
     if (outgoingEdges.length === 0) return;
 
     // Batch-fetch callee nodes (was N+1 — see getCallersRecursive note).

+ 34 - 3
src/mcp/tools.ts

@@ -40,6 +40,7 @@ import {
 } from 'fs';
 import { createHash } from 'crypto';
 import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
+import { guardLabel, guardsForFileSync, siteKey, supportsBranchGuards, warmBranchGuardGrammars } from '../graph/branch-guards';
 import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
 import { countImplementers } from '../graph/type-hierarchy';
 import {
@@ -377,7 +378,7 @@ const ISOLATED_WEAK_KIND_WEIGHT = 0.08;
  */
 const RELEVANCE_USAGE_EDGES: ReadonlySet<string> = new Set([
   'calls', 'references', 'extends', 'implements', 'overrides',
-  'instantiates', 'returns', 'type_of', 'decorates',
+  'instantiates', 'returns', 'type_of', 'decorates', 'navigates',
 ]);
 
 /**
@@ -2440,6 +2441,29 @@ export class ToolHandler {
    * for ordinary static edges. Used by trace + the node trail so a synthesized
    * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
    */
+  /**
+   * The branch conditions a flow hop's call site runs under, read from the
+   * caller's source now (`graph/branch-guards.ts`); '' when unconditional,
+   * unreadable, or the grammar for that language is not loaded.
+   */
+  private whenLabel(cg: CodeGraph, caller: Node, edge: Edge): string {
+    if (!edge.line || !supportsBranchGuards(caller.language)) return '';
+    try {
+      const rec = cg.getFile(caller.filePath);
+      if (!rec) return '';
+      const abs = validatePathWithinRoot(cg.getProjectRoot(), caller.filePath);
+      if (!abs) return '';
+      const st = statSync(abs);
+      // Drifted since the index: the recorded line may point elsewhere.
+      if (st.size !== rec.size || Math.floor(st.mtimeMs) !== Math.floor(rec.modifiedAt)) return '';
+      const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
+      const g = guardsForFileSync(abs, caller.language, [site]).get(siteKey(site));
+      return g ? guardLabel(g) : '';
+    } catch {
+      return '';
+    }
+  }
+
   private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
     if (!edge || edge.provenance !== 'heuristic') return null;
     const m = edge.metadata as Record<string, unknown> | undefined;
@@ -2704,7 +2728,11 @@ export class ToolHandler {
         out.push('**Flow (call path among the symbols you queried)**', '');
         for (let i = 0; i < best!.length; i++) {
           const step = best![i]!;
-          if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(`   ↓ ${sy ? sy.compact : step.edge.kind}`); }
+          if (step.edge) {
+            const sy = this.synthEdgeNote(step.edge);
+            const when = i > 0 ? this.whenLabel(cg, best![i - 1]!.node, step.edge) : '';
+            out.push(`   ↓ ${sy ? sy.compact : step.edge.kind}${when ? ` (when ${when})` : ''}`);
+          }
           out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
         }
         out.push('');
@@ -3014,7 +3042,7 @@ export class ToolHandler {
 
     const RANK_EDGES = new Set<string>([
       'calls', 'references', 'extends', 'implements', 'overrides',
-      'instantiates', 'returns', 'type_of', 'imports',
+      'instantiates', 'returns', 'type_of', 'imports', 'navigates',
     ]);
     const adj: number[][] = Array.from({ length: n }, () => []);
     for (const e of edges) {
@@ -3948,6 +3976,9 @@ export class ToolHandler {
     // Compute the flow spine once — used both to prepend the Flow section (below)
     // and to gate adaptive source sizing: files on the spine get full source,
     // off-spine peers skeletonize.
+    // The Flow section labels each hop with its branch conditions; that read
+    // is synchronous, so the grammars it needs are loaded here, once.
+    await warmBranchGuardGrammars();
     const flow = this.buildFlowFromNamedSymbols(cg, matchQuery);
 
     // Snapshot every ranked candidate's scoring inputs, in final sort order, so

+ 3 - 0
src/resolution/callback-synthesizer.ts

@@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection';
 import { stripCommentsForRegex } from './strip-comments';
 import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
 import { goframeRouteEdges } from './goframe-synthesizer';
+import { expoRouterReturnEdges } from './expo-router-synthesizer';
 import { createYielder, type MaybeYield } from './cooperative-yield';
 
 const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
@@ -3610,6 +3611,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [
     run: (q, c, y, sub) => cFnPointerDispatchEdges(q, c, y, sub),
   },
   { name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) },
+  // `router.push(await helper())` — the helper's return literals are the screens.
+  { name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) },
   { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) },
 ];
 

+ 184 - 0
src/resolution/expo-router-synthesizer.ts

@@ -0,0 +1,184 @@
+/**
+ * Expo Router — navigation whose destination comes back from a helper.
+ *
+ *   router.push(await resolvePostLoginRoute())
+ *
+ * The argument is a call, not a string, so the resolver in
+ * `frameworks/expo-router.ts` (which binds literal hrefs) correctly leaves the
+ * `router.push` ref unresolved. But the destination is still static — it is
+ * written down inside the helper:
+ *
+ *   const resolvePostLoginRoute = async () =>
+ *     (await hasSeenWelcome()) ? '/home/' : '/welcome/'
+ *
+ * This pass finds every navigation call whose argument is a call to a project
+ * function, reads the screen-path literals out of that function's body, and
+ * synthesizes one `navigates` edge from the HELPER to each screen. The push
+ * site already has a plain `calls` edge to the helper, so the flow reads
+ * `fetchUser → resolvePostLoginRoute → /home`, and the fork the helper decides
+ * shows up as its two (or three) outgoing screens — which is the answer to
+ * "where does the app go after login".
+ *
+ * Edges are `provenance:'heuristic'`, `synthesizedBy:'expo-router-return'`,
+ * with `registeredAt` = the push site that made the helper's return value a
+ * destination. A helper is only read because a navigation call consumes it;
+ * a function that merely contains path-like strings is never touched. Nothing
+ * here runs on a project with no Expo Router screens.
+ */
+
+import type { Edge, Language, Node } from '../types';
+import type { ResolutionContext } from './types';
+import type { MaybeYield } from './cooperative-yield';
+import { stripCommentsForRegex } from './strip-comments';
+import {
+  matchRoute,
+  normalizeHrefPath,
+  readStringAt,
+  routeTable,
+  stringEnd,
+  toHref,
+} from './frameworks/expo-router';
+
+const JS_LANGS: ReadonlySet<Language> = new Set(['typescript', 'javascript', 'tsx', 'jsx']);
+const JS_FILE = /\.(?:[cm]?[jt]sx?)$/;
+
+/** `.push(await helper(` / `.navigate(obj.helper(` — a navigation call fed by a call. */
+const NAV_FED_BY_CALL = /\.(push|replace|navigate|dismissTo)\(\s*(?:await\s+)?([A-Za-z_$][\w$.]*)\s*\(/g;
+
+/** A helper yielding more distinct screens than this is a table, not a decision. */
+const MAX_SCREENS_PER_HELPER = 8;
+
+const HELPER_KINDS: ReadonlySet<string> = new Set(['function', 'method']);
+
+interface NavSite {
+  file: string;
+  line: number;
+  method: string;
+  callee: string;
+}
+
+export async function expoRouterReturnEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+  const table = routeTable(ctx);
+  if (table.exact.size === 0) return [];
+
+  // 1. Every navigation call whose argument is a call.
+  const sites: NavSite[] = [];
+  let scanned = 0;
+  for (const file of ctx.getAllFiles()) {
+    if (!JS_FILE.test(file)) continue;
+    if ((++scanned & 63) === 0) await onYield();
+    const source = ctx.readFile(file);
+    if (!source || !/\.(?:push|replace|navigate|dismissTo)\(/.test(source)) continue;
+    const stripped = stripCommentsForRegex(source, 'typescript');
+    NAV_FED_BY_CALL.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = NAV_FED_BY_CALL.exec(stripped)) !== null) {
+      const line = stripped.slice(0, m.index).split('\n').length;
+      sites.push({ file, line, method: m[1]!, callee: m[2]! });
+    }
+  }
+  if (sites.length === 0) return [];
+
+  // 2. Each callee → the project function it names → the screens in its body.
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const screensByHelper = new Map<string, Array<{ node: Node; href: string; line: number }> | null>();
+  for (const site of sites) {
+    await onYield();
+    const helper = resolveHelper(site, ctx);
+    if (!helper) continue;
+    let screens = screensByHelper.get(helper.id);
+    if (screens === undefined) {
+      screens = screensInBody(helper, ctx, table);
+      screensByHelper.set(helper.id, screens);
+    }
+    if (!screens) continue;
+    for (const s of screens) {
+      const key = `${helper.id}>${s.node.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: helper.id,
+        target: s.node.id,
+        kind: 'navigates',
+        line: s.line,
+        provenance: 'heuristic',
+        metadata: {
+          synthesizedBy: 'expo-router-return',
+          href: s.href,
+          navMethod: site.method,
+          registeredAt: `${site.file}:${site.line}`,
+        },
+      });
+    }
+  }
+  return edges;
+}
+
+/**
+ * The function `site.callee` names, seen from `site.file`: same file first,
+ * then the file its import points at, then a unique project-wide match.
+ * Ambiguity is a null — an edge onto the wrong `load()` is worse than none.
+ */
+function resolveHelper(site: NavSite, ctx: ResolutionContext): Node | null {
+  const segs = site.callee.split('.');
+  const bare = segs[segs.length - 1]!;
+  const head = segs[0]!;
+  const candidates = ctx
+    .getNodesByName(bare)
+    .filter((n) => HELPER_KINDS.has(n.kind) && JS_LANGS.has(n.language));
+  if (candidates.length === 0) return null;
+  const local = candidates.filter((n) => n.filePath === site.file);
+  if (local.length === 1) return local[0]!;
+  if (local.length > 1) return null;
+
+  const lang: Language = site.file.endsWith('x') ? 'tsx' : 'typescript';
+  const imported = ctx
+    .getImportMappings(site.file, lang)
+    .find((im) => im.localName === head || im.localName === bare);
+  if (imported?.resolvedPath) {
+    const viaImport = candidates.filter((n) => n.filePath === imported.resolvedPath);
+    if (viaImport.length === 1) return viaImport[0]!;
+  }
+  return candidates.length === 1 ? candidates[0]! : null;
+}
+
+/**
+ * The screens named by string literals in the helper's body — each literal
+ * that begins with `/`, resolved like an href would be. Null when the body
+ * cannot be read or names too many screens to be a decision.
+ */
+function screensInBody(
+  helper: Node,
+  ctx: ResolutionContext,
+  table: ReturnType<typeof routeTable>
+): Array<{ node: Node; href: string; line: number }> | null {
+  const lines = ctx.getFileLines?.(helper.filePath) ?? ctx.readFile(helper.filePath)?.split(/\r?\n/);
+  if (!lines) return null;
+  const body = stripCommentsForRegex(
+    lines.slice(helper.startLine - 1, helper.endLine).join('\n'),
+    'typescript'
+  );
+  const found = new Map<string, { node: Node; href: string; line: number }>();
+  for (let i = 0; i < body.length; i++) {
+    const ch = body[i];
+    if (ch !== '"' && ch !== "'" && ch !== '`') continue;
+    const start = i;
+    const literal = readStringAt(body, i);
+    i = stringEnd(body, i);
+    if (literal === null || !literal.startsWith('/')) continue;
+    const href = toHref(literal);
+    if (!href) continue;
+    const segs = normalizeHrefPath(href.path, helper.filePath);
+    if (segs === null) continue;
+    const route = matchRoute(segs, table);
+    if (!route || found.has(route.id)) continue;
+    found.set(route.id, {
+      node: route,
+      href: href.display,
+      line: helper.startLine + body.slice(0, start).split('\n').length - 1,
+    });
+    if (found.size > MAX_SCREENS_PER_HELPER) return null;
+  }
+  return found.size > 0 ? [...found.values()] : null;
+}

+ 673 - 0
src/resolution/frameworks/expo-router.ts

@@ -0,0 +1,673 @@
+/**
+ * Expo Router (React Native) — file-based screens and string-keyed navigation.
+ *
+ * Two things static extraction cannot see on its own, and that together are
+ * most of what "how does the app flow" means in an Expo app:
+ *
+ * 1. **A screen is a file, not a symbol.** Every file under `app/` (or
+ *    `src/app/`) is a route: `app/object-detail.tsx` is `/object-detail`,
+ *    `app/capture/index.tsx` is `/capture`, `app/item/[id].tsx` is `/item/[id]`,
+ *    and `(group)` directories are invisible in the URL. `extract()` emits one
+ *    `route` node per screen file, named by its path, with a `calls` ref to the
+ *    file's default export so the route reaches the component that renders it.
+ *
+ * 2. **Navigation is a string.** `router.push('/object-detail?…')`,
+ *    `router.navigate({ pathname: '/item/[id]', params })`, a template literal
+ *    with the params interpolated — the extractor records each as a `calls` ref
+ *    named `router.push` that resolves to nothing, because the target is a
+ *    path, not an identifier. `resolve()` claims those refs, reads the argument
+ *    off the source lines, matches it against the route table, and returns a
+ *    **`navigates`** edge to the route node, carrying the href it read.
+ *
+ * Between them the graph gains `ItemCard → openObjectDetail → /object-detail →
+ * ObjectDetail`, which is the chain a reader asking "where does tapping an
+ * object go" needs and previously got "no path" for.
+ *
+ * Precision rests on the string resolving to a real screen file, not on the
+ * receiver being called `router`: `nav.push('/x')` from `const nav =
+ * useRouter()` binds, `list.push('/x')` where no such route exists does not.
+ * Anything the resolver cannot bind to exactly one screen — a computed path,
+ * an ambiguous dynamic match, a relative href from a non-screen file — is left
+ * unresolved rather than guessed. Silent beats wrong.
+ *
+ * Not covered yet (each needs the enclosing component, which `extract()` does
+ * not receive): `<Link href>`, `<Redirect href>`, and `Stack.Screen` /
+ * `Tabs.Screen` `name` props. `router.back()` / `dismiss()` have no target and
+ * are correctly skipped.
+ */
+
+import type { Language, Node } from '../../types';
+import type {
+  FrameworkResolver,
+  ResolutionContext,
+  ResolvedRef,
+  UnresolvedRef,
+} from '../types';
+import { stripCommentsForRegex } from '../strip-comments';
+
+// =============================================================================
+// Route files
+// =============================================================================
+
+const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
+
+/** The app directory: `app/` at the project root, or `src/app/`. First match wins. */
+const APP_DIR = /(?:^|\/)(?:src\/)?app\//;
+
+/** A screen file's extension, with an optional platform suffix (`.ios.tsx`). */
+const ROUTE_EXT = /\.(?:(?:ios|android|native|web)\.)?(tsx|ts|jsx|js|mjs|cjs)$/;
+
+/** Files under `app/` that define a screen (not a layout, not test, not html). */
+export function routePathForFile(filePath: string): string | null {
+  const dir = APP_DIR.exec(filePath);
+  if (!dir) return null;
+  const rel = filePath.slice(dir.index + dir[0].length);
+  const ext = ROUTE_EXT.exec(rel);
+  if (!ext) return null;
+  const bare = rel.slice(0, ext.index);
+  if (bare.endsWith('.d') || /\.(?:test|spec|stories)$/.test(bare)) return null;
+  const segs = bare.split('/');
+  if (segs.includes('__tests__') || segs.includes('__mocks__')) return null;
+  const base = segs[segs.length - 1]!;
+  // `_layout` (and any other `_`-prefixed file) is not navigable. `+not-found`
+  // is a real screen; the other `+` files (`+html`, `+native-intent`) are not.
+  if (base.startsWith('_')) return null;
+  if (base.startsWith('+') && base !== '+not-found') return null;
+  const kept = segs.filter((s) => !(s.startsWith('(') && s.endsWith(')')));
+  if (kept[kept.length - 1] === 'index') kept.pop();
+  return '/' + kept.join('/');
+}
+
+function languageForFile(filePath: string): Language {
+  const ext = ROUTE_EXT.exec(filePath)?.[1];
+  switch (ext) {
+    case 'tsx':
+      return 'tsx';
+    case 'jsx':
+      return 'jsx';
+    case 'ts':
+      return 'typescript';
+    default:
+      return 'javascript';
+  }
+}
+
+const IDENT = '[A-Za-z_$][\\w$]*';
+
+/**
+ * The name the file exports as its screen. Expo renders the DEFAULT export,
+ * so that is the only binding that matters; a wrapper (`memo(Screen)`,
+ * `observer(Screen)`, `React.forwardRef(Screen)`) is looked through to its
+ * first identifier argument. Anonymous defaults (`export default () => …`)
+ * have no name to bind and yield null.
+ */
+export function defaultExportName(stripped: string): { name: string; index: number } | null {
+  const patterns: RegExp[] = [
+    new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s*\\*?\\s*(${IDENT})`),
+    new RegExp(`export\\s+default\\s+class\\s+(${IDENT})`),
+    new RegExp(`export\\s+default\\s+(?:${IDENT}\\.)?${IDENT}\\s*\\(\\s*(${IDENT})\\s*[,)]`),
+    new RegExp(`export\\s+default\\s+(${IDENT})\\s*;?\\s*$`, 'm'),
+    new RegExp(`export\\s*\\{\\s*(${IDENT})\\s+as\\s+default\\s*\\}`),
+  ];
+  for (const re of patterns) {
+    const m = re.exec(stripped);
+    if (m && m[1] && m[1] !== 'default') return { name: m[1], index: m.index };
+  }
+  return null;
+}
+
+// =============================================================================
+// Reading the href out of a navigation call
+// =============================================================================
+
+/**
+ * The `router` methods that take a destination (`back`/`dismiss` take none),
+ * and a project's own wrappers around them: `safePush('/x')`,
+ * `guardedNavigate('/x')` — a camelCase name ending in the verb. A wrapper
+ * usually defers the real call through state the graph cannot follow
+ * (`pendingNav = { method, href }` … `router[method](href)`), so its NAME is
+ * the only static evidence; the argument resolving to a real screen is what
+ * makes the claim safe. Second group: the verb, when it came from a wrapper.
+ */
+export const NAV_METHOD = /(?:^|\.)(push|replace|navigate|dismissTo)$|^[a-z][A-Za-z]*(Push|Replace|Navigate)$/;
+
+/** The verb a NAV_METHOD match names, lower-cased: `safePush` → `push`. */
+export function navVerb(name: string): string | null {
+  const m = NAV_METHOD.exec(name);
+  if (!m) return null;
+  // A router method is already the verb (`dismissTo`); a wrapper's suffix
+  // (`safePush` → `Push`) is lower-cased to name the verb it stands in for.
+  return m[1] ?? m[2]!.toLowerCase();
+}
+
+/** Lines a single navigation call is allowed to span. */
+const MAX_CALL_LINES = 12;
+
+/** Placeholder for an interpolated `${…}` inside a template-literal href. */
+const HOLE = '\u0000';
+
+/** Index of the `)` matching the `(` at `open`, skipping string bodies; -1 if unbalanced. */
+function matchParen(s: string, open: number): number {
+  let depth = 0;
+  for (let i = open; i < s.length; i++) {
+    const ch = s[i];
+    if (ch === '"' || ch === "'") {
+      const q = ch;
+      i++;
+      while (i < s.length && s[i] !== q) {
+        if (s[i] === '\\') i++;
+        i++;
+      }
+      continue;
+    }
+    if (ch === '`') {
+      i = skipTemplate(s, i);
+      continue;
+    }
+    if (ch === '(') depth++;
+    else if (ch === ')') {
+      depth--;
+      if (depth === 0) return i;
+    }
+  }
+  return -1;
+}
+
+/** Index of the closing backtick for the template starting at `open`. */
+function skipTemplate(s: string, open: number): number {
+  let i = open + 1;
+  while (i < s.length) {
+    const ch = s[i];
+    if (ch === '\\') {
+      i += 2;
+      continue;
+    }
+    if (ch === '`') return i;
+    if (ch === '$' && s[i + 1] === '{') {
+      let depth = 0;
+      for (i = i + 1; i < s.length; i++) {
+        if (s[i] === '{') depth++;
+        else if (s[i] === '}') {
+          depth--;
+          if (depth === 0) break;
+        } else if (s[i] === '`') i = skipTemplate(s, i);
+      }
+    }
+    i++;
+  }
+  return s.length;
+}
+
+/** Index of the quote that closes the string literal opening at `at` (or the end of `s`). */
+export function stringEnd(s: string, at: number): number {
+  const q = s[at];
+  if (q === '`') return skipTemplate(s, at);
+  for (let i = at + 1; i < s.length; i++) {
+    if (s[i] === '\\') i++;
+    else if (s[i] === q) return i;
+  }
+  return s.length;
+}
+
+/**
+ * A string literal (`'…'`, `"…"`, or a template) starting at `at`, as text
+ * with every `${…}` replaced by {@link HOLE}. Null when `at` is not a string.
+ */
+export function readStringAt(s: string, at: number): string | null {
+  const q = s[at];
+  if (q === '"' || q === "'") {
+    let out = '';
+    for (let i = at + 1; i < s.length; i++) {
+      const ch = s[i]!;
+      if (ch === '\\') {
+        out += s[i + 1] ?? '';
+        i++;
+        continue;
+      }
+      if (ch === q) return out;
+      out += ch;
+    }
+    return null;
+  }
+  if (q === '`') {
+    const end = skipTemplate(s, at);
+    let out = '';
+    for (let i = at + 1; i < end; i++) {
+      const ch = s[i]!;
+      if (ch === '\\') {
+        out += s[i + 1] ?? '';
+        i++;
+        continue;
+      }
+      if (ch === '$' && s[i + 1] === '{') {
+        let depth = 0;
+        for (i = i + 1; i < end; i++) {
+          if (s[i] === '{') depth++;
+          else if (s[i] === '}') {
+            depth--;
+            if (depth === 0) break;
+          }
+        }
+        out += HOLE;
+        continue;
+      }
+      out += ch;
+    }
+    return out;
+  }
+  return null;
+}
+
+export interface HrefLiteral {
+  /** The path part, `${…}` holes kept as {@link HOLE}; query and hash removed. */
+  path: string;
+  /** The literal as written, holes rendered as `${…}` — for the edge metadata. */
+  display: string;
+  /** The other arm of a `cond ? a : b` argument, when the argument was one. */
+  alternate?: HrefLiteral;
+}
+
+/** Index of the first `ch` at bracket depth 0 and outside strings, or -1. */
+function indexAtDepth0(s: string, ch: string, from: number): number {
+  let depth = 0;
+  for (let i = from; i < s.length; i++) {
+    const c = s[i];
+    if (c === '"' || c === "'") {
+      const q = c;
+      i++;
+      while (i < s.length && s[i] !== q) {
+        if (s[i] === '\\') i++;
+        i++;
+      }
+      continue;
+    }
+    if (c === '`') {
+      i = skipTemplate(s, i);
+      continue;
+    }
+    if (c === '(' || c === '[' || c === '{') depth++;
+    else if (c === ')' || c === ']' || c === '}') depth--;
+    else if (c === '?' && (s[i + 1] === '.' || s[i + 1] === '?')) i++; // `?.` / `??`
+    else if (depth === 0 && c === ch) return i;
+  }
+  return -1;
+}
+
+export function toHref(literal: string | null): HrefLiteral | null {
+  if (literal === null || literal.length === 0) return null;
+  const cut = literal.search(/[?#]/);
+  const path = cut < 0 ? literal : literal.slice(0, cut);
+  if (path.length === 0) return null;
+  return { path, display: literal.split(HOLE).join('${…}') };
+}
+
+/**
+ * The href in one argument expression: a string, a template, an `Href` object
+ * with a literal `pathname`, or a conditional whose two arms are each one of
+ * those (`cond ? \`/x?id=${id}\` : '/x'`). Anything else is not static.
+ */
+function parseHrefExpression(expr: string): HrefLiteral | null {
+  // `expr as any` / `expr satisfies Href` — a cast says nothing about the value.
+  let args = expr.trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
+  // `(a ? b : c)` — unwrap one layer of grouping parens.
+  while (args.startsWith('(') && matchParen(args, 0) === args.length - 1) {
+    args = args.slice(1, -1).trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
+  }
+  if (args.length === 0) return null;
+  const q = indexAtDepth0(args, '?', 0);
+  if (q > 0) {
+    const colon = indexAtDepth0(args, ':', q + 1);
+    if (colon > q) {
+      const yes = parseHrefExpression(args.slice(q + 1, colon));
+      const no = parseHrefExpression(args.slice(colon + 1));
+      if (yes && no) return { ...yes, alternate: no };
+      return null;
+    }
+  }
+  if (args[0] === '{') {
+    const key = /\bpathname\s*:\s*/.exec(args);
+    return key ? toHref(readStringAt(args, key.index + key[0].length)) : null;
+  }
+  return toHref(readStringAt(args, 0));
+}
+
+/**
+ * The destination of the navigation call at (`line`, `column`) in `lines`.
+ *
+ * Handles the three shapes Expo Router accepts: a string, a template literal
+ * (static prefix kept, interpolations become holes), and an `Href` object
+ * whose `pathname` is one of those. Anything else — a variable, a call, a
+ * spread — is not a literal and returns null.
+ */
+export function readHrefArgument(
+  lines: readonly string[],
+  line: number,
+  column: number,
+  method: string
+): HrefLiteral | null {
+  const arg = firstArgumentText(lines, line, column, method);
+  return arg === null ? null : parseHrefExpression(arg);
+}
+
+/** The source text of the navigation call's first argument, or null when there is no call there. */
+function firstArgumentText(
+  lines: readonly string[],
+  line: number,
+  column: number,
+  method: string
+): string | null {
+  const first = line - 1;
+  if (first < 0 || first >= lines.length) return null;
+  const text = lines.slice(first, first + MAX_CALL_LINES).join('\n');
+  const nameAt = text.indexOf(method, Math.max(0, column));
+  if (nameAt < 0) return null;
+  let open = nameAt + method.length;
+  while (open < text.length && /\s/.test(text[open]!)) open++;
+  if (text[open] !== '(') return null;
+  const close = matchParen(text, open);
+  const args = text.slice(open + 1, close < 0 ? undefined : close);
+  // Only the first argument: a `,` at depth 0 ends it (`push(href, opts)`).
+  const comma = indexAtDepth0(args, ',', 0);
+  return comma < 0 ? args : args.slice(0, comma);
+}
+
+const CAST_TAIL = /\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/;
+
+/** A line that continues the previous statement rather than starting a new one. */
+const CONTINUATION = /^[?:.)\]}`'"+&|]/;
+
+/**
+ * The href a navigation call reaches through a local variable:
+ *
+ *   const href = params.length ? `/barcode-scan?${q}` : '/barcode-scan'
+ *   router.navigate(href as any)
+ *
+ * When the argument is a bare identifier, its most recent `const`/`let`
+ * declaration between `enclosingStart` and the call is read and its
+ * initializer parsed exactly like a literal argument would be. A reassignment
+ * in between, or an initializer that is not static, yields null.
+ */
+export function readHrefViaLocal(
+  lines: readonly string[],
+  line: number,
+  column: number,
+  method: string,
+  enclosingStart: number
+): HrefLiteral | null {
+  const arg = firstArgumentText(lines, line, column, method);
+  if (arg === null) return null;
+  const ident = arg.trim().replace(CAST_TAIL, '');
+  if (!/^[A-Za-z_$][\w$]*$/.test(ident)) return null;
+  const decl = new RegExp(`\\b(?:const|let|var)\\s+${ident.replace(/\$/g, '\\$')}\\s*(?::[^=]*?)?=(?!=)`);
+  const reassign = new RegExp(`(?:^|[^.\\w$])${ident.replace(/\$/g, '\\$')}\\s*=(?!=)`);
+  const from = Math.max(0, enclosingStart - 1);
+  for (let i = line - 2; i >= from; i--) {
+    const text = lines[i]!;
+    const m = decl.exec(text);
+    if (!m) {
+      // The variable assigned again between declaration and use — not static.
+      if (reassign.test(text)) return null;
+      continue;
+    }
+    // The initializer: the rest of this line, plus continuation lines.
+    let init = text.slice(m.index + m[0].length);
+    for (let j = i + 1; j < line - 1 && j < i + MAX_CALL_LINES; j++) {
+      const next = lines[j]!;
+      if (!CONTINUATION.test(next.trimStart()) && balanced(init)) break;
+      init += '\n' + next;
+    }
+    return parseHrefExpression(init);
+  }
+  return null;
+}
+
+/** True when every bracket and template opened in `s` is closed. */
+function balanced(s: string): boolean {
+  let depth = 0;
+  for (let i = 0; i < s.length; i++) {
+    const c = s[i];
+    if (c === '"' || c === "'" || c === '`') {
+      const end = stringEnd(s, i);
+      if (end >= s.length) return false;
+      i = end;
+      continue;
+    }
+    if (c === '(' || c === '[' || c === '{') depth++;
+    else if (c === ')' || c === ']' || c === '}') depth--;
+  }
+  return depth <= 0;
+}
+
+// =============================================================================
+// Route table
+// =============================================================================
+
+interface RouteEntry {
+  node: Node;
+  segs: string[];
+}
+
+export interface RouteTable {
+  /** Identity of the node array the table was built from — rebuild when it changes. */
+  source: readonly Node[];
+  exact: Map<string, Node>;
+  dynamic: RouteEntry[];
+}
+
+const tables = new Map<string, RouteTable>();
+
+export function routeTable(context: ResolutionContext): RouteTable {
+  const all = context.getNodesByKind('route');
+  const key = context.getProjectRoot();
+  const cached = tables.get(key);
+  if (cached && cached.source === all) return cached;
+  const exact = new Map<string, Node>();
+  const dynamic: RouteEntry[] = [];
+  for (const node of all) {
+    // Only this framework's own route nodes: the ones whose name IS the path
+    // derived from their file. Express/SvelteKit routes in the same project
+    // name themselves differently and never match.
+    if (routePathForFile(node.filePath) !== node.name) continue;
+    exact.set(node.name, node);
+    if (node.name.includes('[')) dynamic.push({ node, segs: node.name.split('/').slice(1) });
+  }
+  const table = { source: all, exact, dynamic };
+  tables.set(key, table);
+  return table;
+}
+
+/**
+ * Normalize an href path to the form route names use: leading `/`, no
+ * trailing `/`, no `(group)` segments, holes as a whole-segment `*`.
+ * A relative href is resolved against the screen the call sits in; from a
+ * non-screen file it has no base and returns null.
+ */
+export function normalizeHrefPath(path: string, fromFile: string): string[] | null {
+  let p = path;
+  if (!p.startsWith('/')) {
+    // Expo resolves `./x` and bare `x` against the DIRECTORY of the screen file
+    // the call sits in — `/capture` for both `capture/index.tsx` and
+    // `capture/review.tsx` — which is the route of that directory's index.
+    if (routePathForFile(fromFile) === null) return null;
+    const dirRoute = routePathForFile(fromFile.slice(0, fromFile.lastIndexOf('/') + 1) + 'index.tsx');
+    if (dirRoute === null) return null;
+    const parent = dirRoute.split('/').slice(1);
+    if (p.startsWith('./')) p = '/' + [...parent, p.slice(2)].join('/');
+    else if (p.startsWith('../')) {
+      const up: string[] = [...parent];
+      while (p.startsWith('../')) {
+        up.pop();
+        p = p.slice(3);
+      }
+      p = '/' + [...up, p].join('/');
+    } else p = '/' + [...parent, p].join('/');
+  }
+  const segs = p
+    .split('/')
+    .slice(1)
+    .filter((s) => s.length > 0 && !(s.startsWith('(') && s.endsWith(')')))
+    .map((s) => (s.includes(HOLE) ? '*' : decodeSegment(s)));
+  return segs;
+}
+
+function decodeSegment(s: string): string {
+  try {
+    return decodeURIComponent(s);
+  } catch {
+    return s;
+  }
+}
+
+/**
+ * The single route the href segments denote, or null when none or several do.
+ *
+ * Scored so that a literal segment beats a wildcard for the same slot and a
+ * `[param]` slot accepts either; a tie between two routes is ambiguity, and
+ * ambiguity is a null, not a coin flip.
+ */
+export function matchRoute(segs: string[], table: RouteTable): Node | null {
+  const exact = table.exact.get('/' + segs.join('/'));
+  if (exact) return exact;
+  let best: { node: Node; score: number } | null = null;
+  let tied = false;
+  for (const entry of table.dynamic) {
+    const score = scoreMatch(segs, entry.segs);
+    if (score === null) continue;
+    if (best === null || score > best.score) {
+      best = { node: entry.node, score };
+      tied = false;
+    } else if (score === best.score) tied = true;
+  }
+  return best && !tied ? best.node : null;
+}
+
+function scoreMatch(href: string[], route: string[]): number | null {
+  let score = 0;
+  let i = 0;
+  for (let r = 0; r < route.length; r++) {
+    const seg = route[r]!;
+    if (seg.startsWith('[...') && seg.endsWith(']')) {
+      // Catch-all: needs at least one segment and takes the rest.
+      if (i >= href.length) return null;
+      score += href.length - i;
+      i = href.length;
+      continue;
+    }
+    if (i >= href.length) return null;
+    const h = href[i]!;
+    if (seg.startsWith('[') && seg.endsWith(']')) score += 2;
+    else if (h === seg) score += 3;
+    else if (h === '*') score += 1;
+    else return null;
+    i++;
+  }
+  return i === href.length ? score : null;
+}
+
+// =============================================================================
+// The resolver
+// =============================================================================
+
+export const expoRouterResolver: FrameworkResolver = {
+  name: 'expo-router',
+  languages: [...ROUTE_LANGUAGES],
+
+  detect(context: ResolutionContext): boolean {
+    const packageJson = context.readFile('package.json');
+    if (packageJson) {
+      try {
+        const pkg = JSON.parse(packageJson);
+        const deps = { ...pkg.dependencies, ...pkg.devDependencies };
+        if (deps['expo-router']) return true;
+      } catch {
+        // Not JSON — fall through to the layout check.
+      }
+    }
+    const files = context.getAllFiles();
+    const hasLayout = files.some((f) => /(?:^|\/)(?:src\/)?app\/_layout\.(?:tsx|jsx|ts|js)$/.test(f));
+    const hasExpoConfig = files.some((f) => /^app\.(?:json|config\.(?:js|ts))$/.test(f));
+    return hasLayout && hasExpoConfig;
+  },
+
+  claimsReference(name: string): boolean {
+    return NAV_METHOD.test(name);
+  },
+
+  extract(filePath: string, content: string) {
+    const routePath = routePathForFile(filePath);
+    if (routePath === null) return { nodes: [], references: [] };
+    const language = languageForFile(filePath);
+    const node: Node = {
+      id: `route:${filePath}:${routePath}`,
+      kind: 'route',
+      name: routePath,
+      qualifiedName: `${filePath}::route:${routePath}`,
+      filePath,
+      startLine: 1,
+      endLine: 1,
+      startColumn: 0,
+      endColumn: 0,
+      language,
+      isExported: true,
+      updatedAt: Date.now(),
+    };
+    const references: UnresolvedRef[] = [];
+    const stripped = stripCommentsForRegex(content, 'typescript');
+    const screen = defaultExportName(stripped);
+    if (screen) {
+      references.push({
+        fromNodeId: node.id,
+        referenceName: screen.name,
+        referenceKind: 'calls',
+        line: stripped.slice(0, screen.index).split('\n').length,
+        column: 0,
+        filePath,
+        language,
+        candidates: [screen.name],
+      });
+    }
+    return { nodes: [node], references };
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    if (ref.referenceKind !== 'calls') return null;
+    const method = navVerb(ref.referenceName);
+    if (!method) return null;
+    if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
+    // The name to find on the line: `navigate` in `router.navigate(`, or the
+    // wrapper's own name in `safePush(`.
+    const callee = ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1);
+
+    const lines =
+      context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
+    if (!lines) return null;
+    let href = readHrefArgument(lines, ref.line, ref.column, callee);
+    if (!href) {
+      const enclosing = context.getNodeById?.(ref.fromNodeId);
+      const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
+      href = readHrefViaLocal(lines, ref.line, ref.column, callee, start);
+    }
+    if (!href) return null;
+    const table = routeTable(context);
+    const segs = normalizeHrefPath(href.path, ref.filePath);
+    if (segs === null) return null;
+    const target = matchRoute(segs, table);
+    if (!target) return null;
+    if (href.alternate) {
+      // `cond ? a : b` — one edge can carry one destination. Both arms
+      // reaching the same screen (a query-string difference, typically) is a
+      // confident bind; two different screens is a fork this ref can't record.
+      const altSegs = normalizeHrefPath(href.alternate.path, ref.filePath);
+      if (altSegs === null || matchRoute(altSegs, table)?.id !== target.id) return null;
+    }
+
+    return {
+      original: ref,
+      targetNodeId: target.id,
+      confidence: 0.95,
+      resolvedBy: 'framework',
+      edgeKind: 'navigates',
+      metadata: { href: href.display, navMethod: method, ...(callee !== method ? { via: callee } : {}) },
+    };
+  },
+};

+ 4 - 0
src/resolution/frameworks/index.ts

@@ -26,6 +26,7 @@ import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
 import { swiftObjcBridgeResolver } from './swift-objc';
 import { reactNativeBridgeResolver } from './react-native';
 import { expoModulesResolver } from './expo-modules';
+import { expoRouterResolver } from './expo-router';
 import { fabricViewResolver } from './fabric';
 import { cicsResolver } from './cics';
 import { terraformResolver } from './terraform';
@@ -70,6 +71,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   reactNativeBridgeResolver,
   // Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin
   expoModulesResolver,
+  // Expo Router — `app/` screen files → route nodes; `router.push('/x')` → navigates edges
+  expoRouterResolver,
   // React Native Fabric / Codegen view components — TS spec → component nodes
   fabricViewResolver,
   // CICS pseudo-conversational TRANSID hops (COBOL)
@@ -151,4 +154,5 @@ export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
 export { swiftObjcBridgeResolver } from './swift-objc';
 export { reactNativeBridgeResolver } from './react-native';
 export { expoModulesResolver } from './expo-modules';
+export { expoRouterResolver } from './expo-router';
 export { fabricViewResolver } from './fabric';

+ 3 - 1
src/resolution/index.ts

@@ -1068,7 +1068,8 @@ export class ReferenceResolver {
       // traverse `references`, so registration sites surface with no
       // graph-layer changes.
       let kind: Edge['kind'] =
-        ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind;
+        ref.edgeKind ??
+        (ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind);
 
       // Promote "extends" to "implements" when a class/struct targets an interface
       if (kind === 'extends') {
@@ -1103,6 +1104,7 @@ export class ReferenceResolver {
         line: ref.original.line,
         column: ref.original.column,
         metadata: {
+          ...(ref.metadata ?? {}),
           confidence: ref.confidence,
           resolvedBy: ref.resolvedBy,
           // The ORIGINAL reference text (and kind, when edge-kind promotion

+ 10 - 1
src/resolution/types.ts

@@ -4,7 +4,7 @@
  * Types for the reference resolution system.
  */
 
-import { Language, Node, ReferenceKind } from '../types';
+import { EdgeKind, Language, Node, ReferenceKind } from '../types';
 
 /**
  * An unresolved reference from extraction
@@ -43,6 +43,15 @@ export interface ResolvedRef {
   confidence: number;
   /** How it was resolved */
   resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref';
+  /**
+   * Edge kind the edge should carry when it is NOT the ref's own kind — a
+   * framework that turns a `calls` ref into a `navigates` edge, for example.
+   * The original kind is still recorded on the edge as `metadata.refKind`, so
+   * re-resolution after a target is removed reconstructs the ref faithfully.
+   */
+  edgeKind?: EdgeKind;
+  /** Extra metadata the strategy wants persisted on the edge (`href`, …). */
+  metadata?: Record<string, unknown>;
 }
 
 /**

+ 1 - 0
src/types.ts

@@ -67,6 +67,7 @@ export const EDGE_KINDS = [
   'instantiates',    // Creates instance of class
   'overrides',       // Method overrides parent method
   'decorates',       // Decorator applied to symbol
+  'navigates',       // Navigates to a screen/route (Expo Router `router.push('/x')`)
 ] as const;
 
 export type EdgeKind = (typeof EDGE_KINDS)[number];

+ 34 - 2
src/ui-server/api/flow.ts

@@ -34,7 +34,8 @@
  */
 
 import type CodeGraph from '../../index';
-import type { Edge, Node } from '../../types';
+import type { Edge, Language, Node } from '../../types';
+import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
 import {
   resolveNamedSymbolFlow,
   normalizeToken,
@@ -344,6 +345,8 @@ function toFlowEdge(edge: Edge, upward: boolean): WireFlowEdge {
 interface FileCache {
   lines: string[] | null;
   language: string;
+  /** Absolute path, when the file was read — what branch-guard parsing needs. */
+  abs?: string;
   drift: boolean;
   reason?: string;
 }
@@ -378,6 +381,7 @@ function loadFile(
       entry = {
         lines: splitLines(fs.readFileSync(absolute, 'utf-8')),
         language: found.record.language,
+        abs: absolute,
         drift: false,
       };
     } catch {
@@ -444,6 +448,23 @@ async function windowFor(
   };
 }
 
+/** The branch label for `edge`'s call site in `siteNode`'s file, or ''. */
+async function whenAt(
+  cg: CodeGraph,
+  projectRoot: string,
+  cache: Map<string, FileCache>,
+  siteNode: Node,
+  edge: Edge
+): Promise<string> {
+  if (!edge.line || !supportsBranchGuards(siteNode.language)) return '';
+  const file = loadFile(cg, projectRoot, cache, siteNode.filePath);
+  if (!file || file.drift || !file.abs) return '';
+  const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
+  const guards = await guardsForFile(file.abs, file.language as Language, [site]);
+  const g = guards.get(siteKey(site));
+  return g ? guardLabel(g) : '';
+}
+
 // =============================================================================
 // Building the flows
 // =============================================================================
@@ -494,9 +515,20 @@ async function toWireFlow(
         backwards: true,
       };
     }
+    // The connector's condition: the call site is in the caller's file — the
+    // previous card going down, this card itself when the reader stepped up.
+    const wireEdge = step.edge === null ? null : toFlowEdge(step.edge, step.upward);
+    if (wireEdge && step.edge?.line) {
+      const siteNode = step.upward ? step.node : previous?.node;
+      const when = siteNode ? await whenAt(cg, projectRoot, cache, siteNode, step.edge) : '';
+      if (when) {
+        wireEdge.when = when;
+        wireEdge.label = `${wireEdge.label} · when ${when}`;
+      }
+    }
     hops.push({
       node: toNodeRef(step.node),
-      edge: step.edge === null ? null : toFlowEdge(step.edge, step.upward),
+      edge: wireEdge,
       callRef,
       source: await windowFor(
         cg,

+ 12 - 4
src/ui-server/api/index.ts

@@ -56,6 +56,7 @@ import { buildRoutes } from './routes';
 import { buildEntryPoints } from './entrypoints';
 import { buildNodeRefs } from './nodes';
 import { buildMap } from './map';
+import { buildScreens } from './screens';
 import { buildDeadCode } from './deadcode';
 import { buildFlow } from './flow';
 import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
@@ -196,6 +197,11 @@ const API_INDEX = {
       description: 'The repository at module granularity: modules, cross-module links, cycles.',
       params: ['root', 'depth'],
     },
+    {
+      path: '/api/screens',
+      description: 'The app as screens and the transitions between them, each with the conditions it runs under.',
+      params: [],
+    },
     {
       path: '/api/flow',
       description: 'The call path between symbols: one hop per card, opened at the calling line.',
@@ -261,6 +267,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
         case '/api/map':
           return ok(res, buildMap(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
+        case '/api/screens':
+          return ok(res, await buildScreens(session.acquire(), ctx.projectRoot), ctx.method);
         case '/api/deadcode':
           return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         case '/api/entrypoints':
@@ -278,7 +286,7 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           // the socket open, so it never goes through `ok()`.
           return events.subscribe(req, res, ctx.method);
         default:
-          return dispatchPathRoutes(route, res, ctx, session);
+          return await dispatchPathRoutes(route, res, ctx, session);
       }
     } catch (err) {
       // A refusal from the read chokepoint is a 403 with the reason attached —
@@ -352,16 +360,16 @@ async function dispatchWrite(
  * straight to an exact lookup, and anything that names nothing is a 404. File
  * paths go through the read chokepoint before anything is opened.
  */
-function dispatchPathRoutes(
+async function dispatchPathRoutes(
   route: string,
   res: Parameters<UiApiHandler>[1],
   ctx: UiRequestContext,
   session: GraphSession
-): boolean {
+): Promise<boolean> {
   const nodeId = suffixAfter(route, '/api/node/');
   if (nodeId !== null) {
     if (nodeId === '') throw badRequest('No symbol id was given. Use /api/node/<id>.');
-    return ok(res, buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
+    return ok(res, await buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
   }
 
   // Before `/api/file/`: that prefix is not a prefix of this route, but keeping

+ 13 - 3
src/ui-server/api/map.ts

@@ -53,6 +53,7 @@ export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
   'instantiates',
   'extends',
   'implements',
+  'navigates',
 ];
 
 /**
@@ -61,7 +62,7 @@ export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
  * A `references` edge to a type is real traffic but "Config → Config" is not
  * an interesting row; calls and imports are what a reader wants named.
  */
-const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates'];
+const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates', 'navigates'];
 
 /** Symbol pairs kept per link — the tooltip shows four (design spec §3.6). */
 const TOP_PAIRS_PER_LINK = 4;
@@ -261,12 +262,17 @@ export function pickDefaultRoot(
   if (total === 0) return '';
   let best = '';
   let bestSymbols = 0;
+  let second = 0;
   for (const [dir, symbols] of [...byDir].sort((a, b) => a[0].localeCompare(b[0]))) {
     if (symbols > bestSymbols) {
+      second = bestSymbols;
       best = dir;
       bestSymbols = symbols;
-    }
+    } else if (symbols > second) second = symbols;
   }
+  // A second root holding a fifth of the code (a React Native app's `ios/`
+  // beside its `src/`) belongs on the picture: map the whole project.
+  if (second * 5 >= total) return '';
   return bestSymbols * 2 > total ? best : '';
 }
 
@@ -310,7 +316,7 @@ export function parseMapQuery(query: URLSearchParams): { root: string | null; de
 
 export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
   const started = Date.now();
-  const { root: requestedRoot, depth } = parseMapQuery(query);
+  let { root: requestedRoot, depth } = parseMapQuery(query);
 
   const fileRecords = cg.getFiles().map((file) => {
     const path = toPosixPath(file.path);
@@ -324,6 +330,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
   });
 
   const root = requestedRoot ?? pickDefaultRoot(fileRecords);
+  // Left to choose, and choosing the whole project (two substantial roots):
+  // one level deeper, so the boxes are `src/app` and `ios/CaptureView`, not
+  // `src` and `ios`.
+  if (requestedRoot === null && root === '' && !query.has('depth')) depth = 2;
   const stats = cg.getStats();
   const key = [
     projectRoot,

+ 9 - 1
src/ui-server/api/node.ts

@@ -26,6 +26,7 @@ import { isTestFile } from '../../search/query-utils';
 import { buildHierarchy, type WireOverride } from './hierarchy';
 import { notFound } from './respond';
 import { findIndexedFile, hasDriftedOnDisk } from './source';
+import { annotateWhen } from './when';
 import {
   BLAST_DEPTH,
   CALLER_EDGE_KINDS,
@@ -73,7 +74,7 @@ export interface WireMember extends WireNodeRef {
   overrides?: WireOverride;
 }
 
-export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
+export async function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): Promise<unknown> {
   const node = cg.getNode(nodeId);
   if (!node) {
     throw notFound(
@@ -146,6 +147,13 @@ export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): u
   const shownIncoming = incomingGroups.slice(0, MAX_INCOMING_GROUPS);
   const shownOutgoing = outgoingGroups.slice(0, MAX_OUTGOING_GROUPS);
 
+  // Branch conditions per call site: the right rail's sites are all in this
+  // file; the left rail's are in each caller's own file.
+  await annotateWhen(cg, projectRoot, [
+    { file: focalFile, edges: shownOutgoing.flatMap((r) => r.edges) },
+    ...shownIncoming.map((r) => ({ file: r.node.file, edges: r.edges })),
+  ]);
+
   // Fan-in for the rail pills ("hub · N"), for the rows actually returned —
   // one query, not one per row.
   const fanInOf = cg.getFanIn([

+ 500 - 0
src/ui-server/api/screens.ts

@@ -0,0 +1,500 @@
+/**
+ * `GET /api/screens` — the app as a reader experiences it: screens, and the
+ * transitions between them, each labelled with what has to be true for it to
+ * happen.
+ *
+ * The graph already holds the pieces: a `route` node per screen file (Expo
+ * Router, and any framework that binds a route to the component that renders
+ * it), and a `navigates` edge from the function that pushes a path to the
+ * route it names. What a reader wants is neither of those nodes — it is
+ * "from the Home screen, tapping an object card opens Object Detail, but only
+ * for a collected object". That sentence is three hops away from the edge:
+ *
+ *   HomeScreen ─renders→ ItemsGrid ─renders→ ItemCard ─calls→ openObjectDetail ─navigates→ /object-detail
+ *
+ * So for every `navigates` edge this walks BACKWARDS from its source through
+ * `calls` edges (the JSX-render synthesizer's edges among them) until it
+ * reaches a component that a route renders. That component's screen is where
+ * the transition starts; the nodes passed on the way are the `via` chain, and
+ * the branch conditions at each call site along it (`graph/branch-guards.ts`)
+ * are joined into the link's `when`. A navigation whose walk reaches no screen
+ * within the hop cap — a store action, a service that runs after login — is
+ * kept as an `origin` rather than dropped: it is a real transition with a real
+ * trigger, just not a screen.
+ *
+ * Read from the graph at request time, never cached: the `when` labels are
+ * read from the source as it stands. Seventy-odd transitions and a few
+ * hundred guarded call sites resolve in tens of milliseconds.
+ */
+
+import type CodeGraph from '../../index';
+import type { Edge, Language, Node } from '../../types';
+import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
+import { resolveProjectFile } from '../security';
+import { findIndexedFile, hasDriftedOnDisk } from './source';
+import { toNodeRef, type WireNodeRef } from './wire';
+
+// =============================================================================
+// Wire shapes
+// =============================================================================
+
+export interface WireScreen {
+  /** The route node's id — what a link's `from`/`to` name. */
+  id: string;
+  /** The screen's path: `/object-detail`, `/item/[id]`. */
+  path: string;
+  file: string;
+  line: number;
+  /** The component the route renders, when the graph bound one. */
+  component: WireNodeRef | null;
+  /** Transitions into and out of this screen. */
+  incoming: number;
+  outgoing: number;
+}
+
+/**
+ * A navigation whose start is not one screen: a function no screen reaches
+ * (a store action after login), or a component so many screens render (a
+ * top bar) that attributing its navigation to each of them would draw the
+ * same three arrows from every box.
+ */
+export interface WireScreenOrigin {
+  id: string;
+  node: WireNodeRef;
+  outgoing: number;
+  /** For shared chrome: how many screens render it. */
+  sharedBy?: number;
+}
+
+export interface WireScreenSite {
+  file: string;
+  line: number;
+  /** The href as written at the call, `${…}` for interpolations. */
+  href: string;
+  /** `push`, `replace`, `navigate`, or `return` for a helper's return value. */
+  method: string;
+  /** Branch conditions at this site alone. */
+  when: string;
+}
+
+export interface WireScreenLink {
+  id: string;
+  /** A screen id, or an origin id. */
+  from: string;
+  /** Always a screen id. */
+  to: string;
+  /** True when `from` is an origin, not a screen. */
+  fromOrigin: boolean;
+  /**
+   * The symbols the transition passes through, from just below the screen's
+   * component down to the one that holds the navigation call. Empty when the
+   * screen's own component navigates.
+   */
+  via: WireNodeRef[];
+  /** Conditions along the whole chain, joined; '' when unconditional. */
+  when: string;
+  /** Every call site behind this link (same screen, same chain end). */
+  sites: WireScreenSite[];
+  /**
+   * The destination was inferred, not written at the call: it came back from
+   * a helper's return value. (A synthesized render hop on the way — every
+   * parent → child component step is one — does not count: that would dash
+   * nearly every arrow.)
+   */
+  synthesized: boolean;
+}
+
+export interface WireScreensPayload {
+  /** False when the graph holds no screen navigation at all. */
+  routed: boolean;
+  /** The route named `/`, when there is one. */
+  entry: string | null;
+  screens: WireScreen[];
+  origins: WireScreenOrigin[];
+  links: WireScreenLink[];
+  /** Navigations dropped because the backwards walk hit a cap. */
+  dropped: number;
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
+// =============================================================================
+// Caps
+// =============================================================================
+
+/** Hops walked back from a navigation call before giving up on a screen. */
+const MAX_DEPTH = 7;
+/** Callers expanded per node — a hub (`useToast`) is a dead end, not a path. */
+const MAX_CALLERS_PER_NODE = 30;
+/** Nodes visited per navigation. */
+const MAX_VISITED = 800;
+/** Call sites labelled with conditions per request. */
+const MAX_WHEN_SITES = 600;
+
+/**
+ * Edges walked backwards from a navigation call. `contains` because a handler
+ * declared inside a screen component (`function handleContinue() {…}` in the
+ * body) is reached from the component by containment, not by a call; a
+ * `references` edge is followed only when it passes the function as a value
+ * (`onPress={handleContinue}`), never for a type mention.
+ */
+const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'contains', 'references'];
+
+/** A component rendered by at least this many screens is chrome, not a screen's own behaviour. */
+const SHARED_CHROME_MIN = 3;
+
+// =============================================================================
+// The endpoint
+// =============================================================================
+
+export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<WireScreensPayload> {
+  const started = Date.now();
+  const stats = cg.getStats();
+  const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
+
+  const routes = cg.getNodesByKind('route');
+  const routeIds = routes.map((r) => r.id);
+  const navEdges = routeIds.length === 0 ? [] : cg.getIncomingEdgesTo(routeIds, ['navigates']);
+  if (navEdges.length === 0) {
+    return {
+      routed: false,
+      entry: null,
+      screens: [],
+      origins: [],
+      links: [],
+      dropped: 0,
+      index,
+      timing: { elapsedMs: Date.now() - started },
+    };
+  }
+
+  // Route → the component it renders; component → its route.
+  const routeById = new Map(routes.map((r) => [r.id, r]));
+  const routeByFile = new Map(routes.map((r) => [r.filePath, r.id]));
+  const renders = cg.getOutgoingEdgesFrom(routeIds, ['calls', 'instantiates']);
+  const componentIds = new Set(renders.map((e) => e.target));
+  const nodesById = cg.getNodesByIds([...componentIds, ...navEdges.map((e) => e.source)]);
+  const componentOf = new Map<string, Node>();
+  const screenOfComponent = new Map<string, string>();
+  for (const edge of renders) {
+    const component = nodesById.get(edge.target);
+    if (!component || componentOf.has(edge.source)) continue;
+    componentOf.set(edge.source, component);
+    screenOfComponent.set(component.id, edge.source);
+  }
+
+  const whenAt = makeWhenReader(cg, projectRoot);
+  const links = new Map<string, WireScreenLink>();
+  const origins = new Map<string, WireScreenOrigin>();
+  const counts = new Map<string, { incoming: number; outgoing: number }>();
+  const bump = (id: string, key: 'incoming' | 'outgoing') => {
+    const c = counts.get(id) ?? { incoming: 0, outgoing: 0 };
+    c[key]++;
+    counts.set(id, c);
+  };
+  let dropped = 0;
+
+  for (const nav of navEdges) {
+    const holder = nodesById.get(nav.source);
+    const target = routeById.get(nav.target);
+    if (!holder || !target) continue;
+    const meta = (nav.metadata ?? {}) as Record<string, unknown>;
+    const site: WireScreenSite = {
+      file: toPosix(holder.filePath),
+      line: nav.line ?? holder.startLine,
+      href: typeof meta.href === 'string' ? meta.href : target.name,
+      method: nav.provenance === 'heuristic' ? 'return' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push',
+      when: nav.provenance === 'heuristic' ? '' : await whenAt(holder, nav),
+    };
+
+    let starts = await attribute(cg, holder, screenOfComponent, routeByFile, nodesById);
+    if (starts === null) {
+      dropped++;
+      continue;
+    }
+    starts = collapseSharedChrome(starts, origins);
+    const attributions =
+      starts.length > 0
+        ? starts
+        : [{ screenId: null as string | null, path: [{ node: holder, edge: null }] as Array<{ node: Node; edge: Edge | null }> }];
+
+    for (const start of attributions) {
+      let fromId: string;
+      let fromOrigin = false;
+      if (start.screenId !== null) fromId = start.screenId;
+      else {
+        // The origin is the chain's head: the holder itself, or the shared
+        // component the chain was collapsed onto.
+        const head = start.path[0]!.node;
+        fromId = head.id;
+        fromOrigin = true;
+        if (!origins.has(head.id)) origins.set(head.id, { id: head.id, node: toNodeRef(head), outgoing: 0 });
+      }
+
+      // `path` is [screen component, …, holder]; `path[i].edge` is the call
+      // from `path[i-1]` into `path[i]`, so its site is in `path[i-1]`'s file.
+      // The component itself is not "via" — it IS the screen.
+      const via = start.path.slice(1).map((h) => toNodeRef(h.node));
+      const whens: string[] = [];
+      const synthesized = nav.provenance === 'heuristic';
+      for (let i = 1; i < start.path.length; i++) {
+        const edge = start.path[i]!.edge;
+        if (!edge) continue;
+        const w = await whenAt(start.path[i - 1]!.node, edge);
+        if (w && !whens.includes(w)) whens.push(w);
+      }
+      if (site.when && !whens.includes(site.when)) whens.push(site.when);
+
+      const viaKey = via.map((v) => v.id).join('>');
+      if (fromOrigin && start.path[0]!.node.id !== holder.id) {
+        // A collapsed chain: the origin's own name is not "via".
+      }
+      const id = `${fromId}${target.id}${viaKey}`;
+      const existing = links.get(id);
+      if (existing) {
+        existing.sites.push(site);
+        const mine = whens.join(' && ');
+        if (mine !== existing.when) {
+          // `if (x) push(A) else push(A)`: the two arms together are "always".
+          if (complementary(mine, existing.when)) existing.when = '';
+          else if (mine && existing.when) existing.when = `${existing.when} || ${mine}`;
+          else if (!mine) existing.when = '';
+        }
+        continue;
+      }
+      links.set(id, {
+        id,
+        from: fromId,
+        to: target.id,
+        fromOrigin,
+        via,
+        when: whens.join(' && '),
+        sites: [site],
+        synthesized,
+      });
+      bump(target.id, 'incoming');
+      if (fromOrigin) origins.get(fromId)!.outgoing++;
+      else bump(fromId, 'outgoing');
+    }
+  }
+
+  const screens: WireScreen[] = routes
+    .map((route) => {
+      const component = componentOf.get(route.id) ?? null;
+      const c = counts.get(route.id) ?? { incoming: 0, outgoing: 0 };
+      return {
+        id: route.id,
+        path: route.name,
+        file: toPosix(route.filePath),
+        line: route.startLine,
+        component: component ? toNodeRef(component) : null,
+        incoming: c.incoming,
+        outgoing: c.outgoing,
+      };
+    })
+    .sort((a, b) => a.path.localeCompare(b.path));
+
+  const entry = screens.find((s) => s.path === '/')?.id ?? null;
+  const ordered = [...links.values()].sort((a, b) => a.id.localeCompare(b.id));
+  return {
+    routed: true,
+    entry,
+    screens,
+    origins: [...origins.values()].sort((a, b) => a.node.name.localeCompare(b.node.name)),
+    links: ordered,
+    dropped,
+    index,
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+// =============================================================================
+// Attribution: which screen does this navigation start from?
+// =============================================================================
+
+interface Attribution {
+  screenId: string | null;
+  /** [screen component, …, holder], each with the edge that led INTO it from the previous. */
+  path: Array<{ node: Node; edge: Edge | null }>;
+}
+
+/**
+ * Every screen whose component reaches `holder` through calls, each with the
+ * shortest chain (breadth-first). `[]` when none does within the caps but the
+ * walk completed; `null` when the walk was cut short — a hub so wide the
+ * answer would be a guess.
+ */
+async function attribute(
+  cg: CodeGraph,
+  holder: Node,
+  screenOfComponent: Map<string, string>,
+  routeByFile: Map<string, string>,
+  known: Map<string, Node>
+): Promise<Attribution[] | null> {
+  // The holder IS a screen component: the transition starts on that screen.
+  const own = screenOfComponent.get(holder.id);
+  if (own) return [{ screenId: own, path: [{ node: holder, edge: null }] }];
+
+  const parent = new Map<string, { prev: string | null; edge: Edge | null }>();
+  parent.set(holder.id, { prev: null, edge: null });
+  const nodes = new Map<string, Node>([[holder.id, holder]]);
+  let frontier = [holder.id];
+  const found: Attribution[] = [];
+  let truncated = false;
+
+  for (let depth = 0; depth < MAX_DEPTH && frontier.length > 0; depth++) {
+    const incoming = cg.getIncomingEdgesTo(frontier, WALK_KINDS);
+    const byTarget = new Map<string, Edge[]>();
+    for (const e of incoming) {
+      if (e.kind === 'references' && (e.metadata as Record<string, unknown> | undefined)?.fnRef !== true) continue;
+      const list = byTarget.get(e.target) ?? [];
+      list.push(e);
+      byTarget.set(e.target, list);
+    }
+    const nextIds: string[] = [];
+    const wanted = new Set<string>();
+    for (const [, edges] of byTarget) {
+      if (edges.length > MAX_CALLERS_PER_NODE) {
+        truncated = true;
+        continue;
+      }
+      for (const e of edges) if (!parent.has(e.source)) wanted.add(e.source);
+    }
+    if (parent.size + wanted.size > MAX_VISITED) truncated = true;
+    const fetched = wanted.size === 0 ? new Map<string, Node>() : cg.getNodesByIds([...wanted]);
+    for (const [, edges] of byTarget) {
+      if (edges.length > MAX_CALLERS_PER_NODE) continue;
+      for (const e of edges) {
+        if (parent.has(e.source)) continue;
+        const caller = fetched.get(e.source) ?? known.get(e.source);
+        // A file's top level or a route node is not a place a user is.
+        if (!caller || caller.kind === 'file' || caller.kind === 'route') continue;
+        parent.set(e.source, { prev: e.target, edge: e });
+        nodes.set(e.source, caller);
+        const screen = screenOfComponent.get(caller.id);
+        if (screen) {
+          found.push({ screenId: screen, path: pathFrom(caller.id, parent, nodes) });
+          continue; // a screen is where the walk stops
+        }
+        nextIds.push(e.source);
+        if (parent.size >= MAX_VISITED) break;
+      }
+    }
+    frontier = nextIds;
+    if (parent.size >= MAX_VISITED) {
+      truncated = true;
+      break;
+    }
+  }
+  if (found.length > 0) return found;
+  // No screen component reached, but the chain passed through a screen's
+  // FILE: a component that file defines for itself (a wrapper the render
+  // synthesizer did not see through) belongs to that screen. Nearest first,
+  // so the holder's own file wins over a helper's.
+  for (const [id] of parent) {
+    const node = nodes.get(id);
+    const screen = node ? routeByFile.get(node.filePath) : undefined;
+    if (screen) return [{ screenId: screen, path: pathFrom(id, parent, nodes) }];
+  }
+  return truncated ? null : [];
+}
+
+/**
+ * Shared chrome: when the same first-hop component carries this navigation
+ * to {@link SHARED_CHROME_MIN} or more screens, those attributions collapse
+ * into ONE from that component, marked with how many screens render it. A top
+ * bar's "Account settings" link is one fact about the top bar, not twelve
+ * facts about twelve screens.
+ */
+function collapseSharedChrome(starts: Attribution[], origins: Map<string, WireScreenOrigin>): Attribution[] {
+  const byFirstHop = new Map<string, Attribution[]>();
+  for (const s of starts) {
+    if (s.screenId === null || s.path.length < 2) continue;
+    const key = s.path[1]!.node.id;
+    byFirstHop.set(key, [...(byFirstHop.get(key) ?? []), s]);
+  }
+  const out: Attribution[] = [];
+  const collapsed = new Set<Attribution>();
+  for (const [, group] of byFirstHop) {
+    const screens = new Set(group.map((g) => g.screenId));
+    if (screens.size < SHARED_CHROME_MIN) continue;
+    const head = group[0]!.path[1]!.node;
+    const existing = origins.get(head.id);
+    if (existing) existing.sharedBy = Math.max(existing.sharedBy ?? 0, screens.size);
+    else origins.set(head.id, { id: head.id, node: toNodeRef(head), outgoing: 0, sharedBy: screens.size });
+    // One attribution, headed by the shared component, chain continuing below it.
+    out.push({ screenId: null, path: group[0]!.path.slice(1) });
+    for (const g of group) collapsed.add(g);
+  }
+  for (const s of starts) if (!collapsed.has(s)) out.push(s);
+  return out;
+}
+
+/** The chain from `start` down to the holder, following `prev` links. */
+function pathFrom(
+  start: string,
+  parent: Map<string, { prev: string | null; edge: Edge | null }>,
+  nodes: Map<string, Node>
+): Array<{ node: Node; edge: Edge | null }> {
+  const out: Array<{ node: Node; edge: Edge | null }> = [];
+  let id: string | null = start;
+  let edgeInto: Edge | null = null;
+  while (id !== null) {
+    const node = nodes.get(id)!;
+    out.push({ node, edge: edgeInto });
+    const step: { prev: string | null; edge: Edge | null } = parent.get(id)!;
+    edgeInto = step.edge;
+    id = step.prev;
+  }
+  return out;
+}
+
+// =============================================================================
+// Conditions
+// =============================================================================
+
+/** `x` and `!x`, or `a && x` and `a && !x`. */
+function complementary(a: string, b: string): boolean {
+  if (!a || !b) return false;
+  const pa = a.split(' && ');
+  const pb = b.split(' && ');
+  if (pa.length !== pb.length) return false;
+  let flips = 0;
+  for (let i = 0; i < pa.length; i++) {
+    if (pa[i] === pb[i]) continue;
+    if (pa[i] === `!${pb[i]}` || pb[i] === `!${pa[i]}`) flips++;
+    else return false;
+  }
+  return flips === 1;
+}
+
+function makeWhenReader(cg: CodeGraph, projectRoot: string) {
+  const files = new Map<string, { abs: string; language: Language } | null>();
+  let sites = 0;
+  return async (caller: Node, edge: Edge): Promise<string> => {
+    if (!edge.line || sites >= MAX_WHEN_SITES || !supportsBranchGuards(caller.language)) return '';
+    const posix = toPosix(caller.filePath);
+    let file = files.get(posix);
+    if (file === undefined) {
+      file = null;
+      const found = findIndexedFile(cg, posix);
+      if (found && !hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) {
+        try {
+          file = { abs: resolveProjectFile(projectRoot, found.storedPath), language: found.record.language as Language };
+        } catch {
+          file = null;
+        }
+      }
+      files.set(posix, file);
+    }
+    if (!file) return '';
+    sites++;
+    const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
+    const g = (await guardsForFile(file.abs, file.language, [site])).get(siteKey(site));
+    return g ? guardLabel(g) : '';
+  };
+}
+
+function toPosix(p: string): string {
+  return p.replace(/\\/g, '/');
+}

+ 75 - 0
src/ui-server/api/when.ts

@@ -0,0 +1,75 @@
+/**
+ * `when` on a wire edge — the branch conditions its call site sits under,
+ * read from the source at request time (see `src/graph/branch-guards.ts`).
+ *
+ * The viewer groups a symbol's edges into relations; this annotates the edges
+ * of a set of relations in one pass, parsing each file once. Files that
+ * drifted since the index sync are skipped: the recorded line no longer
+ * reliably points at the call, and a label at the wrong line is worse than
+ * none. The pass is bounded so a hub with hundreds of callers cannot turn one
+ * Symbol view into a parse of the repository.
+ */
+
+import type CodeGraph from '../../index';
+import type { Language } from '../../types';
+import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
+import { resolveProjectFile } from '../security';
+import { findIndexedFile, hasDriftedOnDisk } from './source';
+import type { WireEdge } from './wire';
+
+/** Distinct files parsed per request, and sites labelled per request. */
+const MAX_FILES = 24;
+const MAX_SITES = 400;
+
+/**
+ * Wall-clock allowance for the whole pass. The Symbol view answers in under
+ * 100 ms; batches are taken in order (the focal file first), and once the
+ * budget is spent the remaining rails simply carry no `when`. The parsed
+ * trees are cached, so the next view of the same neighbourhood is cheaper.
+ */
+const BUDGET_MS = 40;
+
+export interface WhenBatch {
+  /** POSIX project-relative path of the file the call sites are in. */
+  file: string;
+  edges: WireEdge[];
+}
+
+export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches: readonly WhenBatch[]): Promise<void> {
+  const byFile = new Map<string, WireEdge[]>();
+  for (const batch of batches) {
+    const bucket = byFile.get(batch.file);
+    if (bucket) bucket.push(...batch.edges);
+    else byFile.set(batch.file, [...batch.edges]);
+  }
+  let files = 0;
+  let sites = 0;
+  const started = Date.now();
+  for (const [file, edges] of byFile) {
+    if (files >= MAX_FILES || sites >= MAX_SITES) return;
+    if (files > 0 && Date.now() - started > BUDGET_MS) return;
+    const found = findIndexedFile(cg, file);
+    if (!found || !supportsBranchGuards(found.record.language)) continue;
+    if (hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) continue;
+    let abs: string;
+    try {
+      abs = resolveProjectFile(projectRoot, found.storedPath);
+    } catch {
+      continue;
+    }
+    const withLine = edges.filter((e) => typeof e.line === 'number' && e.line > 0);
+    if (withLine.length === 0) continue;
+    files++;
+    sites += withLine.length;
+    const guards = await guardsForFile(
+      abs,
+      found.record.language as Language,
+      withLine.map((e) => ({ line: e.line!, column: typeof e.col === 'number' ? e.col : null }))
+    );
+    for (const edge of withLine) {
+      const g = guards.get(siteKey({ line: edge.line!, column: typeof edge.col === 'number' ? edge.col : null }));
+      const label = g ? guardLabel(g) : '';
+      if (label) edge.when = label;
+    }
+  }
+}

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

@@ -181,6 +181,12 @@ export interface WireEdge {
   via?: string;
   registeredAt?: string;
   valueRef?: boolean;
+  /**
+   * The conditions the call site runs under (`!isUploading && isCollected`),
+   * read from the source at request time — see `graph/branch-guards.ts`.
+   * Absent when the site is unconditional or the language has no rules.
+   */
+  when?: string;
 }
 
 export function toWireEdge(edge: Edge): WireEdge {
@@ -339,6 +345,7 @@ export const CALLER_EDGE_KINDS: ReadonlySet<EdgeKind> = new Set<EdgeKind>([
   'references',
   'imports',
   'instantiates',
+  'navigates',
 ]);
 
 export { rel as toPosixPath };

+ 14 - 1
ui/src/App.svelte

@@ -7,6 +7,7 @@
   import FileView from './views/FileView.svelte';
   import FileCodeView from './views/FileCodeView.svelte';
   import MapView from './views/MapView.svelte';
+  import ScreensView from './views/ScreensView.svelte';
   import FlowView from './views/FlowView.svelte';
   import EntryView from './views/EntryView.svelte';
   import DeadCodeView from './views/DeadCodeView.svelte';
@@ -19,6 +20,7 @@
     mapHref,
     flowHref,
     entryHref,
+    screensHref,
     deadHref,
   } from './lib/router.svelte';
   import { palette } from './lib/palette.svelte';
@@ -66,6 +68,11 @@
 
   let route = $derived(router.route);
 
+  // An app with screens opens on them. The Symbol tab's empty state is for a
+  // library, where there is nothing to draw until a name is typed; a project
+  // whose graph holds screen navigation has a picture worth landing on.
+  let hasScreens = $derived((project.stats?.graph.edgesByKind.navigates ?? 0) > 0);
+
   // Keep the in-memory trail and the `t` param in step. untrack() because the
   // body writes the same store it would otherwise read itself into a loop.
   $effect(() => {
@@ -127,6 +134,10 @@
         event.preventDefault();
         navigate(entryHref());
         break;
+      case 's':
+        event.preventDefault();
+        navigate(screensHref());
+        break;
       case 'd':
         event.preventDefault();
         navigate(deadHref());
@@ -142,7 +153,7 @@
 
 <svelte:window {onkeydown} />
 
-<TopBar bind:this={topbar} project={project.name} stats={project.summary} />
+<TopBar bind:this={topbar} project={project.name} stats={project.summary} showScreens={hasScreens} />
 <TrailBar />
 <main>
   {#if route.view === 'symbol'}
@@ -162,6 +173,8 @@
     />
   {:else if route.view === 'entry'}
     <EntryView project={project.name} />
+  {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
+    <ScreensView />
   {:else if route.view === 'dead'}
     <DeadCodeView exported={route.exported} />
   {:else if route.view === 'unknown'}

+ 6 - 3
ui/src/components/TopBar.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { router, mapHref, flowHref, entryHref, deadHref, symbolHref } from '../lib/router.svelte';
+  import { router, mapHref, flowHref, entryHref, screensHref, deadHref, symbolHref } from '../lib/router.svelte';
   import { trail } from '../lib/trail.svelte';
   import SearchPalette from './SearchPalette.svelte';
   import { live } from '../lib/live.svelte';
@@ -9,9 +9,11 @@
     project?: string | null;
     /** "13,060 symbols · 46,004 edges · 593 files indexed". Null until loaded. */
     stats?: string | null;
+    /** The project's graph holds screen navigation: show the Screens tab and land on it. */
+    showScreens?: boolean;
   }
 
-  let { project = null, stats = null }: Props = $props();
+  let { project = null, stats = null, showScreens = false }: Props = $props();
 
   let search: SearchPalette | null = $state(null);
 
@@ -65,9 +67,10 @@
   </a>
 
   <nav class="views" aria-label="Views">
+    {#if showScreens}<a href={screensHref()} class:active={view === 'screens' || view === 'home'}>Screens</a>{/if}
     <a href={entryHref()} class:active={view === 'entry'}>Entry points</a>
     <a href={mapHref()} class:active={view === 'map'}>Map</a>
-    <a href={symbolTabHref} class:active={view === 'symbol' || view === 'home'}>Symbol</a>
+    <a href={symbolTabHref} class:active={view === 'symbol' || (view === 'home' && !showScreens)}>Symbol</a>
     <a href={flowHref()} class:active={view === 'flow'}>Flow</a>
     <a href={deadHref()} class:active={view === 'dead'}>Dead code</a>
   </nav>

+ 113 - 0
ui/src/components/screens/ScreenEdge.svelte

@@ -0,0 +1,113 @@
+<script lang="ts">
+  /**
+   * One transition on the Screens view — the Map's cubic, plus a label at the
+   * midpoint saying under what condition it happens. A pair with several
+   * transitions draws once and counts them; the tooltip and the side panel
+   * tell them apart. Dashed when every transition behind it rides a
+   * synthesized hop (a helper's return value); accent-dashed when it points
+   * back up the layering (Capture → Home).
+   */
+  import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
+  import type { MapEdgeLayout } from '../../lib/map-model';
+  import type { ScreenEdgeInfo } from '../../lib/screens-model';
+
+  let { sourceX, sourceY, targetX, targetY, data }: EdgeProps = $props();
+
+  const d = $derived(
+    data as unknown as {
+      edge: MapEdgeLayout;
+      info: ScreenEdgeInfo;
+      hot: boolean;
+      dimmed: boolean;
+      /** Show the label; only the selected screen's edges and the hovered one do. */
+      labelled: boolean;
+      /**
+       * Where along the curve the label sits, 0 = source end, 1 = target end.
+       * Close to the selected screen, where its lines are still apart, so a
+       * label sits beside the one line it belongs to.
+       */
+      labelAt: number;
+      onHover: (edge: MapEdgeLayout | null, event: MouseEvent | null) => void;
+    }
+  );
+
+  const midY = $derived((sourceY + targetY) / 2);
+  const path = $derived(`M${sourceX},${sourceY} C${sourceX},${midY} ${targetX},${midY} ${targetX},${targetY}`);
+
+  /** The point at `t` on the same cubic the path draws. */
+  const label = $derived.by(() => {
+    const t = d.labelAt;
+    const u = 1 - t;
+    const x = u * u * u * sourceX + 3 * u * u * t * sourceX + 3 * u * t * t * targetX + t * t * t * targetX;
+    const y = u * u * u * sourceY + 3 * u * u * t * midY + 3 * u * t * t * midY + t * t * t * targetY;
+    return { x, y };
+  });
+  /** IBM Plex Mono at 10.5px: ~6.3px per character, plus the pill's padding. */
+  const pillWidth = $derived(d.info.label.length * 6.3 + 12);
+</script>
+
+<BaseEdge
+  {path}
+  class={`sedge${d.edge.back ? ' back' : ''}${d.hot ? ' hot' : ''}${d.dimmed ? ' dimmed' : ''}${d.info.synthesized ? ' synth' : ''}`}
+  style={`stroke-width:${Math.min(3, d.edge.width)}px`}
+/>
+<path
+  class="hit"
+  d={path}
+  role="presentation"
+  onmousemove={(event) => d.onHover(d.edge, event)}
+  onmouseleave={() => d.onHover(null, null)}
+/>
+{#if d.info.label && d.labelled}
+  <g class="epill" class:hot={d.hot}>
+    <rect x={label.x - pillWidth / 2} y={label.y - 9} width={pillWidth} height={17} rx="2" />
+    <text x={label.x} y={label.y + 3.5} text-anchor="middle">{d.info.label}</text>
+  </g>
+{/if}
+
+<style>
+  :global(.svelte-flow__edge-path.sedge) {
+    stroke: var(--ink);
+    stroke-opacity: 0.32;
+    fill: none;
+  }
+  :global(.svelte-flow__edge-path.sedge.hot) {
+    stroke-opacity: 0.95;
+  }
+  :global(.svelte-flow__edge-path.sedge.dimmed) {
+    stroke-opacity: 0.06;
+  }
+  :global(.svelte-flow__edge-path.sedge.synth) {
+    stroke-dasharray: 5 3;
+  }
+  :global(.svelte-flow__edge-path.sedge.back) {
+    stroke: var(--accent);
+    stroke-opacity: 0.6;
+    stroke-dasharray: 4 3;
+  }
+  .hit {
+    stroke: transparent;
+    stroke-width: 12;
+    fill: none;
+    pointer-events: stroke;
+    cursor: crosshair;
+  }
+  .epill {
+    pointer-events: none;
+  }
+  .epill rect {
+    fill: var(--paper);
+    stroke: var(--rule);
+    stroke-width: 1px;
+  }
+  .epill text {
+    font: 400 10.5px var(--mono);
+    fill: var(--ink-2);
+  }
+  .epill.hot rect {
+    stroke: var(--ink-3);
+  }
+  .epill.hot text {
+    fill: var(--ink);
+  }
+</style>

+ 137 - 0
ui/src/components/screens/ScreenNode.svelte

@@ -0,0 +1,137 @@
+<script lang="ts">
+  /**
+   * One screen on the Screens view: its path, and the component that renders
+   * it. An origin — a function that navigates but belongs to no screen (a
+   * store action after login) — draws dashed, so it reads as a trigger rather
+   * than a place. The entry screen (`/`) carries a mark.
+   *
+   * Hidden handles along the top and bottom, one per link, exactly as the
+   * Map's module box does: the layout decided the ports, this only draws them.
+   */
+  import { Handle, Position, type NodeProps } from '@xyflow/svelte';
+  import type { MapNodeLayout } from '../../lib/map-model';
+  import type { ScreenNodeInfo } from '../../lib/screens-model';
+
+  let { data }: NodeProps = $props();
+
+  const node = $derived(
+    data as unknown as {
+      layout: MapNodeLayout;
+      info: ScreenNodeInfo;
+      selected: boolean;
+      dimmed: boolean;
+      onSelect: (id: string) => void;
+    }
+  );
+  const layout = $derived(node.layout);
+  const info = $derived(node.info);
+
+  function portStyle(index: number, total: number): string {
+    return `left:${((index + 1) / (total + 1)) * 100}%`;
+  }
+</script>
+
+{#each layout.targetHandles as handle, i (handle)}
+  <Handle
+    type="target"
+    id={`t:${handle}`}
+    position={Position.Top}
+    style={portStyle(i, layout.targetHandles.length)}
+    isConnectable={false}
+  />
+{/each}
+
+<button
+  class="snode"
+  class:sel={node.selected}
+  class:dimmed={node.dimmed}
+  class:origin={info.origin}
+  class:entry={info.entry}
+  class:unreached={info.unreached}
+  style={`width:${layout.width}px;height:${layout.height}px`}
+  onclick={() => node.onSelect(info.id)}
+  aria-pressed={node.selected}
+  title={info.origin
+    ? `${info.label} — navigates, but no screen reaches it within the walk. In ${info.sub}.`
+    : `${info.label} — rendered by ${info.sub}${info.entry ? '. The entry screen.' : ''}${
+        info.unreached ? '. No transition in the graph reaches it from the entry.' : ''
+      }`}
+>
+  <span class="name">{#if info.entry}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}</span>
+  <span class="sub">{info.sub}</span>
+</button>
+
+{#each layout.sourceHandles as handle, i (handle)}
+  <Handle
+    type="source"
+    id={`s:${handle}`}
+    position={Position.Bottom}
+    style={portStyle(i, layout.sourceHandles.length)}
+    isConnectable={false}
+  />
+{/each}
+
+<style>
+  .snode {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    gap: 1px;
+    box-sizing: border-box;
+    padding: 0 9px;
+    border: 1px solid var(--ink);
+    border-radius: 0;
+    background: var(--paper);
+    text-align: left;
+    cursor: pointer;
+    font: inherit;
+    color: var(--ink);
+    transition: background 90ms linear;
+  }
+  .snode:hover,
+  .snode.sel {
+    border-width: 2px;
+    padding: 0 8px;
+    background: var(--press);
+  }
+  .snode.dimmed {
+    border-color: var(--ink-4);
+    color: var(--ink-4);
+  }
+  .snode.dimmed .sub {
+    color: var(--ink-4);
+  }
+  .snode.origin {
+    border-style: dashed;
+    border-color: var(--ink-3);
+  }
+  .snode.unreached {
+    border-color: var(--ink-4);
+    color: var(--ink-2);
+  }
+  .snode:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 1px;
+  }
+  .name {
+    font: 500 13px var(--mono);
+    line-height: 15px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+  .mark {
+    color: var(--accent);
+    margin-right: 5px;
+    font-size: 9px;
+    vertical-align: 1px;
+  }
+  .sub {
+    font: 400 11px var(--sans);
+    line-height: 13px;
+    color: var(--ink-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+</style>

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

@@ -81,6 +81,9 @@
         {#if row.via}<span class="tag" title="A synthesized edge — dynamic dispatch the parser cannot see"
             >via {row.via}</span
           >{/if}
+        {#each row.when as w (w)}<span class="tag when" title="The call runs only under this condition — read from the source as it is now"
+            >when {w}</span
+          >{/each}
       </div>
     </div>
   </div>
@@ -249,6 +252,10 @@
     font-size: 10.5px;
   }
 
+  .tag.when {
+    color: var(--ink-2);
+  }
+
   .rfold {
     position: absolute;
     right: 12px;

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

@@ -96,6 +96,9 @@
           <div class="nm">{rowName(node)}</div>
           <div class="meta">
             {#if row.words.length > 0}<span class="kindlbl">{row.words.join(', ')}</span>{/if}
+            {#each row.when as w (w)}<span class="tag when" title="The call runs only under this condition — read from the source as it is now"
+                >when {w}</span
+              >{/each}
             {#each row.lines as line (line)}
               <button
                 type="button"
@@ -299,6 +302,18 @@
     font-size: 11px;
   }
 
+  .tag {
+    flex: 0 0 auto;
+    padding: 0 4px;
+    border: 1px solid var(--rule-soft);
+    color: var(--ink-3);
+    font-size: 10.5px;
+  }
+
+  .tag.when {
+    color: var(--ink-2);
+  }
+
   .kindlbl {
     color: var(--ink-3);
   }

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

@@ -35,6 +35,7 @@ import type {
   WireFileCodePayload,
   WireFlowPayload,
   WireMapPayload,
+  WireScreensPayload,
   WireNodeRefs,
   WireRoutes,
   WireSearch,
@@ -210,6 +211,8 @@ export interface GraphAdapter {
   flow(request: FlowRequest, signal?: AbortSignal): Promise<WireFlowPayload>;
   /** The repository at module granularity, layered. */
   map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
+  /** The app's screens and the transitions between them, with their conditions. */
+  screens(signal?: AbortSignal): Promise<WireScreensPayload>;
   /** The URL → handler map. */
   routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
   /** Where a reader starts: routes, files that run something, tests, hubs. */
@@ -393,6 +396,10 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
       return getJson<WireRoutes>(`api/routes${query(params)}`, signal);
     },
 
+    screens(signal) {
+      return getJson<WireScreensPayload>('api/screens', signal);
+    },
+
     entryPoints(request = {}, signal) {
       const params = new URLSearchParams();
       if (request.limit) params.set('limit', String(request.limit));

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

@@ -19,6 +19,7 @@ import type {
   WireFileCodePayload,
   WireFlowPayload,
   WireMapPayload,
+  WireScreensPayload,
   WireNodeRefs,
   WireRoutes,
   WireSearch,
@@ -141,6 +142,10 @@ export function fetchSource(
  * is how many path segments under it name a module. Omitting `root` lets the
  * adapter pick the repository's source directory.
  */
+export function fetchScreens(signal?: AbortSignal): Promise<WireScreensPayload> {
+  return getGraphAdapter().screens(signal);
+}
+
 export function fetchMap(
   opts: { root?: string | null; depth?: number } = {},
   signal?: AbortSignal

+ 3 - 1
ui/src/lib/entry-model.ts

@@ -243,7 +243,9 @@ export function buildEntryPanel(entries: WireEntryPoints | null): EntryPanel {
       section(
         'routes',
         'Routes',
-        'A request from outside arrives here — the URL, and the symbol that serves it.',
+        entries.routes.items.items.every((r) => !r.method)
+          ? 'A screen of the app — its path, and the component that renders it.'
+          : 'A request from outside arrives here — the URL, and the symbol that serves it.',
         entries.routes.items,
         groupRows(
           entries.routes.items.items.map((route) => ({

+ 28 - 3
ui/src/lib/map-model.ts

@@ -186,6 +186,22 @@ export interface MapLayout {
 
 export interface MapLayoutOptions {
   includeTests: boolean;
+  /** Override the hidden-link floor; 0 draws every link (the Screens view). */
+  minWeight?: number;
+  /**
+   * The two lines a box is sized for. The Map's boxes show the module id and
+   * its counts; a view that shows something else (a screen's path and its
+   * component) must size for what it draws, or an opaque id decides the width.
+   */
+  sizing?: (module: WireMapModule, island: boolean) => { label: string; meta: string };
+  /**
+   * Replace longest-path layering. Receives every module id and the acyclic
+   * links (mutual pairs already broken); returns each id's layer, 0 at the
+   * BOTTOM. The Screens view lays out by distance from the entry screen,
+   * where "one layer above what it depends on" would put the head of the
+   * longest chain of screens above the login page.
+   */
+  layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
 }
 
 export function strokeWidthFor(count: number): number {
@@ -212,7 +228,7 @@ export function buildMapLayout(
   // depends on is depended on, whatever this screen is currently showing.
   const depended = new Set(payload.links.map((l) => l.target));
   const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
-  const minWeight = options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT;
+  const minWeight = options.minWeight ?? (options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT);
 
   const declaredLinks = links.filter((l) => l.declared > 0);
   const useDeclared =
@@ -246,7 +262,12 @@ export function buildMapLayout(
   for (const list of out.values()) list.sort();
 
   const layer = new Map<string, number>();
-  for (const module of modules) longestPath(module.id, out, layer, new Set());
+  if (options.layering) {
+    for (const [id, value] of options.layering(modules.map((m) => m.id), acyclic)) layer.set(id, value);
+    for (const module of modules) if (!layer.has(module.id)) layer.set(module.id, 0);
+  } else {
+    for (const module of modules) longestPath(module.id, out, layer, new Set());
+  }
 
   const layerCount = Math.max(1, ...[...layer.values()].map((v) => v + 1));
   const rows: string[][] = Array.from({ length: layerCount }, () => []);
@@ -282,7 +303,11 @@ export function buildMapLayout(
   // --- placement -----------------------------------------------------------
   const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
   const widths = new Map(
-    modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m, islands.has(m.id)))])
+    modules.map((m) => {
+      const island = islands.has(m.id);
+      const lines = options.sizing?.(m, island) ?? { label: m.id, meta: moduleMetaLabel(m, island) };
+      return [m.id, nodeWidth(lines.label, lines.meta)];
+    })
   );
   const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
   // Natural span = the boxes shoulder to shoulder. The content width is the

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

@@ -68,6 +68,7 @@ export interface NavigationDriver {
   mapHref(opts?: MapHrefOptions): string;
   flowHref(opts?: FlowHrefOptions): string;
   entryHref(): string;
+  screensHref(): string;
   deadHref(opts?: DeadCodeHrefOptions): string;
   /** Go to an href this driver built. */
   navigate(href: string, opts?: { replace?: boolean }): void;
@@ -133,6 +134,10 @@ export const hashNavigation: NavigationDriver = {
     return '#/entry';
   },
 
+  screensHref() {
+    return '#/screens';
+  },
+
   deadHref(opts = {}) {
     const params = new URLSearchParams();
     if (opts.exported) params.set('exported', '1');
@@ -212,6 +217,10 @@ export function entryHref(): string {
   return driver.entryHref();
 }
 
+export function screensHref(): string {
+  return driver.screensHref();
+}
+
 export function deadHref(opts: DeadCodeHrefOptions = {}): string {
   return driver.deadHref(opts);
 }

+ 5 - 0
ui/src/lib/router.svelte.ts

@@ -11,6 +11,7 @@
  *   #/map                  module map       (?root=&depth=&tests=1)
  *   #/flow                 flow strip       (?from=&to= | ?symbols= | ?t=<trail>)
  *   #/entry                entry points     (where a flow starts)
+ *   #/screens              screens          (the app's screens and transitions)
  *   #/dead                 dead code        (?exported=1 widens the claim)
  *
  * Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -40,6 +41,7 @@ export {
   hashNavigation,
   mapHref,
   navigate,
+  screensHref,
   setNavigationDriver,
   symbolHref,
 } from './navigation';
@@ -74,6 +76,7 @@ export type Route =
       trail: string | null;
     }
   | { view: 'entry' }
+  | { view: 'screens' }
   | {
       view: 'dead';
       /** Symbols reachable from outside the index are on the list. */
@@ -136,6 +139,8 @@ export function parseHash(hash: string): RouterLocation {
     };
   } else if (head === 'entry' && rest.length === 0) {
     route = { view: 'entry' };
+  } else if (head === 'screens' && rest.length === 0) {
+    route = { view: 'screens' };
   } else if (head === 'dead' && rest.length === 0) {
     // The widening travels in the URL like the map's shape does: a link to
     // "including exported symbols" has to reopen the same list.

+ 277 - 0
ui/src/lib/screens-model.ts

@@ -0,0 +1,277 @@
+/**
+ * The Screens view's model — the app's screens and the transitions between
+ * them, laid out so that a screen sits above the screens it opens.
+ *
+ * The layout is the Map's (`buildMapLayout`): the same longest-path layering,
+ * the same barycenter ordering, the same ports, the same determinism. A
+ * screen graph is a module graph with different words — nodes with names,
+ * weighted links that mostly point one way, a few cycles (Home ↔ Capture)
+ * that become dashed back-edges rather than being straightened into a lie.
+ * Reusing it means a reader who learned the Map reads this without learning
+ * anything new, and means this file is mostly translation, not geometry.
+ *
+ * What is this file's own: which links share a pair (several transitions from
+ * Home to Capture, each with its own condition, draw as ONE edge whose label
+ * counts them), the words on that edge, and the two lists the side panel
+ * shows for a selected screen.
+ */
+
+import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
+import { buildMapLayout, linkId, type MapLayout } from './map-model';
+
+/** The longest `when` a connector prints before an ellipsis; the tooltip has the rest. */
+const EDGE_LABEL_MAX = 30;
+
+export interface ScreenNodeInfo {
+  id: string;
+  /** `/object-detail`, or a function name for an origin. */
+  label: string;
+  /** The component's name for a screen; the file for an origin. */
+  sub: string;
+  screen: WireScreen | null;
+  /** A navigation that could not be attributed to a screen. */
+  origin: boolean;
+  entry: boolean;
+  /** No path of transitions leads here from the entry screen. */
+  unreached: boolean;
+}
+
+export interface ScreenEdgeInfo {
+  id: string;
+  from: string;
+  to: string;
+  /** Every transition between the pair — one connector, several stories. */
+  links: WireScreenLink[];
+  /** The connector's short label: the condition, or how many transitions. */
+  label: string;
+  synthesized: boolean;
+}
+
+export interface ScreensModel {
+  layout: MapLayout;
+  nodes: Map<string, ScreenNodeInfo>;
+  /** Keyed by the layout edge's id (see `linkId`). */
+  edges: Map<string, ScreenEdgeInfo>;
+  /** Screens no chain of transitions reaches from the entry. */
+  unreached: number;
+}
+
+/**
+ * Layer = distance from the entry screen: the entry on top, each row down one
+ * more transition away. Origins (chrome, triggers outside any screen) count
+ * as reachable seeds too, so what they open is placed below them. Whatever
+ * nothing reaches sits in a band at the bottom, layered among itself by the
+ * same rule from its own sources — a screen the graph cannot see anyone open
+ * is a fact worth a place, not a crash.
+ */
+export function entryLayering(entry: string | null, seeds: readonly string[]) {
+  return (ids: string[], links: ReadonlyArray<{ source: string; target: string }>): Map<string, number> => {
+    const out = new Map<string, string[]>(ids.map((id) => [id, []]));
+    const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
+    for (const l of links) {
+      out.get(l.source)?.push(l.target);
+      indeg.set(l.target, (indeg.get(l.target) ?? 0) + 1);
+    }
+    const depth = new Map<string, number>();
+    const bfs = (starts: string[]) => {
+      let frontier = starts.filter((s) => !depth.has(s));
+      for (const s of frontier) depth.set(s, 0);
+      let d = 0;
+      while (frontier.length > 0) {
+        d++;
+        const next: string[] = [];
+        for (const id of frontier) {
+          for (const t of out.get(id) ?? []) {
+            if (depth.has(t)) continue;
+            depth.set(t, d);
+            next.push(t);
+          }
+        }
+        frontier = next;
+      }
+    };
+    const roots = [entry, ...seeds].filter((s): s is string => s !== null && ids.includes(s));
+    bfs(roots);
+    const reachedMax = Math.max(0, ...[...depth.values()]);
+    // The unreached band: its own sources first, then whatever they open.
+    const rest = ids.filter((id) => !depth.has(id));
+    const restDepth = new Map<string, number>();
+    if (rest.length > 0) {
+      const restSources = rest.filter((id) => (indeg.get(id) ?? 0) === 0);
+      const seedsRest = restSources.length > 0 ? restSources : [rest[0]!];
+      let frontier = seedsRest;
+      for (const s of frontier) restDepth.set(s, 0);
+      let d = 0;
+      while (frontier.length > 0) {
+        d++;
+        const next: string[] = [];
+        for (const id of frontier) {
+          for (const t of out.get(id) ?? []) {
+            if (restDepth.has(t) || depth.has(t)) continue;
+            restDepth.set(t, d);
+            next.push(t);
+          }
+        }
+        frontier = next;
+      }
+      for (const id of rest) if (!restDepth.has(id)) restDepth.set(id, 0);
+    }
+    const restMax = Math.max(0, ...[...restDepth.values()]);
+    // Layer 0 is the bottom. Unreached band occupies [0, restMax]; reached
+    // screens sit above it, the entry highest, with one empty row between.
+    const base = rest.length > 0 ? restMax + 2 : 0;
+    const layer = new Map<string, number>();
+    for (const [id, d] of depth) layer.set(id, base + reachedMax - d);
+    for (const [id, d] of restDepth) layer.set(id, restMax - d);
+    return layer;
+  };
+}
+
+/** What the connector says. Empty when unconditional and single. */
+export function edgeLabel(links: readonly WireScreenLink[]): string {
+  if (links.length === 1) {
+    const when = links[0]!.when;
+    if (!when) return '';
+    return when.length > EDGE_LABEL_MAX ? `${when.slice(0, EDGE_LABEL_MAX - 1)}…` : when;
+  }
+  const conditional = links.filter((l) => l.when).length;
+  return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
+}
+
+export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
+  const nodes = new Map<string, ScreenNodeInfo>();
+  const modules: WireMapModule[] = [];
+  const used = new Set<string>();
+  for (const link of payload.links) {
+    used.add(link.from);
+    used.add(link.to);
+  }
+
+  for (const screen of payload.screens) {
+    const info: ScreenNodeInfo = {
+      id: screen.id,
+      label: screen.path,
+      sub: screen.component?.name ?? screen.file,
+      screen,
+      origin: false,
+      entry: payload.entry === screen.id,
+      unreached: false,
+    };
+    nodes.set(screen.id, info);
+    modules.push(moduleFor(info, screen.incoming + screen.outgoing));
+  }
+  for (const origin of payload.origins) {
+    const info: ScreenNodeInfo = {
+      id: origin.id,
+      label: origin.node.kind === 'component' ? `<${origin.node.name}>` : `${origin.node.name}()`,
+      sub: origin.sharedBy ? `on ${origin.sharedBy} screens` : origin.node.file,
+      screen: null,
+      origin: true,
+      entry: false,
+      unreached: false,
+    };
+    nodes.set(origin.id, info);
+    modules.push(moduleFor(info, origin.outgoing));
+  }
+
+  // One layout link per (from, to); the transitions behind it stay listed.
+  const byPair = new Map<string, WireScreenLink[]>();
+  for (const link of payload.links) {
+    const key = linkId({ source: link.from, target: link.to });
+    const list = byPair.get(key) ?? [];
+    list.push(link);
+    byPair.set(key, list);
+  }
+  const links: WireMapLink[] = [];
+  const edges = new Map<string, ScreenEdgeInfo>();
+  for (const [key, group] of byPair) {
+    const first = group[0]!;
+    if (!nodes.has(first.from) || !nodes.has(first.to)) continue;
+    // A screen that reopens itself (a retry) is a fact for the panel, not an
+    // arrow the layout can draw.
+    if (first.from === first.to) continue;
+    links.push({
+      source: first.from,
+      target: first.to,
+      count: group.length,
+      declared: group.length,
+      byKind: [{ kind: 'navigates', count: group.length }],
+      topPairs: [],
+    });
+    edges.set(key, {
+      id: key,
+      from: first.from,
+      to: first.to,
+      links: group,
+      label: edgeLabel(group),
+      synthesized: group.every((l) => l.synthesized),
+    });
+  }
+
+  // Reachability from the entry (and from the origins, which are entries of
+  // a kind: chrome is on the screen the user is on).
+  const seeds = payload.origins.map((o) => o.id);
+  const reachable = new Set<string>();
+  {
+    const out = new Map<string, string[]>();
+    for (const l of payload.links) out.set(l.from, [...(out.get(l.from) ?? []), l.to]);
+    const stack = [payload.entry, ...seeds].filter((s): s is string => s !== null);
+    while (stack.length > 0) {
+      const id = stack.pop()!;
+      if (reachable.has(id)) continue;
+      reachable.add(id);
+      for (const t of out.get(id) ?? []) stack.push(t);
+    }
+  }
+  let unreached = 0;
+  for (const info of nodes.values()) {
+    if (!info.origin && !reachable.has(info.id)) {
+      info.unreached = true;
+      unreached++;
+    }
+  }
+
+  const layout = buildMapLayout(
+    { modules, links },
+    {
+      includeTests: true,
+      minWeight: 0,
+      sizing: (m) => {
+        const info = nodes.get(m.id);
+        return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
+      },
+      layering: entryLayering(payload.entry, seeds),
+    }
+  );
+  return { layout, nodes, edges, unreached };
+}
+
+function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
+  return {
+    id: info.id,
+    label: info.label,
+    files: 1,
+    symbols,
+    languages: [],
+    test: false,
+    generated: 0,
+    generatedFiles: [],
+    facade: false,
+    fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
+  };
+}
+
+/** The side panel's two lists for a selected node. */
+export function neighbourhood(
+  payload: WireScreensPayload,
+  id: string
+): { opensFrom: WireScreenLink[]; goesTo: WireScreenLink[] } {
+  const opensFrom = payload.links.filter((l) => l.to === id);
+  const goesTo = payload.links.filter((l) => l.from === id);
+  return { opensFrom, goesTo };
+}
+
+/** `ItemCard → openObjectDetail`, or '' when the screen's own component navigates. */
+export function viaText(link: WireScreenLink): string {
+  return link.via.map((v) => v.name).join(' → ');
+}

+ 3 - 1
ui/src/lib/search-model.ts

@@ -269,7 +269,9 @@ export function buildEntryPalette(
   if (entries.routes.routed && entries.routes.items.items.length > 0) {
     sections.push({
       title: 'Routes',
-      note: 'A request from outside arrives here.',
+      note: entries.routes.items.items.every((r) => !r.method)
+        ? 'A screen of the app.'
+        : 'A request from outside arrives here.',
       items: take(entries.routes.items.items).map((route) => ({
         type: 'route' as const,
         id: `route:${route.routeId}`,

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

@@ -65,6 +65,8 @@ export function edgeWord(edge: WireEdge): string {
       return '';
     case 'instantiates':
       return 'creates';
+    case 'navigates':
+      return 'navigates to';
     case 'references':
       return edge.valueRef ? 'passes as value' : 'uses type';
     default:
@@ -82,6 +84,16 @@ export function relationWords(relation: WireRelation): string[] {
   return words;
 }
 
+/** The distinct branch conditions across a relation's edges, at most three. */
+export function relationWhens(relation: WireRelation): string[] {
+  const out: string[] = [];
+  for (const edge of relation.edges) {
+    if (edge.when && !out.includes(edge.when)) out.push(edge.when);
+    if (out.length === 3) break;
+  }
+  return out;
+}
+
 /** The synthesizer that produced this relation's edge, when one did. */
 export function synthesizedBy(relation: WireRelation): string | null {
   if (!relation.synthesized) return null;
@@ -335,6 +347,8 @@ export interface CalleeRow {
   lines: number[];
   words: string[];
   via: string | null;
+  /** `when` conditions, distinct, for the meta line. */
+  when: string[];
 }
 
 export interface CalleeRailModel {
@@ -357,6 +371,7 @@ export function buildCalleeRail(payload: WireSymbolPayload): CalleeRailModel {
       lines: relation.lines,
       words: relationWords(relation),
       via: synthesizedBy(relation),
+      when: relationWhens(relation),
     };
     if (relation.uncertain) uncertain.push(row);
     else rows.push(row);
@@ -380,6 +395,8 @@ export interface CallerRow {
   /** Call-site lines in the CALLER's file — the `:4657` chips. */
   lines: number[];
   via: string | null;
+  /** `when` conditions, distinct, for the meta line. */
+  when: string[];
 }
 
 export interface CallerFileGroup {
@@ -420,6 +437,7 @@ export function buildCallerRail(payload: WireSymbolPayload): CallerRailModel {
       words: relationWords(relation),
       lines: relation.lines,
       via: synthesizedBy(relation),
+      when: relationWhens(relation),
     };
     // Uncertainty wins over test-ness: a name-only guess is a claim about the
     // edge, and burying it in the tests fold would present it as established.

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

@@ -117,6 +117,8 @@ export interface WireEdge {
   via?: string;
   registeredAt?: string;
   valueRef?: boolean;
+  /** Branch conditions the call site runs under — `!isUploading && isCollected`. */
+  when?: string;
 }
 
 /** Every edge between the focal symbol and ONE other symbol, as a single row. */
@@ -642,6 +644,56 @@ export interface WireMapPayload {
   timing: { elapsedMs: number; cached: boolean };
 }
 
+/* ---------------------------------------------------------------- screens -- */
+
+export interface WireScreen {
+  id: string;
+  path: string;
+  file: string;
+  line: number;
+  component: WireNodeRef | null;
+  incoming: number;
+  outgoing: number;
+}
+
+export interface WireScreenOrigin {
+  id: string;
+  node: WireNodeRef;
+  outgoing: number;
+  /** Shared chrome: how many screens render it. */
+  sharedBy?: number;
+}
+
+export interface WireScreenSite {
+  file: string;
+  line: number;
+  href: string;
+  method: string;
+  when: string;
+}
+
+export interface WireScreenLink {
+  id: string;
+  from: string;
+  to: string;
+  fromOrigin: boolean;
+  via: WireNodeRef[];
+  when: string;
+  sites: WireScreenSite[];
+  synthesized: boolean;
+}
+
+export interface WireScreensPayload {
+  routed: boolean;
+  entry: string | null;
+  screens: WireScreen[];
+  origins: WireScreenOrigin[];
+  links: WireScreenLink[];
+  dropped: number;
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
 /* -------------------------------------------------------------- dead code -- */
 
 /** One symbol nothing in the index reaches. */

+ 588 - 0
ui/src/views/ScreensView.svelte

@@ -0,0 +1,588 @@
+<!--
+  The Screens view (`#/screens`): the app as its user meets it — one box per
+  screen, an arrow for every way of getting from one to another, and on each
+  arrow the condition under which it happens.
+
+  Everything drawn comes from `/api/screens`: routes the framework resolver
+  found, `navigates` edges the extractor bound, attribution back through the
+  render/call chain to the screen a transition starts on, and branch guards
+  read from the source. The canvas is the Map's machinery with different
+  words (see `screens-model.ts`); the side panel is where the sentences are.
+-->
+<script lang="ts">
+  import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
+  import '@xyflow/svelte/dist/style.css';
+  import ScreenNode from '../components/screens/ScreenNode.svelte';
+  import ScreenEdge from '../components/screens/ScreenEdge.svelte';
+  import KindGlyph from '../components/KindGlyph.svelte';
+  import { fetchScreens, type WireScreensPayload, type WireScreenLink } from '../lib/api';
+  import { live } from '../lib/live.svelte';
+  import { symbolHref, fileHref } from '../lib/navigation';
+  import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
+  import { buildScreensModel, neighbourhood, viaText, type ScreensModel } from '../lib/screens-model';
+
+  let payload = $state<WireScreensPayload | null>(null);
+  let error = $state<string | null>(null);
+  let loading = $state(true);
+  let selected = $state<string | null>(null);
+  let hovered = $state<{ edge: MapEdgeLayout; x: number; y: number } | null>(null);
+  let stage = $state<HTMLDivElement | null>(null);
+
+  // The key stays open until the reader closes it; the choice survives a
+  // reload but is per browser — a preference, not a fact about the project.
+  const LEGEND_KEY = 'codegraph-ui:screens-legend';
+  let legendOpen = $state(readLegendOpen());
+  function readLegendOpen(): boolean {
+    try {
+      return localStorage.getItem(LEGEND_KEY) !== 'closed';
+    } catch {
+      return true;
+    }
+  }
+  $effect(() => {
+    try {
+      localStorage.setItem(LEGEND_KEY, legendOpen ? 'open' : 'closed');
+    } catch {
+      // Storage refused (private mode): the key simply reopens next time.
+    }
+  });
+
+  const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } };
+  const nodeTypes = { screen: ScreenNode };
+  const edgeTypes = { screen: ScreenEdge };
+
+  $effect(() => {
+    void live.indexTick;
+    const controller = new AbortController();
+    loading = true;
+    error = null;
+    fetchScreens(controller.signal)
+      .then((next) => {
+        payload = next;
+        loading = false;
+      })
+      .catch((err: unknown) => {
+        if (controller.signal.aborted) return;
+        error = err instanceof Error ? err.message : String(err);
+        loading = false;
+      });
+    return () => controller.abort();
+  });
+
+  const model = $derived<ScreensModel | null>(
+    payload === null || !payload.routed ? null : buildScreensModel(payload)
+  );
+
+  const neighbours = $derived.by(() => {
+    if (model === null || selected === null) return null;
+    const set = new Set<string>([selected]);
+    for (const edge of model.layout.edges) {
+      if (edge.source === selected) set.add(edge.target);
+      if (edge.target === selected) set.add(edge.source);
+    }
+    return set;
+  });
+
+  const nodes = $derived.by<Node[]>(() => {
+    if (model === null) return [];
+    return model.layout.nodes.map((node) => ({
+      id: node.id,
+      type: 'screen',
+      position: { x: node.x, y: node.y },
+      draggable: false,
+      selectable: false,
+      connectable: false,
+      data: {
+        layout: node,
+        info: model.nodes.get(node.id)!,
+        selected: selected === node.id,
+        dimmed: neighbours !== null && !neighbours.has(node.id),
+        onSelect: (id: string) => {
+          selected = selected === id ? null : id;
+          hovered = null;
+        },
+      },
+    }));
+  });
+
+  const edges = $derived.by<Edge[]>(() => {
+    if (model === null) return [];
+    return model.layout.edges
+      .filter((edge) => isEdgeVisible(edge, selected))
+      .map((edge) => ({
+        id: edge.id,
+        source: edge.source,
+        target: edge.target,
+        sourceHandle: edge.sourceHandle,
+        targetHandle: edge.targetHandle,
+        type: 'screen',
+        selectable: false,
+        deletable: false,
+        data: {
+          edge,
+          info: model.edges.get(edge.id)!,
+          hot: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
+          dimmed: selected !== null && edge.source !== selected && edge.target !== selected,
+          // A label only where it can be read as belonging to one line: the
+          // selected screen's own edges (near that screen) and the hovered
+          // edge (near its source). Unselected, the picture is lines and
+          // boxes; the conditions are one click away.
+          labelled: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
+          labelAt: selected !== null && edge.target === selected && edge.source !== selected ? 0.72 : 0.28,
+          onHover: onEdgeHover,
+        },
+      }));
+  });
+
+  const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
+  const lists = $derived(
+    selected === null || payload === null ? null : neighbourhood(payload, selected)
+  );
+  const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
+
+  function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
+    if (edge === null || event === null || stage === null) {
+      hovered = null;
+      return;
+    }
+    const box = stage.getBoundingClientRect();
+    hovered = {
+      edge,
+      x: Math.min(event.clientX - box.left + 14, box.width - 360),
+      y: event.clientY - box.top + 14,
+    };
+  }
+
+  function nameOf(id: string): string {
+    return model?.nodes.get(id)?.label ?? id;
+  }
+
+  /** The row the panel prints for one transition, seen from `side`. */
+  function sentence(link: WireScreenLink, side: 'from' | 'to'): string {
+    const other = side === 'from' ? nameOf(link.from) : nameOf(link.to);
+    return other;
+  }
+</script>
+
+<div class="screens">
+  <div class="stage" bind:this={stage}>
+    {#if error !== null}
+      <div class="state">
+        <h2>The screens could not be read</h2>
+        <p>{error}</p>
+      </div>
+    {:else if loading && payload === null}
+      <div class="state"><p class="dim">Reading screens and transitions…</p></div>
+    {:else if payload !== null && !payload.routed}
+      <div class="state">
+        <h2>No screen navigation in this graph</h2>
+        <p>
+          This view draws the routes a UI framework binds to components and the navigation calls
+          that reach them. The index has {payload.screens.length === 0 ? 'no routes' : 'routes'} but no
+          navigation between them — it is not an app with screens, or its router is one CodeGraph
+          does not read yet.
+        </p>
+      </div>
+    {:else if model !== null}
+      <SvelteFlow
+        {nodes}
+        {edges}
+        {nodeTypes}
+        {edgeTypes}
+        fitView
+        {...FIT}
+        minZoom={0.2}
+        maxZoom={1.6}
+        nodesDraggable={false}
+        nodesConnectable={false}
+        elementsSelectable={false}
+        panOnDrag
+        proOptions={{ hideAttribution: true }}
+        onpaneclick={() => {
+          selected = null;
+          hovered = null;
+        }}
+      >
+        <Controls position="bottom-right" showLock={false} />
+      </SvelteFlow>
+
+      <!-- The key, on the picture it explains. Each row draws the actual
+           stroke or box, not a word for it — a reader matches shapes, not
+           descriptions. Collapsible, remembered per browser. -->
+      <div class="legend" class:open={legendOpen}>
+        <button class="legend-h" onclick={() => (legendOpen = !legendOpen)} aria-expanded={legendOpen}>
+          Key <span class="dim">{legendOpen ? '▾' : '▸'}</span>
+        </button>
+        {#if legendOpen}
+          <div class="legend-body">
+            <div class="lrow">
+              <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
+              <span>Transition — the destination is written at the call</span>
+            </div>
+            <div class="lrow">
+              <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-synth" /></svg>
+              <span>Destination inferred from a helper's return value</span>
+            </div>
+            <div class="lrow">
+              <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
+              <span>Goes back up the picture (returning)</span>
+            </div>
+            <div class="lrow">
+              <span class="k-label mono">when x</span>
+              <span>Runs only under that condition (shown on the selected screen's lines); none = always</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box mono">/path</span>
+              <span>A screen — its path and the component that renders it</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box k-entry mono"><span class="mark">●</span>/</span>
+              <span>The entry screen; each row down is one more transition away</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box k-origin mono">fn()</span>
+              <span>Not a screen: shared chrome, or a trigger no screen reaches</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box k-unreached mono">/path</span>
+              <span>Nothing reaches it from the entry (bottom band)</span>
+            </div>
+          </div>
+        {/if}
+      </div>
+
+      {#if hovered !== null && hoveredInfo !== null}
+        <div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
+          <div class="mono"><b>{nameOf(hoveredInfo.from)}</b> → {nameOf(hoveredInfo.to)}</div>
+          {#each hoveredInfo.links.slice(0, 5) as link (link.id)}
+            <div class="tiprow">
+              {#if link.when}<span class="when">when {link.when}</span>{:else}<span class="dim">always</span>{/if}
+              {#if link.via.length > 0}<span class="mono dim">via {viaText(link)}</span>{/if}
+            </div>
+          {/each}
+          {#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
+        </div>
+      {/if}
+    {/if}
+  </div>
+
+  {#if payload !== null && model !== null}
+    <aside class="side">
+      {#if selectedInfo !== null && lists !== null}
+        <div class="head">
+          <div>
+            <div class="mono big">{selectedInfo.label}</div>
+            {#if selectedInfo.screen?.component}
+              <a class="sub" href={symbolHref(selectedInfo.screen.component.id)}>
+                <KindGlyph kind={selectedInfo.screen.component.kind} />
+                {selectedInfo.screen.component.name}
+              </a>
+            {:else if selectedInfo.origin}
+              <span class="sub dim">navigates from outside any screen</span>
+            {/if}
+            {#if selectedInfo.screen}
+              <a class="sub dim" href={fileHref(selectedInfo.screen.file)}>{selectedInfo.screen.file}</a>
+            {/if}
+          </div>
+          <button class="clear" onclick={() => (selected = null)}>clear</button>
+        </div>
+
+        <h4>Opens from <span class="dim">{lists.opensFrom.length}</span></h4>
+        {#if lists.opensFrom.length === 0}
+          <p class="dim">
+            {selectedInfo.entry ? 'The entry screen — the app starts here.' : 'Nothing in the graph navigates here.'}
+          </p>
+        {/if}
+        {#each lists.opensFrom as link (link.id)}
+          <div class="row">
+            <button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
+            {#if link.when}<div class="when">when {link.when}</div>{/if}
+            {#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
+            {#each link.sites as site (site.file + site.line)}
+              <a class="site dim" href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.id, { line: site.line })}
+                >{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
+              >
+            {/each}
+          </div>
+        {/each}
+
+        <h4>Goes to <span class="dim">{lists.goesTo.length}</span></h4>
+        {#if lists.goesTo.length === 0}<p class="dim">No navigation leaves this screen.</p>{/if}
+        {#each lists.goesTo as link (link.id)}
+          <div class="row">
+            <button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
+            {#if link.when}<div class="when">when {link.when}</div>{/if}
+            {#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
+            {#each link.sites as site (site.file + site.line)}
+              <a
+                class="site dim"
+                href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.screen?.component?.id ?? selectedInfo.id, { line: site.line })}
+                >{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
+              >
+            {/each}
+          </div>
+        {/each}
+      {:else}
+        <div class="head"><div class="big">Screens</div></div>
+        <p>
+          <b>{payload.screens.length}</b> screens · <b>{payload.links.length}</b> transitions{#if payload.origins.length > 0}
+            · <b>{payload.origins.length}</b> triggered outside a screen{/if}.
+        </p>
+        <p class="dim">
+          <span class="mark">●</span> The entry screen is at the top; each row down is one more
+          transition away from it. An arrow's label is the condition under which that transition
+          happens, read from the code around the navigation call; hover it for the chain the call
+          travels through, click a screen for everything in and out of it.
+        </p>
+        <p class="dim">
+          Solid: the destination is written at the call. Dashed grey: it comes back from a
+          helper's return value (inferred). Dashed accent: a transition back up the picture
+          (returning). Dashed box: a trigger that is not a screen — shared chrome, or code no
+          screen's render chain reaches.
+        </p>
+        {#if model.unreached > 0}
+          <p class="dim">
+            <b>{model.unreached}</b> screen{model.unreached === 1 ? '' : 's'} in the band at the bottom: no
+            transition in the graph reaches {model.unreached === 1 ? 'it' : 'them'} from the entry — opened
+            by something the graph cannot see (a layout's initial route, a deep link), or unused.
+          </p>
+        {/if}
+        {#if payload.dropped > 0}
+          <p class="dim">{payload.dropped} navigation{payload.dropped === 1 ? '' : 's'} could not be attributed: the walk back to a screen hit a hub.</p>
+        {/if}
+        <h4>Most connected</h4>
+        {#each [...payload.screens].sort((a, b) => b.incoming + b.outgoing - (a.incoming + a.outgoing)).slice(0, 8) as screen (screen.id)}
+          <button class="peer mono" onclick={() => (selected = screen.id)}
+            >{screen.path} <span class="dim">←{screen.incoming} →{screen.outgoing}</span></button
+          >
+        {/each}
+      {/if}
+    </aside>
+  {/if}
+</div>
+
+<style>
+  .screens {
+    display: grid;
+    grid-template-columns: minmax(600px, 1fr) 340px;
+    height: 100%;
+    min-height: 0;
+  }
+  .stage {
+    position: relative;
+    overflow: hidden;
+    background: var(--paper);
+  }
+  .stage :global(.svelte-flow) {
+    background: var(--paper);
+  }
+  .stage :global(.svelte-flow__handle) {
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    min-width: 0;
+    min-height: 0;
+    border: 0;
+    pointer-events: none;
+  }
+  .stage :global(.svelte-flow__controls-button) {
+    background: var(--paper);
+    border: 0;
+    border-bottom: 1px solid var(--rule-soft);
+    border-radius: 0;
+    color: var(--ink-2);
+  }
+  .stage :global(.svelte-flow__controls-button svg) {
+    fill: var(--ink-2);
+  }
+  .state {
+    padding: 48px 40px;
+    max-width: 560px;
+  }
+  .state h2 {
+    font: 600 20px var(--sans);
+    margin: 0 0 8px;
+  }
+  .legend {
+    position: absolute;
+    left: 12px;
+    bottom: 12px;
+    z-index: 4;
+    max-width: 380px;
+    border: 1px solid var(--rule);
+    background: var(--paper);
+    font-size: 11.5px;
+    color: var(--ink-2);
+  }
+  .legend-h {
+    display: block;
+    width: 100%;
+    border: 0;
+    background: transparent;
+    padding: 5px 10px;
+    text-align: left;
+    color: var(--ink);
+    font: 600 12px var(--sans);
+    cursor: pointer;
+  }
+  .legend-body {
+    padding: 2px 10px 8px;
+    border-top: 1px solid var(--rule-soft);
+  }
+  .lrow {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 3px 0;
+  }
+  .lrow > :first-child {
+    flex: 0 0 44px;
+    display: inline-flex;
+    justify-content: center;
+  }
+  .k-line {
+    stroke: var(--ink);
+    stroke-opacity: 0.6;
+    stroke-width: 1.5;
+    fill: none;
+  }
+  .k-line.k-synth {
+    stroke-dasharray: 5 3;
+  }
+  .k-line.k-back {
+    stroke: var(--accent);
+    stroke-opacity: 0.8;
+    stroke-dasharray: 4 3;
+  }
+  .k-label {
+    font-size: 10.5px;
+    color: var(--ink-3);
+  }
+  .k-box {
+    box-sizing: border-box;
+    padding: 1px 5px;
+    border: 1px solid var(--ink);
+    font-size: 10.5px;
+    color: var(--ink);
+    line-height: 14px;
+  }
+  .k-box.k-origin {
+    border-style: dashed;
+    border-color: var(--ink-3);
+  }
+  .k-box.k-unreached {
+    border-color: var(--ink-4);
+    color: var(--ink-2);
+  }
+  .k-entry .mark {
+    font-size: 8px;
+    margin-right: 3px;
+    vertical-align: 1px;
+  }
+
+  .tip {
+    position: absolute;
+    z-index: 5;
+    width: 340px;
+    padding: 8px 10px;
+    border: 1px solid var(--ink);
+    background: var(--paper);
+    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
+    font-size: 12px;
+    pointer-events: none;
+  }
+  .tiprow {
+    display: flex;
+    flex-direction: column;
+    gap: 1px;
+    margin-top: 6px;
+    padding-top: 6px;
+    border-top: 1px solid var(--rule-soft);
+  }
+  .side {
+    border-left: 1px solid var(--rule);
+    padding: 14px 16px;
+    overflow: auto;
+    font-size: 12.5px;
+  }
+  .head {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 8px;
+    margin-bottom: 10px;
+  }
+  .big {
+    font-size: 15px;
+    font-weight: 600;
+  }
+  .sub {
+    display: flex;
+    align-items: center;
+    gap: 5px;
+    margin-top: 3px;
+    color: var(--ink-2);
+    text-decoration: none;
+  }
+  .sub:hover {
+    text-decoration: underline;
+  }
+  .clear {
+    border: 1px solid var(--rule);
+    background: transparent;
+    color: var(--ink-2);
+    font: inherit;
+    font-size: 11.5px;
+    padding: 1px 7px;
+    cursor: pointer;
+  }
+  h4 {
+    margin: 16px 0 6px;
+    font: 600 12.5px var(--sans);
+  }
+  .row {
+    padding: 7px 0;
+    border-top: 1px solid var(--rule-soft);
+  }
+  .peer {
+    display: block;
+    width: 100%;
+    border: 0;
+    background: transparent;
+    padding: 2px 0;
+    text-align: left;
+    color: var(--ink);
+    font: 500 12.5px var(--mono);
+    cursor: pointer;
+  }
+  .peer:hover {
+    text-decoration: underline;
+  }
+  .when {
+    color: var(--ink);
+    font: 400 11.5px var(--mono);
+    margin-top: 2px;
+  }
+  .via {
+    font: 400 11px var(--mono);
+    margin-top: 2px;
+  }
+  .site {
+    display: block;
+    font: 400 11px var(--mono);
+    margin-top: 2px;
+    text-decoration: none;
+  }
+  .site:hover {
+    text-decoration: underline;
+  }
+  .mono {
+    font-family: var(--mono);
+  }
+  .dim {
+    color: var(--ink-3);
+  }
+  .mark {
+    color: var(--accent);
+  }
+</style>