Преглед изворни кода

feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API

Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
Colby McHenry пре 1 недеља
родитељ
комит
873f133c96
36 измењених фајлова са 3711 додато и 73 уклоњено
  1. 12 0
      CHANGELOG.md
  2. 2 1
      CLAUDE.md
  3. 1 0
      README.md
  4. 78 0
      __tests__/namespace-object-resolution.test.ts
  5. 136 0
      __tests__/react-hook-handlers.test.ts
  6. 127 0
      __tests__/react-native-bridge.test.ts
  7. 68 0
      __tests__/rn-event-channel.test.ts
  8. 67 0
      __tests__/store-exported-later.test.ts
  9. 273 0
      __tests__/ui-steps-api.test.ts
  10. 110 0
      __tests__/ui-steps-model.test.ts
  11. 29 1
      codegraph-kernel/src/tsjs/extractors.rs
  12. 6 1
      codegraph-kernel/src/tsjs/fnref.rs
  13. 46 1
      codegraph-kernel/src/tsjs/mod.rs
  14. 37 0
      docs/design/codegraph-ui-design-spec.md
  15. 14 1
      src/extraction/function-ref.ts
  16. 76 2
      src/extraction/tree-sitter.ts
  17. 27 4
      src/resolution/callback-synthesizer.ts
  18. 174 16
      src/resolution/frameworks/react-native.ts
  19. 109 3
      src/resolution/import-resolver.ts
  20. 9 0
      src/ui-server/api/index.ts
  21. 4 32
      src/ui-server/api/screens.ts
  22. 723 0
      src/ui-server/api/steps.ts
  23. 38 0
      src/ui-server/api/when.ts
  24. 3 0
      ui/src/App.svelte
  25. 2 1
      ui/src/components/TopBar.svelte
  26. 3 2
      ui/src/components/screens/ScreenEdge.svelte
  27. 184 0
      ui/src/components/steps/StepNode.svelte
  28. 26 0
      ui/src/lib/adapter.ts
  29. 21 1
      ui/src/lib/api.ts
  30. 24 0
      ui/src/lib/navigation.ts
  31. 22 0
      ui/src/lib/router.svelte.ts
  32. 19 6
      ui/src/lib/screens-model.ts
  33. 238 0
      ui/src/lib/steps-model.ts
  34. 70 0
      ui/src/lib/wire.ts
  35. 5 1
      ui/src/views/ScreensView.svelte
  36. 928 0
      ui/src/views/StepsView.svelte

+ 12 - 0
CHANGELOG.md

@@ -14,6 +14,18 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **A Steps tab in `codegraph ui` — what happens from here.** Pick a screen (or search any symbol and choose *What happens from here*) and the viewer draws everything it sets in motion as typed steps: the handlers wired to its taps and listeners, the calls that cross into native code, the native events that come back, the store actions it writes, and the calls that leave the app into the network, storage, the device or telemetry — one box per step, an arrow for every way one leads to the next, and on each arrow the condition under which it happens. The plumbing between two steps (hooks, helpers, the components in between) is folded into the arrow and listed in the side panel, exactly as the Screens tab folds a tap's chain into one transition. Any step is the next anchor, any link opens as a Flow strip, a cap the walk hit is announced on the step it hit it at, and the picture travels in the URL. React Native + Expo apps get the full picture today; any project gets handlers, stores and calls that leave the index.
+
+- **React Native apps: Swift native modules and their events connect end to end.** A JS call like `captureView.finalizeCaptureSession()` — where `captureView` is bound to `NativeModules.CaptureView` and the module is a Swift class exposed through an `RCT_EXTERN_MODULE` shim — now resolves to the Swift method itself instead of stopping at the constant, so `codegraph_explore`, the Flow strip and the Steps view follow the code into native. Native → JS events now also land on listeners written inline (`addListener('onZipComplete', (data) => { … })`), attributed to the component that registers them. Re-index after upgrading to pick the new edges up.
+
+### Fixes
+
+- **React handlers written with `useCallback` are now symbols.** `const handleSubmit = useCallback(() => {…}, [])` — the way nearly every handler in a React or React Native component is written — is extracted as a function named by its binding (also `React.useCallback`, `useEffectEvent`), so `onPress={handleSubmit}` and `addListener('x', handleSubmit)` resolve to it, its calls are its own rather than the component's, and a tap's handler shows up in `codegraph_explore`, the Screens tab and the Steps tab. A JSX attribute value (`onPress={handleSubmit}`, `renderItem={renderRow}`) and a handler a hook hands back in an object (`return { handleSubmit, handleRetake }`) are now function-as-value references from the component or hook, so the graph knows which functions are wired as handlers.
+
+- **Stores exported on a later line are read like any other.** `const useStore = create((set, get) => ({ … }))` followed by `export default useStore` (or `export { useStore }`) now has its actions extracted as functions, the same as an `export const` store — previously the two-statement form, common in React Native apps, left every action invisible.
+
+- **API objects exported as a default namespace resolve through to their functions.** `import Api from './api'` + `Api.upload()` where the module ends in `const Api = { upload, createFolder }; export default Api` now links the call to `upload` itself (through the object's own imports), and a default import of any const named by an `export default NAME` statement finds that const rather than guessing the file's first exported function.
+
 - **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. Click a screen and each of its transitions is labelled beside the screen at the other end of the line with the last condition checked before it happens — `→ isCollected` above `/object-detail` — laid out so that no two labels overlap and none sits under a line; hover a label, a line, or its row in the side panel for the whole condition and the chain the tap travels through (`HomeSearchResults → ItemCard → openObjectDetail`), with a link to each navigation call. A screen that returns to where it came from is drawn around the boxes rather than through them, shared chrome (a top bar rendered on ten screens) is one node a row above what it opens rather than the same arrows from every box, a screen that opens many others is wide enough to follow each line back to it and its lines take separate paths through the gap so they fan out instead of stacking, hovering picks the line nearest the pointer, and 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.

+ 2 - 1
CLAUDE.md

@@ -86,6 +86,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the
 - `src/installer/` — see below.
 - `src/bin/codegraph.ts` — CLI (commander). Subcommands: `install`, `init`, `uninit`, `index`, `sync`, `status`, `query`, `files`, `context`, `affected`, `serve --mcp`.
 - `src/ui/` — terminal UI (shimmer progress, worker).
+- `src/ui-server/` — the `codegraph ui` browser viewer's read-only JSON API (`api/`: one module per endpoint — `node`, `flow`, `map`, `screens`, `steps`, `deadcode`, `trails`…) and static server; the Svelte viewer itself lives in `ui/` (see `docs/design/codegraph-ui-design-spec.md`). `api/screens.ts` (the app as screens and transitions) and `api/steps.ts` (what happens from a screen or a symbol, as typed steps — screens, handlers, native bridge calls and events, store actions, calls that leave the index) share one fold: everything between two boxes is `via`, and the branch guards along it join into `when` (`graph/branch-guards.ts`, read at request time).
 
 ### NodeKind / EdgeKind
 
@@ -152,7 +153,7 @@ Two functions in `src/mcp/tools.ts` scale explore with indexed file count. This
 
 ### Dynamic-dispatch coverage — the flow must EXIST in the graph end-to-end
 
-Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), django ORM descriptor. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail.
+Static tree-sitter extraction misses computed/indirect calls, so flows break at dynamic dispatch and the agent reads to reconstruct them. Synthesizers/resolvers bridge these so `codegraph_explore` connects them end-to-end (`src/resolution/callback-synthesizer.ts`, `src/resolution/frameworks/`). Channels today: callback/observer, EventEmitter, **React re-render** (`setState`→`render`), **JSX child** (`render`→child component), **React Native native→JS events** (`sendEvent(withName:)` / JVM `emit` → the `addListener` handler, named or inline, `rn-event-channel`), django ORM descriptor. The JS→native direction is a *resolver* (`frameworks/react-native.ts`: `RCT_EXPORT_METHOD`, `RCT_EXTERN_MODULE` Swift shims, TurboModules), which trusts receiver evidence — an alias bound to `NativeModules.X` — over the import resolver. All synthesized edges are `provenance:'heuristic'` with `metadata.synthesizedBy` + `registeredAt` (the wiring site), surfaced inline in `codegraph_explore`'s Flow section and the `codegraph_node` trail.
 
 **Principle: partial coverage is WORSE than none.** Bridging one boundary but not the next reveals a hop the agent then drills + reads to finish. Measured on excalidraw: react-render alone *raised* reads to 5–7; only completing the flow (adding the jsx-child hop) dropped it to 0–1. **Always close the flow end-to-end and re-measure** — never ship a half-bridged flow.
 

+ 1 - 0
README.md

@@ -349,6 +349,7 @@ What you get on that screen:
 - Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. Its **Source** tab shows the whole file with the same gutter markers, plus an arc in the left margin for every call that stays inside the file — the one place a file's internal call structure is legible, because source order does the layout. A 6,800-line file scrolls at full speed.
 - **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip.
 - **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it.
+- **What happens from here.** On an app with screens, the **Screens** tab draws one box per screen and an arrow for every way of getting from one to another, each labelled with the condition under which it happens. The **Steps** tab does the same for what happens *on* a screen: pick one (or any symbol) and you get its handlers, the calls that cross into native code, the native events that come back, the store actions it writes and the requests that leave the app, as typed steps with the plumbing between them folded into the arrows — the whole capture-to-upload flow of a React Native app on one picture, with every step a click from the next anchor or a Flow strip.
 - **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
 - **Take the picture with you.** A flow strip or a map can be copied as an image straight into a pull-request comment, or saved as an SVG for a README — always in the light theme, whichever one you are reading in, with a caption saying what the picture is. The SVG is real text, so it stays sharp at any size and the names in it are selectable.
 - **Keep a walk.** Press **Save trail** on the trail bar, name it, and the path is kept — listed on the empty screen and on Entry points, above the suggestions, and reopened at the symbol you left with the whole walk restored. Steps are remembered by what they are, not where they sat, so a saved trail survives editing the code it describes; when something does move it says which step moved, which was renamed away, and how much of the walk still opens. Trails are plain JSON under `.codegraph/ui/trails/` (git already ignores it), and **Export** hands you the file if you would rather commit one.

+ 78 - 0
__tests__/namespace-object-resolution.test.ts

@@ -0,0 +1,78 @@
+/**
+ * The default-export namespace object — `const UploadApi = { uploadARCapture };
+ * export default UploadApi` — and a call through it from another file. Two
+ * things have to hold for `handleZipComplete → uploadARCapture` to exist:
+ * the default import must find the constant the `export default` statement
+ * names (it is not exported at its declaration), and the member must resolve
+ * to the binding the shorthand property carries, through the object's own
+ * imports.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+describe('namespace object default exports', () => {
+  let dir: string;
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-namespace-object-'));
+  });
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  function write(rel: string, content: string): void {
+    const full = path.join(dir, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  it('resolves Api.member() to the function the shorthand property names', async () => {
+    write('package.json', '{"name":"app"}');
+    write('src/api/frames.ts', 'export async function uploadARCapture(uri: string) {\n  return uri\n}\n');
+    write('src/api/folders.ts', 'export function createFolder(name: string) {\n  return name\n}\n');
+    write(
+      'src/api/index.ts',
+      "import { uploadARCapture } from './frames'\n" +
+        "import { createFolder } from './folders'\n" +
+        'function localHelper() {\n  return 1\n}\n' +
+        'const UploadApi = {\n  uploadARCapture,\n  makeFolder: createFolder,\n  localHelper,\n}\n' +
+        'export default UploadApi\n'
+    );
+    write(
+      'src/hooks.ts',
+      "import UploadApi from './api'\n" +
+        'export function handleZipComplete(uri: string) {\n' +
+        '  UploadApi.makeFolder(uri)\n' +
+        '  UploadApi.localHelper()\n' +
+        '  return UploadApi.uploadARCapture(uri)\n' +
+        '}\n'
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const handler = cg.getNodesByName('handleZipComplete')[0]!;
+    const callees = cg.getCallees(handler.id).map((c) => c.node.name).sort();
+    cg.close();
+    expect(callees).toEqual(['createFolder', 'localHelper', 'uploadARCapture']);
+  });
+
+  it('a default import of a later-exported const finds that const, and a method inside it', async () => {
+    write('package.json', '{"name":"app"}');
+    write(
+      'src/store.ts',
+      'const useStore = {\n  read() {\n    return 1\n  },\n}\nexport function unrelated() {\n  return 2\n}\nexport default useStore\n'
+    );
+    write('src/use.ts', "import store from './store'\nexport function consume() {\n  return store.read()\n}\n");
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const consume = cg.getNodesByName('consume')[0]!;
+    const callees = cg.getCallees(consume.id).map((c) => c.node.name);
+    cg.close();
+    // Without the `export default NAME` binding the default import guessed the
+    // first exported function (`unrelated`); now it is the object, and the
+    // member resolves inside it.
+    expect(callees).toEqual(['read']);
+  });
+});

+ 136 - 0
__tests__/react-hook-handlers.test.ts

@@ -0,0 +1,136 @@
+/**
+ * React handler hooks name the function they wrap.
+ *
+ * `const handleSubmit = useCallback(() => {…}, [])` is how nearly every
+ * handler in a React / React Native component is written, and the arrow is
+ * anonymous only syntactically — the declarator is the name every
+ * `onPress={handleSubmit}` and `addListener('x', handleSubmit)` uses. Without
+ * a node the handler's calls attribute to the component and the trigger of a
+ * flow (the tap, the native event) has nothing to resolve to.
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+});
+
+const refsFrom = (result: ReturnType<typeof extractFromSource>, id: string) =>
+  result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
+
+describe('useCallback handlers', () => {
+  it('extracts the wrapped arrow as a function named by the declarator, inside the component', () => {
+    const code = `
+      import { useCallback, useMemo, useEffect } from 'react'
+      import { finalize, upload, log } from './api'
+      export default function ReviewScreen() {
+        const handleApprove = useCallback(() => {
+          finalize()
+        }, [])
+        const handleZip = useCallback(async (data: { uri: string }) => {
+          await upload(data.uri)
+        }, [])
+        const total = useMemo(() => 1 + 1, [])
+        useEffect(() => {
+          log('mounted')
+        }, [])
+        return <Button onPress={handleApprove} />
+      }
+    `;
+    const result = extractFromSource('src/app/review.tsx', code);
+    const fns = result.nodes.filter((n) => n.kind === 'function');
+    const names = fns.map((n) => n.name);
+    expect(names).toEqual(expect.arrayContaining(['ReviewScreen', 'handleApprove', 'handleZip']));
+    // A memo is a value and an effect is anonymous: neither becomes a function.
+    expect(names).not.toContain('total');
+    expect(names.filter((n) => n === '<anonymous>')).toEqual([]);
+
+    const screen = fns.find((n) => n.name === 'ReviewScreen')!;
+    const handleZip = fns.find((n) => n.name === 'handleZip')!;
+    expect(handleZip.qualifiedName).toBe('ReviewScreen::handleZip');
+    expect(handleZip.startLine).toBe(8);
+
+    // The handler's calls are its own; the component keeps only what it does itself.
+    expect(refsFrom(result, handleZip.id)).toContain('upload');
+    expect(refsFrom(result, screen.id)).not.toContain('upload');
+    expect(refsFrom(result, screen.id)).toContain('log');
+
+    // Containment: the component contains its handlers.
+    expect(
+      result.edges.some((e) => e.kind === 'contains' && e.source === screen.id && e.target === handleZip.id)
+    ).toBe(true);
+
+    // `onPress={handleApprove}` is a function-as-value site: the tap's handler
+    // is referenced from the component, which is how a Steps picture knows
+    // the handler is a trigger.
+    const handleApprove = fns.find((n) => n.name === 'handleApprove')!;
+    expect(
+      result.unresolvedReferences.some(
+        (r) => r.fromNodeId === screen.id && r.referenceKind === 'function_ref' && r.referenceName === 'handleApprove'
+      )
+    ).toBe(true);
+    expect(handleApprove.startLine).toBe(5);
+  });
+
+  it('accepts React.useCallback, function expressions, and useEffectEvent', () => {
+    const code = `
+      import React from 'react'
+      export function Screen() {
+        const onOpen = React.useCallback(function () { open() }, [])
+        const onLog = useEffectEvent((url: string) => { track(url) })
+        return null
+      }
+    `;
+    const result = extractFromSource('src/screen.tsx', code);
+    const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
+    expect(names).toEqual(expect.arrayContaining(['Screen', 'onOpen', 'onLog']));
+  });
+
+  it('leaves a hook whose first argument is not the bound function alone', () => {
+    const code = `
+      export function Screen() {
+        const value = useState(() => compute())
+        const cb = useCallback(existingHandler, [])
+        const [x] = useReducer((s) => s, 0)
+        return null
+      }
+    `;
+    const result = extractFromSource('src/screen.tsx', code);
+    const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
+    expect(names).toEqual(['Screen']);
+  });
+
+  it('a handler a hook returns in an object is a function-as-value of the hook', () => {
+    const code = `
+      import { useCallback } from 'react'
+      export function useReviewHandlers() {
+        const handleApprove = useCallback(() => { finalize() }, [])
+        const handleRetake = useCallback(() => { retake() }, [])
+        const count = 1
+        return { handleApprove, handleRetake, count, extra: helper }
+      }
+      function helper() {}
+    `;
+    const result = extractFromSource('src/hooks.ts', code);
+    const hook = result.nodes.find((n) => n.name === 'useReviewHandlers')!;
+    const fnRefs = result.unresolvedReferences
+      .filter((r) => r.fromNodeId === hook.id && r.referenceKind === 'function_ref')
+      .map((r) => r.referenceName)
+      .sort();
+    // `count` is a value, not a function defined here: gated out.
+    expect(fnRefs).toEqual(['handleApprove', 'handleRetake', 'helper']);
+  });
+
+  it('does nothing outside the JS family', () => {
+    const code = `
+      func screen() {
+        let handle = useCallback({ () in finalize() }, [])
+      }
+    `;
+    const result = extractFromSource('Screen.swift', code);
+    const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
+    expect(names).toEqual(['screen']);
+  });
+});

+ 127 - 0
__tests__/react-native-bridge.test.ts

@@ -340,3 +340,130 @@ describe('React Native cross-platform pairing — end to end', () => {
     expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
   });
 });
+
+// =============================================================================
+// Swift modules via RCT_EXTERN_MODULE, and receiver evidence
+// =============================================================================
+
+import { parseObjcRNExterns, collectNativeModuleAliases } from '../src/resolution/frameworks/react-native';
+
+function swiftMethod(name: string, owner: string, filePath: string, startLine: number): Node {
+  return {
+    id: `swift:${filePath}:${name}:${startLine}`,
+    kind: 'method',
+    name,
+    qualifiedName: `${owner}::${name}`,
+    filePath,
+    language: 'swift',
+    startLine,
+    endLine: startLine + 4,
+    startColumn: 0,
+    endColumn: 0,
+    updatedAt: Date.now(),
+  } as Node;
+}
+
+const SHIM = `
+#import <React/RCTBridgeModule.h>
+#import <React/RCTViewManager.h>
+
+@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)
+
+RCT_EXTERN_METHOD(syncSettings:(NSDictionary *)settings)
+RCT_EXTERN_METHOD(finalizeCaptureSession)
+RCT_EXTERN_REMAP_METHOD(pause, pauseInferenceNow)
+
+@end
+`;
+
+describe('React Native bridge resolver — RCT_EXTERN (Swift) modules', () => {
+  const finalize = swiftMethod('finalizeCaptureSession', 'CaptureView', 'ios/CaptureView+ReactBridge.swift', 26);
+  const sync = swiftMethod('syncSettings', 'CaptureView', 'ios/CaptureView.swift', 40);
+  const pause = swiftMethod('pauseInferenceNow', 'CaptureView', 'ios/CaptureView.swift', 60);
+  // Same method name on another Swift type — never the bridge target.
+  const decoy = swiftMethod('syncSettings', 'CaptureSettings', 'ios/CaptureSettings.swift', 12);
+
+  const files = {
+    'package.json': '{"name":"app","dependencies":{"react-native":"0.76"}}',
+    'ios/CaptureView.m': SHIM,
+    'src/components/capture/capture-view.tsx':
+      "import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
+      'export const { CaptureEvents } = NativeModules\n' +
+      'export const captureView = NativeModules.CaptureView\n',
+  };
+  const ctx = makeContext([finalize, sync, pause, decoy], files);
+
+  it('parses the shim: module, class, first keyword, remap', () => {
+    expect(parseObjcRNExterns(SHIM).map((e) => [e.moduleName, e.className, e.jsName, e.nativeSelectorFirstKw])).toEqual([
+      ['CaptureView', 'CaptureView', 'syncSettings', 'syncSettings'],
+      ['CaptureView', 'CaptureView', 'finalizeCaptureSession', 'finalizeCaptureSession'],
+      ['CaptureView', 'CaptureView', 'pause', 'pauseInferenceNow'],
+    ]);
+    const remapped = parseObjcRNExterns('@interface RCT_EXTERN_REMAP_MODULE(Camera, CameraModule, NSObject)\nRCT_EXTERN_METHOD(snap)');
+    expect(remapped).toEqual([
+      { moduleName: 'Camera', className: 'CameraModule', jsName: 'snap', nativeSelectorFirstKw: 'snap', line: 2 },
+    ]);
+  });
+
+  it('collects the local names bound to NativeModules', () => {
+    const aliases = new Map<string, string>();
+    const ambiguous = new Set<string>();
+    collectNativeModuleAliases(
+      'const captureView = NativeModules.CaptureView\n' +
+        'export const { CaptureEvents, Geo: geolocation } = NativeModules\n' +
+        'let typed: Spec = NativeModules.Typed\n',
+      aliases,
+      ambiguous
+    );
+    // Direct bindings first (one pass), then the destructured ones.
+    expect([...aliases]).toEqual([
+      ['captureView', 'CaptureView'],
+      ['typed', 'Typed'],
+      ['CaptureEvents', 'CaptureEvents'],
+      ['geolocation', 'Geo'],
+    ]);
+    // The same name bound to two modules is dropped, not guessed.
+    collectNativeModuleAliases('const captureView = NativeModules.Other', aliases, ambiguous);
+    expect(aliases.has('captureView')).toBe(false);
+    expect(ambiguous.has('captureView')).toBe(true);
+  });
+
+  it('detects a project from the RCT_EXTERN_MODULE marker alone', () => {
+    expect(reactNativeBridgeResolver.detect(makeContext([], { 'ios/CaptureView.m': SHIM }))).toBe(true);
+  });
+
+  it('resolves an aliased receiver to the Swift method of the named class at 0.95', () => {
+    const r = reactNativeBridgeResolver.resolve(
+      ref('captureView.finalizeCaptureSession', 'tsx', 'src/hooks/use-review-handlers.ts'),
+      ctx
+    );
+    expect(r?.targetNodeId).toBe(finalize.id);
+    expect(r?.confidence).toBe(0.95);
+    expect(r?.metadata).toEqual({ bridge: 'react-native', module: 'CaptureView' });
+  });
+
+  it('resolves NativeModules.Module.method the same way, class-scoped past a same-named decoy', () => {
+    const r = reactNativeBridgeResolver.resolve(ref('NativeModules.CaptureView.syncSettings', 'tsx', 'src/a.tsx'), ctx);
+    expect(r?.targetNodeId).toBe(sync.id);
+    expect(r?.confidence).toBe(0.95);
+  });
+
+  it('follows RCT_EXTERN_REMAP_METHOD to the Swift implementation under the JS name', () => {
+    const r = reactNativeBridgeResolver.resolve(ref('captureView.pause', 'tsx', 'src/a.tsx'), ctx);
+    expect(r?.targetNodeId).toBe(pause.id);
+  });
+
+  it('keeps a bare method name at the by-name confidence, and refuses a named module that lacks the method', () => {
+    const bare = reactNativeBridgeResolver.resolve(ref('syncSettings', 'tsx', 'src/a.tsx'), ctx);
+    expect(bare?.targetNodeId).toBe(sync.id);
+    expect(bare?.confidence).toBe(0.6);
+    expect(reactNativeBridgeResolver.resolve(ref('captureView.nothingHere', 'tsx', 'src/a.tsx'), ctx)).toBeNull();
+    // A receiver that is NOT a module alias falls back to by-name evidence.
+    const other = reactNativeBridgeResolver.resolve(ref('somethingElse.syncSettings', 'tsx', 'src/a.tsx'), ctx);
+    expect(other?.confidence).toBe(0.6);
+  });
+
+  it('never redirects a native caller', () => {
+    expect(reactNativeBridgeResolver.resolve(ref('captureView.finalizeCaptureSession', 'swift', 'ios/x.swift'), ctx)).toBeNull();
+  });
+});

+ 68 - 0
__tests__/rn-event-channel.test.ts

@@ -158,3 +158,71 @@ export function onMessage(listener: (m: any) => void) {
     expect(rows[0].target_name).toBe('onBattery');
   });
 });
+
+describe('RN event channel synthesizer — inline listeners', () => {
+  let dir: string;
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-event-inline-'));
+  });
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('attributes an inline arrow listener to the enclosing component, from a Swift sendEvent(withName:)', async () => {
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"x","dependencies":{"react-native":"^0.76"}}');
+    fs.writeFileSync(
+      path.join(dir, 'CaptureEvents.swift'),
+      `import Foundation
+class CaptureEvents: RCTEventEmitter {
+  func emitZipComplete() {
+    sendEvent(withName: "onZipComplete", body: ["ok": true])
+  }
+  func emitProgress() {
+    sendEvent(withName: "onCaptureProgress", body: nil)
+  }
+}
+`
+    );
+    fs.writeFileSync(
+      path.join(dir, 'App.tsx'),
+      `import { useEffect } from 'react'
+export default function ReviewScreen() {
+  useEffect(() => {
+    const zip = nativeEmitter.addListener('onZipComplete', (data) => {
+      upload(data)
+    })
+    const progress = nativeEmitter.addListener('onCaptureProgress', async function () {
+      await tick()
+    })
+    return () => {
+      zip.remove()
+      progress.remove()
+    }
+  }, [])
+  return null
+}
+function upload(d: unknown) {}
+function tick() {}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source_name, t.name target_name, json_extract(e.metadata,'$.event') event,
+                json_extract(e.metadata,'$.registeredAt') registered_at
+         FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rn-event-channel'
+         ORDER BY event`
+      )
+      .all();
+    cg.close?.();
+    expect(rows.map((r: any) => [r.source_name, r.target_name, r.event])).toEqual([
+      ['emitProgress', 'ReviewScreen', 'onCaptureProgress'],
+      ['emitZipComplete', 'ReviewScreen', 'onZipComplete'],
+    ]);
+    expect(rows[1].registered_at).toBe('App.tsx:4');
+  });
+});

+ 67 - 0
__tests__/store-exported-later.test.ts

@@ -0,0 +1,67 @@
+/**
+ * A store exported by a LATER statement — `const useStore = create(…)` then
+ * `export default useStore` — is exported, and its actions are extracted like
+ * an `export const` store's (object-literal-methods.test.ts covers that
+ * form). The scope rule that keeps inline-object noise out still holds: a
+ * store nothing exports stays a constant.
+ */
+import { describe, it, expect, beforeAll } from 'vitest';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+});
+
+const fnNames = (code: string, file = 'store.ts') =>
+  extractFromSource(file, code)
+    .nodes.filter((n) => n.kind === 'function')
+    .map((n) => n.name);
+
+describe('store actions on a later-exported const', () => {
+  it('export default NAME', () => {
+    const code = `
+      import { create } from 'zustand'
+      const useCaptureStorage = create<State>((set, get) => ({
+        object: null,
+        setSettings: (settings: Settings) => {
+          set({ settings })
+        },
+        reset: () => set({ object: null }),
+      }))
+      export default useCaptureStorage
+    `;
+    expect(fnNames(code)).toEqual(expect.arrayContaining(['setSettings', 'reset']));
+  });
+
+  it('export { NAME } and export { NAME as default }', () => {
+    const named = `
+      const useStore = create((set) => ({ bump: () => set({}) }))
+      export { useStore }
+    `;
+    const asDefault = `
+      const useStore = create((set) => ({ bump: () => set({}) }))
+      export { useStore as default }
+    `;
+    expect(fnNames(named)).toContain('bump');
+    expect(fnNames(asDefault)).toContain('bump');
+  });
+
+  it('a const nothing exports keeps its members out of the graph', () => {
+    const code = `
+      const useStore = create((set) => ({ bump: () => set({}) }))
+      export const other = 1
+    `;
+    expect(fnNames(code)).not.toContain('bump');
+  });
+
+  it('is not fooled by a different name in the export', () => {
+    const code = `
+      const useStoreInternal = create((set) => ({ bump: () => set({}) }))
+      const useStore = 1
+      export default useStore
+    `;
+    expect(fnNames(code)).not.toContain('bump');
+  });
+});

+ 273 - 0
__tests__/ui-steps-api.test.ts

@@ -0,0 +1,273 @@
+/**
+ * `GET /api/steps` — what happens from a screen, as typed steps.
+ *
+ * Against a real index of a small Expo + React Native app, shaped to cross
+ * every boundary the endpoint classifies: a screen whose handler (a
+ * `useCallback`) calls a Swift method through an `RCT_EXTERN_MODULE` shim,
+ * the Swift side sending an event the screen listens to, the listener calling
+ * an API function that leaves the index (`client.post`), a store action in a
+ * store file, and a navigation to a second screen behind a condition. The
+ * pure layout is tested without an index in `ui-steps-model.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { buildSteps, crossing, effectCategory, isStoreFile } from '../src/ui-server/api/steps';
+
+let tmpDir: string;
+let cg: CodeGraph;
+
+function write(rel: string, content: string): void {
+  const full = path.join(tmpDir, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, content);
+}
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-'));
+  write('package.json', JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', 'react-native': '0.76' } }));
+  write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
+  write('src/app/index.tsx', "import { router } from 'expo-router'\nexport default function Home() {\n  return null\n}\n");
+  write(
+    'src/components/capture/capture-view.tsx',
+    "import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
+      'export const captureView = NativeModules.CaptureView\n' +
+      'export const nativeEmitter = new NativeEventEmitter(NativeModules.CaptureEvents)\n'
+  );
+  write('src/api/client.ts', "import axios from 'axios'\nexport const client = axios.create({ baseURL: 'x' })\n");
+  write(
+    'src/api/frames.ts',
+    "import { client } from './client'\n" +
+      'export async function uploadARCapture(uri: string) {\n' +
+      "  await client.post('/frames', { uri })\n" +
+      "  return client.get('/frames/status')\n" +
+      '}\n'
+  );
+  write(
+    'src/storage/capture.storage.ts',
+    "import { create } from 'zustand'\n" +
+      'const useCaptureStorage = create<State>((set) => ({\n' +
+      '  zipUri: null,\n' +
+      '  setZipUri: (zipUri: string) => set({ zipUri }),\n' +
+      '}))\n' +
+      'export default useCaptureStorage\n'
+  );
+  write(
+    'src/app/capture/review.tsx',
+    "import { useCallback, useEffect } from 'react'\n" +
+      "import { router } from 'expo-router'\n" +
+      "import { captureView, nativeEmitter } from '../../components/capture/capture-view'\n" +
+      "import { uploadARCapture } from '../../api/frames'\n" +
+      "import useCaptureStorage from '../../storage/capture.storage'\n" +
+      'export default function ReviewScreen({ unlimited }: { unlimited: boolean }) {\n' +
+      '  const setZipUri = useCaptureStorage((s) => s.setZipUri)\n' +
+      '  const handleApprove = useCallback(() => {\n' +
+      '    captureView.finalizeCaptureSession()\n' +
+      '  }, [])\n' +
+      '  const handleZipComplete = useCallback(async (data: { uri: string }) => {\n' +
+      '    setZipUri(data.uri)\n' +
+      '    await uploadARCapture(data.uri)\n' +
+      "    if (unlimited) router.replace('/')\n" +
+      '  }, [unlimited])\n' +
+      '  useEffect(() => {\n' +
+      "    const sub = nativeEmitter.addListener('onZipComplete', handleZipComplete)\n" +
+      '    return () => sub.remove()\n' +
+      '  }, [handleZipComplete])\n' +
+      '  return <Button onPress={handleApprove} />\n' +
+      '}\n'
+  );
+  write(
+    'src/app/capture/index.tsx',
+    "import { memo, useCallback } from 'react'\n" +
+      "import { captureView } from '../../components/capture/capture-view'\n" +
+      'function CaptureComponent() {\n' +
+      '  const handleOpen = useCallback(() => {\n' +
+      '    captureView.finalizeCaptureSession()\n' +
+      '  }, [])\n' +
+      '  return <Button onPress={handleOpen} />\n' +
+      '}\n' +
+      'const MemoizedCaptureComponent = memo(CaptureComponent)\n' +
+      'export default function CapturePage() {\n' +
+      '  return <MemoizedCaptureComponent />\n' +
+      '}\n'
+  );
+  write(
+    'ios/CaptureView.m',
+    '#import <React/RCTViewManager.h>\n@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)\nRCT_EXTERN_METHOD(finalizeCaptureSession)\n@end\n'
+  );
+  write(
+    'ios/CaptureView.swift',
+    'import Foundation\n' +
+      'class CaptureView: RCTViewManager {\n' +
+      '  @objc func finalizeCaptureSession() {\n' +
+      '    let result = zip()\n' +
+      '    if result {\n' +
+      '      CaptureEvents.shared.emitZipComplete()\n' +
+      '    }\n' +
+      '  }\n' +
+      '  func zip() -> Bool { return true }\n' +
+      '}\n'
+  );
+  write(
+    'ios/CaptureEvents.swift',
+    'import Foundation\n' +
+      'class CaptureEvents: RCTEventEmitter {\n' +
+      '  static let shared = CaptureEvents()\n' +
+      '  func emitZipComplete() {\n' +
+      '    sendEvent(withName: "onZipComplete", body: nil)\n' +
+      '  }\n' +
+      '}\n'
+  );
+  cg = CodeGraph.initSync(tmpDir);
+  await cg.indexAll();
+});
+
+afterAll(() => {
+  cg?.close();
+  if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+const q = (params: Record<string, string>) => new URLSearchParams(params);
+
+describe('classification helpers', () => {
+  it('crossing: JS → native is a bridge, native → JS an event, anything else nothing', () => {
+    expect(crossing('tsx', 'swift')).toBe('bridge');
+    expect(crossing('swift', 'tsx')).toBe('event');
+    expect(crossing('typescript', 'javascript')).toBeNull();
+    expect(crossing('swift', 'objc')).toBeNull();
+  });
+  it('store files', () => {
+    expect(isStoreFile('src/storage/capture.storage.ts')).toBe(true);
+    expect(isStoreFile('src/stores/user.ts')).toBe(true);
+    expect(isStoreFile('src/features/cart/cart.slice.ts')).toBe(true);
+    expect(isStoreFile('src/components/button.tsx')).toBe(false);
+    expect(isStoreFile('src/restore/thing.ts')).toBe(false);
+  });
+  it('effects: a curated table, by reference text', () => {
+    expect(effectCategory('client.post')).toBe('network');
+    expect(effectCategory('fetch')).toBe('network');
+    expect(effectCategory('AsyncStorage.setItem')).toBe('storage');
+    expect(effectCategory('Linking.openURL')).toBe('device');
+    expect(effectCategory('DdRum.addAction')).toBe('telemetry');
+    expect(effectCategory('Math.max')).toBeNull();
+    expect(effectCategory('i18n.t')).toBeNull();
+  });
+});
+
+describe('buildSteps', () => {
+  it('walks a screen through its handler, the bridge, the event, the store and the request', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    expect(review).toBeDefined();
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
+
+    const byLabel = new Map(payload.steps.map((s) => [s.label, s]));
+    const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
+    expect(kinds['/capture/review']).toBe('screen');
+    expect(payload.steps.find((s) => s.anchor)?.label).toBe('/capture/review');
+    // The handler is wired to the tap, so it is a trigger; the call it makes
+    // crosses into Swift, so that is a bridge; the Swift side's event lands
+    // on the named listener; the listener writes the store, leaves the index
+    // through `client.post`, and navigates home behind `unlimited`.
+    expect(kinds['handleApprove']).toBe('trigger');
+    expect(kinds['finalizeCaptureSession']).toBe('bridge');
+    expect(kinds['handleZipComplete']).toBe('event');
+    expect(byLabel.get('handleZipComplete')?.event).toBe('onZipComplete');
+    expect(byLabel.get('handleZipComplete')?.events).toEqual(['onZipComplete']);
+    expect(kinds['setZipUri']).toBe('store');
+    // One box per (function, category): both calls the upload makes into the
+    // network, labelled by the first and counting the rest.
+    const network = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
+    expect(network.label).toBe('client.post +1');
+    expect(network.effect?.apis).toEqual(['client.post', 'client.get']);
+    expect(network.effect?.by.name).toBe('uploadARCapture');
+    expect(kinds['/']).toBe('screen');
+    // Another screen is a boundary: drawn, marked, not entered.
+    expect(byLabel.get('/')?.cut).toBe('screen');
+
+    const link = (from: string, to: string) =>
+      payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id);
+    expect(link('/capture/review', 'handleApprove')?.kind).toBe('handler');
+    expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
+    const evt = link('finalizeCaptureSession', 'handleZipComplete');
+    expect(evt?.kind).toBe('event');
+    expect(evt?.synthesized).toBe(true);
+    expect(evt?.via.map((v) => v.name)).toEqual(['emitZipComplete']);
+    expect(evt?.when).toBe('result');
+    expect(evt?.label).toContain('event onZipComplete');
+    expect(link('handleZipComplete', 'setZipUri')?.kind).toBe('store');
+    const req = link('handleZipComplete', 'client.post +1');
+    expect(req?.kind).toBe('effect');
+    expect(req?.via.map((v) => v.name)).toEqual(['uploadARCapture']);
+    const nav = link('handleZipComplete', '/');
+    expect(nav?.kind).toBe('navigates');
+    expect(nav?.when).toBe('unlimited');
+    expect(nav?.sites[0]?.text).toBe('replace /');
+
+    // Rows: the anchor on 0, then one more step away each. The listener is
+    // registered BY the screen (`addListener('onZipComplete', handleZipComplete)`),
+    // so it sits one step from the anchor as a handler and the native event
+    // arrives at it from further down — a link back up the picture — and
+    // names the event on the box.
+    expect(byLabel.get('/capture/review')?.depth).toBe(0);
+    expect(byLabel.get('handleApprove')?.depth).toBe(1);
+    expect(byLabel.get('finalizeCaptureSession')?.depth).toBe(2);
+    expect(byLabel.get('handleZipComplete')?.depth).toBe(1);
+    expect(link('/capture/review', 'handleZipComplete')?.kind).toBe('handler');
+    expect(network.depth).toBe(2);
+    expect(payload.through).toBe(false);
+    expect(payload.truncated).toEqual({ steps: 0, hubs: 0, chrome: 0 });
+    // No cap fired; the only thing not entered is the other screen.
+    expect(payload.steps.filter((s) => s.cut !== null).map((s) => [s.label, s.cut])).toEqual([['/', 'screen']]);
+  });
+
+  it('walks through a memo-wrapped component into the screen body', async () => {
+    const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
+    const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
+    // The wrapper and the component are render hops, folded into the link;
+    // the handler is the first box, the native call the next.
+    expect(kinds['handleOpen']).toBe('trigger');
+    expect(kinds['finalizeCaptureSession']).toBe('bridge');
+    const toHandler = payload.links.find((l) => l.to === payload.steps.find((s) => s.label === 'handleOpen')!.id)!;
+    expect(toHandler.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']);
+    expect(payload.steps.map((s) => s.label)).not.toContain('CaptureComponent');
+  });
+
+  it('enters other screens when asked to continue through them', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, through: '1' }));
+    expect(payload.through).toBe(true);
+    expect(payload.steps.find((s) => s.label === '/')?.cut).toBeNull();
+  });
+
+  it('anchors by name, prefers the screen, and lists the rest as ambiguous', async () => {
+    const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
+    expect(payload.anchor.name).toBe('handleApprove');
+    expect(payload.steps[0]?.kind).toBe('anchor');
+    expect(payload.steps.map((s) => s.label)).toContain('finalizeCaptureSession');
+  });
+
+  it('a depth cap is announced on the step it stopped at', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, depth: '2' }));
+    // The bridge is two steps out: drawn, not explored — and says so.
+    const bridge = payload.steps.find((s) => s.label === 'finalizeCaptureSession')!;
+    expect(bridge.cut).toBe('depth');
+    expect(payload.links.some((l) => l.kind === 'event')).toBe(false);
+    // The listener still sits one step out, so the event step keeps its
+    // handler kind: nothing arrived at it from native within the cap.
+    expect(payload.steps.find((s) => s.label === 'handleZipComplete')?.kind).toBe('trigger');
+  });
+
+  it('refuses a missing anchor and an unknown id', async () => {
+    await expect(buildSteps(cg, tmpDir, q({}))).rejects.toThrow(/anchor/);
+    await expect(buildSteps(cg, tmpDir, q({ anchor: 'function:nope' }))).rejects.toThrow(/No symbol/);
+    await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
+  });
+});

+ 110 - 0
__tests__/ui-steps-model.test.ts

@@ -0,0 +1,110 @@
+/**
+ * The Steps view's model, without a browser: rows by the server's depth, the
+ * words in a box by kind, one edge per pair with the Screens view's label
+ * rule, and the panel's two lists.
+ */
+import { describe, it, expect } from 'vitest';
+import { buildStepsModel, kindWord, stepLabel, stepNeighbourhood, stepSub, stepViaText } from '../ui/src/lib/steps-model';
+import { placeLabels } from '../ui/src/lib/screens-model';
+import type { WireNodeRef, WireStep, WireStepLink, WireStepsPayload } from '../ui/src/lib/wire';
+
+function ref(name: string, file = 'src/a.tsx', language: WireNodeRef['language'] = 'tsx'): WireNodeRef {
+  return { id: `function:${name}`, kind: 'function', name, qualifiedName: name, file, line: 1, endLine: 9, language, test: false };
+}
+
+function step(label: string, kind: WireStep['kind'], depth: number, extra: Partial<WireStep> = {}): WireStep {
+  const node = kind === 'effect' ? null : ref(label, extra.node?.file ?? 'src/a.tsx');
+  return { id: node?.id ?? `effect:fn:${label}`, kind, anchor: depth === 0, node, label, sub: 'src/a.tsx', depth, cut: null, ...extra };
+}
+
+function link(from: WireStep, to: WireStep, extra: Partial<WireStepLink> = {}): WireStepLink {
+  return { id: `${from.id} ${to.id}`, from: from.id, to: to.id, kind: 'calls', via: [], when: '', label: '', synthesized: false, uncertain: false, sites: [], ...extra };
+}
+
+function payload(steps: WireStep[], links: WireStepLink[]): WireStepsPayload {
+  return {
+    anchor: steps[0]!.node!,
+    ambiguous: [],
+    steps,
+    links,
+    depth: 8,
+    limit: 120,
+    through: false,
+    truncated: { steps: 0, hubs: 0, chrome: 0 },
+    index: { lastIndexedAt: null, edges: 0, files: 0 },
+    timing: { elapsedMs: 1 },
+  };
+}
+
+describe('steps model', () => {
+  const screen = step('/capture/review', 'screen', 0, { screen: { path: '/capture/review', component: ref('ReviewScreen') } });
+  const handler = step('handleApprove', 'trigger', 1);
+  const bridge = step('finalizeCaptureSession', 'bridge', 2, { node: ref('finalizeCaptureSession', 'ios/CaptureView.swift', 'swift') });
+  const event = step('handleZipComplete', 'event', 3, { event: 'onZipComplete' });
+  const effect = step('client.post', 'effect', 4, { sub: 'network · uploadARCapture', effect: { api: 'client.post', apis: ['client.post'], category: 'network', by: ref('uploadARCapture'), line: 3 } });
+  const store = step('setZipUri', 'store', 4, { node: ref('setZipUri', 'src/storage/capture.storage.ts') });
+  const home = step('/', 'screen', 4, { screen: { path: '/', component: null } });
+  const links = [
+    link(screen, handler, { kind: 'handler' }),
+    link(handler, bridge, { kind: 'bridge', when: '!busy' }),
+    link(bridge, event, { kind: 'event', synthesized: true, via: [ref('emitZipComplete', 'ios/CaptureEvents.swift', 'swift')], when: 'result', label: 'via rn-event-channel · event onZipComplete' }),
+    link(event, effect, { kind: 'effect', via: [ref('uploadARCapture')] }),
+    link(event, store, { kind: 'store' }),
+    link(event, home, { kind: 'navigates', when: 'unlimited' }),
+    // A second way from the event to the store, unconditional: the pair is one edge saying "2 ways".
+    { ...link(event, store, { kind: 'store', when: 'retry' }), id: 'second' },
+  ];
+  const model = buildStepsModel(payload([screen, handler, bridge, event, effect, store, home], links));
+
+  it('puts the anchor on top and each row one step further away', () => {
+    const y = (id: string) => model.layout.nodes.find((n) => n.id === id)!.y;
+    expect(y(screen.id)).toBeLessThan(y(handler.id));
+    expect(y(handler.id)).toBeLessThan(y(bridge.id));
+    expect(y(bridge.id)).toBeLessThan(y(event.id));
+    expect(y(event.id)).toBeLessThan(y(effect.id));
+    expect(y(effect.id)).toBe(y(store.id));
+    expect(y(effect.id)).toBe(y(home.id));
+  });
+
+  it('one edge per pair, labelled with the innermost condition or a count', () => {
+    const edges = [...model.edges.values()];
+    expect(edges).toHaveLength(6);
+    const toBridge = edges.find((e) => e.to === bridge.id)!;
+    expect(toBridge.label).toBe('!busy');
+    expect(toBridge.kind).toBe('bridge');
+    const toEvent = edges.find((e) => e.to === event.id)!;
+    expect(toEvent.synthesized).toBe(true);
+    expect(toEvent.label).toBe('result');
+    const toStore = edges.find((e) => e.to === store.id)!;
+    expect(toStore.links).toHaveLength(2);
+    expect(toStore.label).toBe('2 ways · 1 conditional');
+    expect(toStore.kind).toBe('store');
+  });
+
+  it('counts steps per kind', () => {
+    expect(model.counts).toEqual({ anchor: 0, screen: 2, trigger: 1, bridge: 1, event: 1, store: 1, effect: 1 });
+  });
+
+  it('words a box by its kind', () => {
+    expect(stepLabel(bridge)).toBe('⇢ finalizeCaptureSession');
+    expect(stepLabel(event)).toBe('⇠ onZipComplete');
+    expect(stepLabel({ ...event, events: ['onZipComplete', 'onZipError', 'onCameraReady'] })).toBe('⇠ onZipComplete +2');
+    expect(stepLabel(screen)).toBe('/capture/review');
+    expect(stepSub(event)).toBe('handleZipComplete · a.tsx');
+    expect(stepSub(bridge)).toBe('native · CaptureView.swift');
+    expect(stepSub(store)).toBe('store · capture.storage.ts');
+    expect(stepSub(effect)).toBe('network · uploadARCapture');
+    expect(kindWord('effect')).toBe('outside the index');
+    expect(stepViaText(links[2]!)).toBe('emitZipComplete');
+  });
+
+  it('labels a selected step at the far end of each line, and lists its links', () => {
+    const pills = placeLabels(model, event.id);
+    expect(pills.hidden).toBe(0);
+    const words = [...pills.pills.values()].map((p) => p.text).sort();
+    expect(words).toEqual(['← result', '→ 2 ways · 1 conditional', '→ unlimited']);
+    const lists = stepNeighbourhood(payload([screen, handler, bridge, event, effect, store, home], links), event.id);
+    expect(lists.arrivesFrom.map((l) => l.from)).toEqual([bridge.id]);
+    expect(lists.leadsTo.map((l) => l.to)).toEqual([effect.id, store.id, home.id, store.id]);
+  });
+});

+ 29 - 1
codegraph-kernel/src/tsjs/extractors.rs

@@ -293,6 +293,29 @@ impl<'t> Walker<'t> {
 
     // --- extractVariable (TS/JS branch) ------------------------------------------------
 
+    /// A top-level binding exported by a LATER statement rather than at its
+    /// declaration: `export default NAME`, `export { NAME }`, `export { NAME as
+    /// default }`. The declaration's own `is_exported` (an `export_statement`
+    /// ancestor) cannot see these. One anchored regex over the file source.
+    /// Mirrors TreeSitterExtractor.isExportedLater.
+    pub(super) fn is_exported_later(&self, name: &str) -> bool {
+        if name.is_empty()
+            || !name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$').unwrap_or(false)
+            || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
+        {
+            return false;
+        }
+        let n = regex::escape(name);
+        let pattern = format!(
+            r"(?m)^[ \t]*export\s+(?:default\s+{n}\s*;?[ \t]*$|\{{[^}}]*\b{n}\b[^}}]*\}})",
+            n = n
+        );
+        match regex::Regex::new(&pattern) {
+            Ok(re) => re.is_match(self.src),
+            Err(_) => false,
+        }
+    }
+
     pub(super) fn extract_variable(&mut self, node: Node<'t>) {
         let is_const = self.is_const_decl(node);
         let kind: &'static str = if is_const { "constant" } else { "variable" };
@@ -373,7 +396,12 @@ impl<'t> Walker<'t> {
             let has_inline_fns = object_of_fns
                 .map(|o| self.object_has_inline_functions(o))
                 .unwrap_or(false);
-            let extract_object_methods = is_exported && object_of_fns.is_some() && has_inline_fns;
+            // "Exported" includes the two-statement form `const useStore =
+            // create(…)` … `export default useStore` (is_exported_later), the
+            // shape most React Native stores are written in. Mirrors
+            // TreeSitterExtractor.isExportedLater.
+            let extract_object_methods =
+                (is_exported || self.is_exported_later(&name)) && object_of_fns.is_some() && has_inline_fns;
 
             let rtk_endpoints = match value {
                 Some(v) if v.kind() == "call_expression" => self.find_rtk_endpoints_object(v),

+ 6 - 1
codegraph-kernel/src/tsjs/fnref.rs

@@ -39,6 +39,11 @@ pub fn dispatch(kind: &str) -> Option<Mode> {
         "variable_declarator" => Some(Mode::VarInit),
         "pair" => Some(Mode::Value),
         "array" => Some(Mode::List),
+        // A JSX attribute value or child (`onPress={handleSubmit}`): the
+        // expression's one named child is the value. Mirrors TS_JS_SPEC.
+        "jsx_expression" => Some(Mode::List),
+        // An object literal's shorthand members (`return { handleApprove }`).
+        "object" => Some(Mode::List),
         _ => None,
     }
 }
@@ -117,7 +122,7 @@ pub fn capture(container: Node, mode: Mode, src: &str) -> Vec<(Candidate, Mode)>
 /// `this.<member>` member_expression special form (object EXACTLY `this`).
 fn normalize<'t>(node: Node<'t>, src: &str) -> Vec<(String, Node<'t>)> {
     match node.kind() {
-        "identifier" => vec![(src[node.byte_range()].to_string(), node)],
+        "identifier" | "shorthand_property_identifier" => vec![(src[node.byte_range()].to_string(), node)],
         "member_expression" => {
             let obj = node.child_by_field_name("object");
             let prop = node.child_by_field_name("property");

+ 46 - 1
codegraph-kernel/src/tsjs/mod.rs

@@ -704,13 +704,20 @@ impl<'t> Walker<'t> {
             self.extract_variable_type_annotation(node, owner);
         }
 
-        // Nested NAMED functions become their own nodes.
+        // Nested NAMED functions become their own nodes — and so does the
+        // function a React handler hook binds a name to (`const onPress =
+        // useCallback(() => {…}, [])`). Mirrors TreeSitterExtractor's
+        // reactHookBoundName.
         if is_function_type(kind) {
             let name = self.extract_name(node);
             if name != "<anonymous>" {
                 self.extract_function(node, None);
                 return;
             }
+            if let Some(bound) = self.react_hook_bound_name(node) {
+                self.extract_function(node, Some(bound));
+                return;
+            }
         }
 
         if is_class_type(self.variant, kind) {
@@ -735,6 +742,44 @@ impl<'t> Walker<'t> {
 
     // --- name / signature / modifier helpers ------------------------------------
 
+    /// The declarator name a React handler hook binds an anonymous function
+    /// to — `const NAME = useCallback(<node>, [...])` (also `React.useCallback`,
+    /// `useEffectEvent`, `useEvent`) — or None for any other shape. The node
+    /// must be the call's FIRST argument and the call's value must be bound
+    /// directly by a `variable_declarator`.
+    fn react_hook_bound_name(&self, node: Node<'t>) -> Option<String> {
+        if !matches!(node.kind(), "arrow_function" | "function_expression") {
+            return None;
+        }
+        let args = node.parent()?;
+        if args.kind() != "arguments" {
+            return None;
+        }
+        let first = args.named_child(0)?;
+        if first.start_byte() != node.start_byte() || first.end_byte() != node.end_byte() {
+            return None;
+        }
+        let call = args.parent()?;
+        if call.kind() != "call_expression" {
+            return None;
+        }
+        let callee = call.child_by_field_name("function")?;
+        let callee_text = self.text(callee);
+        let hook = callee_text.strip_prefix("React.").unwrap_or(callee_text);
+        if !matches!(hook, "useCallback" | "useEffectEvent" | "useEvent") {
+            return None;
+        }
+        let declarator = call.parent()?;
+        if declarator.kind() != "variable_declarator" {
+            return None;
+        }
+        let name_node = declarator.child_by_field_name("name")?;
+        if name_node.kind() != "identifier" {
+            return None;
+        }
+        Some(self.text(name_node).to_string())
+    }
+
     /// extractName / extractNameRaw for the TS/JS configs.
     fn extract_name(&self, node: Node) -> String {
         // javascriptExtractor.resolveName: field_definition names its key the

+ 37 - 0
docs/design/codegraph-ui-design-spec.md

@@ -440,6 +440,43 @@ for a pair with several. Placement: at the FAR end of the line; first lane centr
 lane; never over a box; overflow counted in the panel. Panel row hover: that pill prints the whole condition (wraps at
 360px), its line at 1.0, the rest at 0.38; a hovered line tints its row `--press`. Legend bottom-left, remembered per browser.
 
+### 3.13 Steps (`#/steps?anchor=<id>` | `?symbol=<name>`, `&depth=`)
+What happens from an anchor — a screen, a handler, any symbol — drawn with the Screens view's machinery (§3.12's
+layout, tracks, pills, nearest-line pointer, panel) over a different node universe. `/api/steps` walks FORWARD from
+the anchor over `calls` / `instantiates` / `navigates` / function-as-value `references` / function→function
+`contains`, folding everything that is not a step into the link's `via` and joining the branch guards along the
+fold into its `when`. A node is a step when it is a **screen** (a route, entered over `navigates`), a **trigger**
+(a function passed as a value — `onPress={handleX}`, `addListener('x', handleX)`), a **bridge** (the language
+family changes JS → native under the call), an **event** (native → JS: the RN event channel's edge, named on the
+box), a **store** action (a function in a store file — the graph has no store kind, so the file is the evidence and
+the legend says so), or an **effect** (a call that leaves the index into the network / storage / the device /
+telemetry, matched on the call text against a curated table — including a call through a project-made value such
+as `client.post` on an axios instance, whose edge resolves to the constant). A listener the screen registers is a
+trigger when first met and becomes the event's landing when the walk arrives from native. Two passes per fold —
+step-arriving edges first, then plumbing — so a handler is never both a box and folded `via` from the same
+component. **Another screen is a boundary** — drawn, marked `cut: 'screen'`, not entered (`&through=1` enters
+them; the summary's checkbox): the Screens view draws the way between screens, and a walk on through Home is the
+whole app; so is a native event that lands in a COMPONENT (the capture overlay taking `onCaptureProgress` is
+another screen's body — `cut: 'component'`). A bridge or event step needs evidence — a bridge resolver's edge or a synthesized channel's; a plain
+name-matched call across the families (`arr.flat()` landing on a Swift `flat`) is neither drawn nor walked. Effects
+are one box per (function, category), labelled by the first call and counting the rest (`client.post +1`), the calls
+listed in the panel. Caps, each announced: depth in steps (default 8, ≤ 14, `cut: 'depth'` on the step it stopped
+at, drawn with `name …`), fan-out per node (80), folded nodes per step (300), steps per picture (120 default, ≤ 400);
+hubs (fan-in ≥ 40) and shared chrome (a component rendered by ≥ 5 parents — higher than the Screens view's 3, which
+attributes navigations rather than deciding what to walk into) are dead ends, counted in `truncated`. A step several
+events land on says `⇠ first +N` and lists them in the panel.
+
+Rows = distance from the anchor as the server counted it (first discovery), anchor on top with the entry mark. Boxes:
+the §3.12 screen box for a screen or a handler; **bridge / event** add a 3px `--accent` left rule (the language
+changes under the code) and lead with `⇢` / `⇠ <event name>`; **store** sits on `--paper-2`; **effect** is dashed
+`--ink-3` (a place the graph cannot follow into), labelled by the API (`client.post`) over `category · caller`. Edges,
+pills, tooltip and the panel's hover contract are §3.12's verbatim; the panel adds *Start here →* (re-anchor) on any
+step with a symbol, *Open as a flow →* on any link whose ends are both symbols (`#/flow?from=&to=`), a depth `<select>`
+(4–12) that rewrites the URL, per-kind counts, and the `truncated` notes. The bare tab (`#/steps`) is a chooser: the
+project's screens by connectivity, or a hint to search. `Picture` (`screens-model.ts`) is the structural interface
+the shared machinery works over; `steps-model.ts` builds one. Pure model tests: `ui-steps-model.test.ts`; the
+endpoint against a real RN + Expo fixture: `ui-steps-api.test.ts`.
+
 ## 4. Libraries and versions
 - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
   hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a

+ 14 - 1
src/extraction/function-ref.ts

@@ -175,13 +175,26 @@ function cFamilySpec(extra?: { special?: string[]; addressOfOnly?: boolean }): F
 // resolve precisely. Bare identifiers stay function-kind-only (a bare id can
 // never be a method value in JS).
 const TS_JS_SPEC: FnRefSpec = {
-  idTypes: new Set(['identifier']),
+  // `shorthand_property_identifier`: `{ handleSubmit }` — the object a hook
+  // returns its handlers in, and a namespace object's members.
+  idTypes: new Set(['identifier', 'shorthand_property_identifier']),
   dispatch: new Map<string, CaptureRule>([
     ['arguments', { mode: 'args' }],
     ['assignment_expression', { mode: 'rhs', field: 'right' }],
     ['variable_declarator', { mode: 'varinit', field: 'value' }],
     ['pair', { mode: 'value', field: 'value' }],
     ['array', { mode: 'list' }],
+    // A JSX attribute value or child: `onPress={handleSubmit}`, `renderItem={renderRow}`,
+    // `<Route component={Home}/>`. The expression's one named child is the value; a
+    // spread or a call normalizes to nothing. This is THE handler-binding idiom of
+    // React, and without it a tap's handler had no edge from the component that
+    // renders it — the Screens and Steps views could not see what a tap does.
+    ['jsx_expression', { mode: 'list' }],
+    // An object literal's shorthand members — `return { handleApprove,
+    // handleRetake }` from a hook, `const Api = { upload, createFolder }`.
+    // Every named child is offered; only a shorthand identifier normalizes
+    // (a `pair` is its own container above, a spread or a method is nothing).
+    ['object', { mode: 'list' }],
   ]),
   special: new Set(['member_expression']),
 };

+ 76 - 2
src/extraction/tree-sitter.ts

@@ -388,6 +388,13 @@ const LITERAL_RECEIVER_TYPES = new Set([
   'dictionary', 'dict_literal', 'object', 'tuple', 'set',
 ]);
 
+/**
+ * React hooks that bind a NAME to a handler function (`const onPress =
+ * useCallback(() => {…}, [])`). The arrow inside is extracted as a function
+ * node named by the declarator — see `reactHookBoundName`.
+ */
+const REACT_HANDLER_HOOKS = /^(?:React\.)?use(?:Callback|EffectEvent|Event)$/;
+
 export class TreeSitterExtractor {
   private filePath: string;
   private language: Language;
@@ -2205,6 +2212,24 @@ export class TreeSitterExtractor {
     }
   }
 
+  /**
+   * A top-level binding exported by a LATER statement rather than at its
+   * declaration: `export default NAME`, `export { NAME }`, `export { NAME as
+   * default }`. The declaration's own `isExported` (an `export_statement`
+   * ancestor) cannot see these, so a store written as `const useStore =
+   * create(…)` + `export default useStore` read as unexported and its actions
+   * were never extracted. One anchored regex over the file source; JS-family
+   * callers only.
+   */
+  private isExportedLater(name: string): boolean {
+    if (!/^[A-Za-z_$][\w$]*$/.test(name)) return false;
+    const re = new RegExp(
+      `^[ \\t]*export\\s+(?:default\\s+${name}\\s*;?[ \\t]*$|\\{[^}]*\\b${name}\\b[^}]*\\})`,
+      'm'
+    );
+    return re.test(this.source);
+  }
+
   /** Property-key text with surrounding quotes stripped (`'foo'` → `foo`). */
   private objectKeyName(key: SyntaxNode): string {
     return getNodeText(key, this.source).replace(/^['"`]|['"`]$/g, '');
@@ -2653,7 +2678,10 @@ export class TreeSitterExtractor {
             //     never nodes — so `node`/`callers` on `fetchUser` return "not
             //     found" and the agent Reads the store to reconstruct the flow.
             // Scoped to EXPORTED consts to exclude inline-object noise
-            // (`ctx.set({...})`) the object-method skip deliberately avoids.
+            // (`ctx.set({...})`) the object-method skip deliberately avoids —
+            // where "exported" includes the two-statement form `const useStore
+            // = create(…)` … `export default useStore` (see isExportedLater),
+            // the shape most React Native stores are written in.
             const objectOfFns =
               valueNode && (valueNode.type === 'object' || valueNode.type === 'object_expression')
                 ? valueNode
@@ -2666,7 +2694,8 @@ export class TreeSitterExtractor {
             // whose functions are body-local consts — it must fall through to a
             // normal body walk (extracting those consts), not be skipped here.
             const hasInlineFns = !!objectOfFns && this.objectHasInlineFunctions(objectOfFns);
-            const extractObjectMethods = isExported && !!objectOfFns && hasInlineFns;
+            const extractObjectMethods =
+              (isExported || this.isExportedLater(name)) && !!objectOfFns && hasInlineFns;
 
             // RTK Query: `createApi`/`injectEndpoints` define endpoints as
             // object-literal properties whose values are `build.query/mutation(...)`
@@ -5193,6 +5222,36 @@ export class TreeSitterExtractor {
     targets.add(target);
   }
 
+  /**
+   * The declarator name a React handler hook binds an anonymous function to —
+   * `const NAME = useCallback(<node>, [...])` — or null for any other shape.
+   * JS-family only; the node must be the hook call's FIRST argument, and the
+   * call's value must be bound directly by a `variable_declarator`.
+   */
+  private reactHookBoundName(node: SyntaxNode): string | null {
+    if (
+      this.language !== 'typescript' &&
+      this.language !== 'javascript' &&
+      this.language !== 'tsx' &&
+      this.language !== 'jsx'
+    ) {
+      return null;
+    }
+    if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
+    const args = node.parent;
+    if (!args || args.type !== 'arguments') return null;
+    const first = args.namedChild(0);
+    if (!first || first.startIndex !== node.startIndex || first.endIndex !== node.endIndex) return null;
+    const call = args.parent;
+    if (!call || call.type !== 'call_expression') return null;
+    const callee = getChildByField(call, 'function');
+    if (!callee || !REACT_HANDLER_HOOKS.test(getNodeText(callee, this.source))) return null;
+    const declarator = call.parent;
+    if (!declarator || declarator.type !== 'variable_declarator') return null;
+    const nameNode = getChildByField(declarator, 'name');
+    return nameNode?.type === 'identifier' ? getNodeText(nameNode, this.source) : null;
+  }
+
   private visitFunctionBody(body: SyntaxNode, _functionId: string): void {
     if (!this.extractor) return;
 
@@ -5315,6 +5374,21 @@ export class TreeSitterExtractor {
           this.extractFunction(node);
           return;
         }
+        // `const handleSubmit = useCallback(() => {…}, [deps])` — React's
+        // memoised handler. The function is anonymous only syntactically: the
+        // arrow is the first argument of a call whose result the declarator
+        // binds, and that binding is the name every `onPress={handleSubmit}`
+        // and `addListener('x', handleSubmit)` uses. Without a node of its own
+        // the handler's calls attribute to the component and the JSX prop or
+        // event registration has nothing to resolve to — the trigger of a flow
+        // is invisible. Bounded to the hooks React documents for handlers
+        // (`useCallback`, `useEffectEvent`, the experimental `useEvent`):
+        // `useMemo` / `useEffect` callbacks are computations, not handlers.
+        const hookBound = this.reactHookBoundName(node);
+        if (hookBound) {
+          this.extractFunction(node, hookBound);
+          return;
+        }
       }
 
       // Extract structural nodes found inside function bodies.

+ 27 - 4
src/resolution/callback-synthesizer.ts

@@ -1406,10 +1406,13 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
  *     DeviceEventEmitter.addListener("locationUpdate", handler);
  *
  * Synthesize: native dispatch site → JS handler, keyed by the literal
- * event name. Only matches NAMED handlers (the existing `ON_RE` named-
- * capture form). Inline arrow handlers like `addListener('x', d => …)`
- * aren't named at extraction time and would need link-through-body
- * support; matches the deliberate scope of the in-language synthesizer.
+ * event name. A NAMED handler (`addListener('x', handleX)`) is the target
+ * when it is a node; an unnamed one — a parameter passed through, or an
+ * inline `(data) => {…}` written in a `useEffect` — is attributed to the
+ * enclosing function, where the event demonstrably lands. (The in-language
+ * synthesizer stays named-only; this channel pairs across a language
+ * boundary on a literal, which is the evidence that makes the wider
+ * attribution safe.)
  *
  * Provenance `'heuristic'`, synthesizedBy `'rn-event-channel'`.
  */
@@ -1518,6 +1521,9 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
       // function (the abstraction layer), giving a reachability-correct
       // hop even when the actual user-side handler lives one call up.
       const ADDLISTENER_ANY = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z_][\w.]*)/g;
+      // The inline form: `.addListener('x', (data) => {…})` / `function () {…}`.
+      const ADDLISTENER_INLINE =
+        /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:async\s*)?(?:\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>|function\s*\()/g;
       ADDLISTENER_ANY.lastIndex = 0;
       let m: RegExpExecArray | null;
       while ((m = ADDLISTENER_ANY.exec(content))) {
@@ -1561,6 +1567,23 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
         map.set(targetId, `${file}:${lineOf(m.index)}`);
         jsHandlersByEvent.set(event, map);
       }
+      // Inline listeners — the shape a React component registers inside a
+      // `useEffect`: `nativeEmitter.addListener('onCaptureComplete', (data) =>
+      // { … router.push('/review') })`. There is no handler symbol to name,
+      // so the subscription is attributed to the enclosing function exactly
+      // as the unnamed-argument form above is: the native event lands in that
+      // component, and its body is where a reader looks next. This channel
+      // only — the in-language synthesizer keeps its named-handler policy.
+      ADDLISTENER_INLINE.lastIndex = 0;
+      while ((m = ADDLISTENER_INLINE.exec(content))) {
+        const event = m[1];
+        if (!event) continue;
+        const enclosing = enclosingFn(nodesInFile, lineOf(m.index));
+        if (!enclosing) continue;
+        const map = jsHandlersByEvent.get(event) ?? new Map<string, string>();
+        if (!map.has(enclosing.id)) map.set(enclosing.id, `${file}:${lineOf(m.index)}`);
+        jsHandlersByEvent.set(event, map);
+      }
     }
   }
 

+ 174 - 16
src/resolution/frameworks/react-native.ts

@@ -31,6 +31,25 @@
  * receiver is the default export, not literally `NativeModules.<Mod>`,
  * so name-by-method-only is what actually resolves in practice).
  *
+ * **Swift modules via `RCT_EXTERN_MODULE`** — the shape an app's OWN native
+ * code usually takes: a Swift class exposed through a thin `.m` shim.
+ *   - `@interface RCT_EXTERN_MODULE(ClassName, RCTSuperclass)` names the
+ *     Swift class, which is also the JS module (`NativeModules.ClassName`);
+ *     `RCT_EXTERN_REMAP_MODULE(jsName, ClassName, Super)` renames it.
+ *   - `RCT_EXTERN_METHOD(selector:(args)…)` exposes the Swift method named
+ *     by the selector's first keyword; `RCT_EXTERN_REMAP_METHOD(jsName,
+ *     selector…)` under another JS name. The implementation is the Swift
+ *     `@objc func` of that name on the class or one of its extensions — a
+ *     real node from the Swift extractor, so no synthetic node is minted.
+ *
+ * **Receiver evidence.** `captureView.finalizeCaptureSession()` where
+ * `const captureView = NativeModules.CaptureView` names the module as surely
+ * as `NativeModules.CaptureView.finalizeCaptureSession()` does: both resolve
+ * at 0.95 to that module's method — ahead of the import resolver, which
+ * would otherwise land the call on the `captureView` constant and the flow
+ * would stop one hop short of native. A bare `.method()` with no module
+ * evidence keeps the by-name match at 0.6.
+ *
  * **Not covered** (deferred to a follow-up phase, per design doc §6):
  *   - Fabric view components (`RCT_EXPORT_VIEW_PROPERTY` / Codegen view
  *     specs) — these connect JSX props to native renderers, a different
@@ -59,7 +78,7 @@ interface NativeMethod {
 /** Per-context lazy map cache. */
 const nativeMethodMaps: WeakMap<
   ResolutionContext,
-  { byJsName: Map<string, NativeMethod[]> }
+  { byJsName: Map<string, NativeMethod[]>; aliases: Map<string, string> }
 > = new WeakMap();
 
 // ─── Native-side extraction ─────────────────────────────────────────────────
@@ -154,6 +173,87 @@ function findObjcClassName(source: string): string | null {
   return m?.[1] ?? null;
 }
 
+/**
+ * `RCT_EXTERN_MODULE` / `RCT_EXTERN_METHOD` — the `.m` shim that exposes a
+ * Swift class. One entry per exposed method, each naming the Swift class the
+ * implementation lives on (the module name is the class name unless
+ * `RCT_EXTERN_REMAP_MODULE` says otherwise).
+ */
+export function parseObjcRNExterns(
+  source: string
+): Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> {
+  const results: Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
+  const remap = source.match(
+    /RCT_EXTERN_REMAP_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/
+  );
+  const plain = source.match(/RCT_EXTERN_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/);
+  const moduleName = remap?.[1] ?? plain?.[1] ?? null;
+  const className = remap?.[2] ?? plain?.[1] ?? null;
+  if (!moduleName || !className) return results;
+
+  const lineOf = (idx: number): number => {
+    let line = 1;
+    for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
+    return line;
+  };
+
+  const methodRegex = /RCT_EXTERN_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
+  let m: RegExpExecArray | null;
+  while ((m = methodRegex.exec(source)) !== null) {
+    const kw = m[1];
+    if (kw) results.push({ moduleName, className, jsName: kw, nativeSelectorFirstKw: kw, line: lineOf(m.index) });
+  }
+  const remapRegex =
+    /RCT_EXTERN_REMAP_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/g;
+  while ((m = remapRegex.exec(source)) !== null) {
+    const jsName = m[1];
+    const nativeKw = m[2];
+    if (jsName && nativeKw) {
+      results.push({ moduleName, className, jsName, nativeSelectorFirstKw: nativeKw, line: lineOf(m.index) });
+    }
+  }
+  return results;
+}
+
+/**
+ * Local names bound to a native module on the JS side — the receiver evidence
+ * `resolve()` trusts:
+ *
+ *   const captureView = NativeModules.CaptureView
+ *   export const { CaptureEvents } = NativeModules
+ *   const { Geo: geolocation } = NativeModules
+ *
+ * Collected across the project by name: an alias is nearly always an exported
+ * constant imported elsewhere under the same name. A name bound to two
+ * different modules is dropped as ambiguous rather than guessed.
+ */
+export function collectNativeModuleAliases(
+  source: string,
+  aliases: Map<string, string>,
+  ambiguous: Set<string>
+): void {
+  const bind = (alias: string, moduleName: string): void => {
+    if (ambiguous.has(alias)) return;
+    const prior = aliases.get(alias);
+    if (prior !== undefined && prior !== moduleName) {
+      aliases.delete(alias);
+      ambiguous.add(alias);
+      return;
+    }
+    aliases.set(alias, moduleName);
+  };
+  const direct = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*NativeModules\.([A-Z][\w$]*)/g;
+  let m: RegExpExecArray | null;
+  while ((m = direct.exec(source)) !== null) bind(m[1]!, m[2]!);
+  const destructured = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*NativeModules\b/g;
+  while ((m = destructured.exec(source)) !== null) {
+    for (const part of m[1]!.split(',')) {
+      const entry = part.trim().match(/^([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?$/);
+      if (entry) bind(entry[2] ?? entry[1]!, entry[1]!);
+    }
+  }
+}
+
 /**
  * Parse a Java/Kotlin source file for `@ReactMethod` annotated methods
  * and the surrounding class's `getName()` return value (the JS-visible
@@ -260,16 +360,19 @@ const RN_EMITTER_BUILTINS = new Set([
   'stopObserving',
 ]);
 
-function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]> } {
+function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]>; aliases: Map<string, string> } {
   const cached = nativeMethodMaps.get(context);
   if (cached) return cached;
 
   const byJsName = new Map<string, NativeMethod[]>();
+  const aliases = new Map<string, string>();
+  const ambiguousAliases = new Set<string>();
   const allFiles = context.getAllFiles();
   // Pre-index native methods by name for fast lookup when matching to
   // their bridge exports.
   const objcMethodsByFirstKw = new Map<string, Node[]>();
   const jvmMethodsByName = new Map<string, Node[]>();
+  const swiftMethodsByName = new Map<string, Node[]>();
   for (const node of context.getNodesByKind('method')) {
     if (node.language === 'objc') {
       const firstKw = node.name.includes(':') ? node.name.split(':')[0] : node.name;
@@ -282,6 +385,10 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
       const arr = jvmMethodsByName.get(node.name);
       if (arr) arr.push(node);
       else jvmMethodsByName.set(node.name, [node]);
+    } else if (node.language === 'swift') {
+      const arr = swiftMethodsByName.get(node.name);
+      if (arr) arr.push(node);
+      else swiftMethodsByName.set(node.name, [node]);
     }
   }
 
@@ -306,6 +413,33 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
         if (arr) arr.push(entry);
         else byJsName.set(exp.jsName, [entry]);
       }
+      // Swift-backed module: the shim names the class; the implementation is
+      // the Swift method of that name on the class or one of its extensions.
+      // Class-scoped, so a same-named method on another Swift type (a
+      // `syncSettings` on `CaptureSettings` beside the one on `CaptureView`)
+      // is never the answer.
+      if (/RCT_EXTERN_(?:REMAP_)?MODULE\b/.test(source)) {
+        for (const ext of parseObjcRNExterns(source)) {
+          if (RN_EMITTER_BUILTINS.has(ext.jsName)) continue;
+          const candidates = (swiftMethodsByName.get(ext.nativeSelectorFirstKw) ?? [])
+            .filter((c) => c.qualifiedName.split('::').includes(ext.className))
+            .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
+          const node = candidates[0];
+          if (!node) continue;
+          const entry: NativeMethod = { moduleName: ext.moduleName, jsName: ext.jsName, node };
+          const arr = byJsName.get(ext.jsName);
+          if (arr) arr.push(entry);
+          else byJsName.set(ext.jsName, [entry]);
+        }
+      }
+    }
+
+    // JS side: the local names bound to `NativeModules.<Module>`.
+    if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
+      const source = context.readFile(file);
+      if (source && source.includes('NativeModules')) {
+        collectNativeModuleAliases(source, aliases, ambiguousAliases);
+      }
     }
 
     // Legacy bridge — Java/Kotlin side.
@@ -352,7 +486,7 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
     }
   }
 
-  const result = { byJsName };
+  const result = { byJsName, aliases };
   nativeMethodMaps.set(context, result);
   return result;
 }
@@ -406,7 +540,7 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
 
   /**
    * Detect: package.json depends on `react-native`, OR any source file
-   * uses the `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` /
+   * uses the `RCT_EXPORT_MODULE` / `RCT_EXTERN_MODULE` /
    * `TurboModuleRegistry` markers. Either signal is enough — different
    * libraries split the JS package from the native code (`react-native-svg`'s
    * apple/ + android/ directories vs its src/), so we don't require both.
@@ -423,7 +557,7 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
       if (!f) continue;
       if (f.endsWith('.mm') || f.endsWith('.m')) {
         const src = context.readFile(f);
-        if (src && /RCT_EXPORT_MODULE\b/.test(src)) return true;
+        if (src && /RCT_EXPORT_MODULE\b|RCT_EXTERN_(?:REMAP_)?MODULE\b/.test(src)) return true;
       }
       if (f.endsWith('.ts') || f.endsWith('.tsx')) {
         const src = context.readFile(f);
@@ -454,28 +588,52 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
     }
 
     // JS callsites of `obj.method()` reach the resolver as either
-    // `obj.method` (qualified) or `method` (bare). Strip a single dot
-    // prefix to get the JS-visible method name.
-    const name = ref.referenceName.includes('.')
-      ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
-      : ref.referenceName;
+    // `obj.method` (qualified) or `method` (bare). Strip the receiver to
+    // get the JS-visible method name — and keep it, as evidence.
+    const raw = ref.referenceName;
+    const lastDot = raw.lastIndexOf('.');
+    const name = lastDot >= 0 ? raw.slice(lastDot + 1) : raw;
 
     const maps = buildRNMaps(context);
     const entries = maps.byJsName.get(name);
     if (!entries || entries.length === 0) return null;
 
-    // Prefer the iOS (ObjC) target over Android when both exist — iOS is
-    // the conventional first-class platform for RN library docs and most
-    // graph queries. We still record only one edge; a JVM-only resolution
-    // is fine when no ObjC target exists.
-    const objc = entries.find((e) => e.node.language === 'objc');
-    const target = objc ?? entries[0];
+    // iOS first — the conventional first-class platform for RN library docs
+    // and most graph queries; one edge is recorded either way.
+    const pick = (list: NativeMethod[]): NativeMethod | undefined =>
+      list.find((e) => e.node.language === 'objc') ??
+      list.find((e) => e.node.language === 'swift') ??
+      list[0];
+
+    // Receiver evidence: `NativeModules.Mod.method`, or an alias the project
+    // bound to `NativeModules.Mod`. The module is named, so the match is to
+    // THAT module's method at a confidence the import resolver cannot beat
+    // — and a module that has no such method is not ours to guess at.
+    if (lastDot >= 0) {
+      const receiver = raw.slice(0, lastDot);
+      const direct = receiver.match(/^NativeModules\.([A-Z][\w$]*)$/);
+      const moduleName = direct ? direct[1]! : maps.aliases.get(receiver) ?? null;
+      if (moduleName !== null) {
+        const exact = pick(entries.filter((e) => e.moduleName === moduleName));
+        if (!exact) return null;
+        return {
+          original: ref,
+          targetNodeId: exact.node.id,
+          confidence: 0.95,
+          resolvedBy: 'framework',
+          metadata: { bridge: 'react-native', module: moduleName },
+        };
+      }
+    }
+
+    const target = pick(entries);
     if (!target) return null;
     return {
       original: ref,
       targetNodeId: target.node.id,
       confidence: 0.6,
       resolvedBy: 'framework',
+      metadata: { bridge: 'react-native', module: target.moduleName },
     };
   },
 };

+ 109 - 3
src/resolution/import-resolver.ts

@@ -79,6 +79,27 @@ interface FileExportIndex {
   byName: Map<string, Node>;
   defaultComponent: Node | undefined;
   defaultFnClass: Node | undefined;
+  /**
+   * The node an `export default NAME` statement names, exported at its
+   * declaration or not — the precise answer where `defaultFnClass` is a
+   * guess. `const Home = () => …; export default Home` and the namespace
+   * object `const UploadApi = { uploadARCapture }; export default UploadApi`
+   * are both invisible to the `isExported` index above: neither declaration
+   * has an `export_statement` ancestor.
+   */
+  defaultBinding: Node | undefined;
+}
+
+const DEFAULT_BINDING_KINDS = new Set<string>(['function', 'class', 'component', 'constant', 'variable']);
+const DEFAULT_EXPORT_BINDING_RE = /^[ \t]*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?[ \t]*$/m;
+const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/;
+
+/** The identifier `export default NAME` names in a JS-family file, or null. */
+function defaultExportBinding(filePath: string, context: ResolutionContext): string | null {
+  if (!JS_FAMILY_FILE.test(filePath)) return null;
+  const source = context.readFile(filePath);
+  if (!source || !source.includes('export default')) return null;
+  return source.match(DEFAULT_EXPORT_BINDING_RE)?.[1] ?? null;
 }
 const fileExportIndexes = new WeakMap<ResolutionContext, Map<string, FileExportIndex>>();
 
@@ -90,13 +111,20 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE
   }
   let idx = perFile.get(filePath);
   if (!idx) {
-    idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined };
-    for (const n of context.getNodesInFile(filePath)) {
+    idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined, defaultBinding: undefined };
+    const nodesInFile = context.getNodesInFile(filePath);
+    for (const n of nodesInFile) {
       if (!n.isExported) continue;
       if (!idx.byName.has(n.name)) idx.byName.set(n.name, n);
       if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n;
       if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n;
     }
+    const bound = defaultExportBinding(filePath, context);
+    if (bound !== null) {
+      idx.defaultBinding = nodesInFile
+        .filter((n) => n.name === bound && DEFAULT_BINDING_KINDS.has(n.kind))
+        .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
+    }
     perFile.set(filePath, idx);
   }
   return idx;
@@ -1558,6 +1586,8 @@ export function resolveViaImport(
               if (member) {
                 const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import');
                 if (literalMember) return literalMember;
+                const aliasMember = resolveObjectLiteralAlias(targetNode, member, ref, context);
+                if (aliasMember) return aliasMember;
               }
             }
             // An imported VALUE (singleton constant / shared instance) called
@@ -1717,6 +1747,80 @@ function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): Reso
   return null;
 }
 
+/**
+ * `UploadApi.uploadARCapture()` where `UploadApi` is a NAMESPACE OBJECT — the
+ * default-export façade most React Native API layers are written as:
+ *
+ *   import { uploadARCapture } from './frames'
+ *   const UploadApi = { uploadARCapture, createFolder }
+ *   export default UploadApi
+ *
+ * The member is a shorthand (or `key: ident`) property whose value is a
+ * binding of the object's file, not a function defined inside the literal,
+ * so containment (`resolveObjectLiteralMember`) finds nothing and the call
+ * landed on the constant — every cross-file caller of the API function went
+ * missing. Read the literal's source, take the binding the member names, and
+ * resolve it where the object's file would: a symbol declared there, else
+ * through its own imports. Calls accept callable targets only.
+ */
+function resolveObjectLiteralAlias(
+  container: Node,
+  member: string,
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  if (container.kind !== 'constant' && container.kind !== 'variable') return null;
+  if (!JS_FAMILY_FILE.test(container.filePath)) return null;
+  if (!/^[A-Za-z_$][\w$]*$/.test(member)) return null;
+  const lines = context.getFileLines?.(container.filePath) ?? context.readFile(container.filePath)?.split('\n');
+  if (!lines) return null;
+  const extent = lines.slice(container.startLine - 1, container.endLine).join('\n');
+  const brace = extent.indexOf('{');
+  if (brace < 0) return null;
+  const body = extent.slice(brace);
+  const keyed = new RegExp(`[{,\\s]${member}\\s*:\\s*([A-Za-z_$][\\w$]*)\\s*[,}]`);
+  const shorthand = new RegExp(`[{,\\s]${member}\\s*[,}]`);
+  const k = body.match(keyed);
+  const binding = k ? k[1]! : shorthand.test(body) ? member : null;
+  if (binding === null) return null;
+
+  const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'class';
+  const accepts =
+    ref.referenceKind === 'calls'
+      ? callable
+      : (n: Node) => callable(n) || n.kind === 'constant' || n.kind === 'variable' || n.kind === 'component';
+
+  // Declared in the object's own file, outside the literal.
+  const local = context
+    .getNodesInFile(container.filePath)
+    .filter((n) => n.name === binding && n.id !== container.id && accepts(n))
+    .sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
+  if (local) return { original: ref, targetNodeId: local.id, confidence: 0.9, resolvedBy: 'import' };
+
+  // Imported into the object's file.
+  for (const imp of context.getImportMappings(container.filePath, container.language)) {
+    if (imp.localName !== binding || imp.isNamespace) continue;
+    const resolvedPath = resolveImportPath(imp.source, container.filePath, container.language, context);
+    if (!resolvedPath) continue;
+    const target = findExportedSymbol(
+      resolvedPath,
+      {
+        isDefault: imp.isDefault,
+        isNamespace: false,
+        exportedName: imp.isDefault ? 'default' : imp.exportedName,
+        memberName: null,
+      },
+      container.language,
+      context,
+      new Set()
+    );
+    if (target && accepts(target)) {
+      return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
+    }
+  }
+  return null;
+}
+
 function resolveModuleImportToFile(
   ref: UnresolvedRef,
   imports: ImportMapping[],
@@ -2152,7 +2256,9 @@ function findExportedSymbolWalk(
     // `.ts`/`.tsx` `export default fn`/`class` case. Without the component
     // branch, an `export { default as X } from './X.svelte'` barrel never
     // resolves and the component shows a false 0 callers (#629).
-    const direct = exportIndex.defaultComponent ?? exportIndex.defaultFnClass;
+    // A component file IS its default export; otherwise the statement that
+    // names the binding beats the first-exported-function guess.
+    const direct = exportIndex.defaultComponent ?? exportIndex.defaultBinding ?? exportIndex.defaultFnClass;
     if (direct) return direct;
   } else if (want.isNamespace && want.memberName) {
     const direct = exportIndex.byName.get(want.memberName);

+ 9 - 0
src/ui-server/api/index.ts

@@ -57,6 +57,7 @@ import { buildEntryPoints } from './entrypoints';
 import { buildNodeRefs } from './nodes';
 import { buildMap } from './map';
 import { buildScreens } from './screens';
+import { buildSteps } from './steps';
 import { buildDeadCode } from './deadcode';
 import { buildFlow } from './flow';
 import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
@@ -202,6 +203,12 @@ const API_INDEX = {
       description: 'The app as screens and the transitions between them, each with the conditions it runs under.',
       params: [],
     },
+    {
+      path: '/api/steps',
+      description:
+        'What happens from a screen or a symbol: screens, handlers, native bridge calls and events, store writes and calls that leave the index, as typed steps with the conditions between them.',
+      params: ['anchor', 'symbol', 'depth', 'limit'],
+    },
     {
       path: '/api/flow',
       description: 'The call path between symbols: one hop per card, opened at the calling line.',
@@ -269,6 +276,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           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/steps':
+          return ok(res, await buildSteps(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         case '/api/deadcode':
           return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         case '/api/entrypoints':

+ 4 - 32
src/ui-server/api/screens.ts

@@ -28,10 +28,8 @@
  */
 
 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 type { Edge, Node } from '../../types';
+import { createWhenReader } from './when';
 import { toNodeRef, type WireNodeRef } from './wire';
 
 // =============================================================================
@@ -183,7 +181,8 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
     screenOfComponent.set(component.id, edge.source);
   }
 
-  const whenAt = makeWhenReader(cg, projectRoot);
+  const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
+  const whenAt = (caller: Node, edge: Edge): Promise<string> => readWhen(caller, { line: edge.line, column: edge.column });
   const links = new Map<string, WireScreenLink>();
   const origins = new Map<string, WireScreenOrigin>();
   const counts = new Map<string, { incoming: number; outgoing: number }>();
@@ -468,33 +467,6 @@ function complementary(a: string, b: string): boolean {
   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, '/');
 }

+ 723 - 0
src/ui-server/api/steps.ts

@@ -0,0 +1,723 @@
+/**
+ * `GET /api/steps` — what happens from here: a screen, a handler or any
+ * symbol as the ANCHOR, and everything it sets in motion drawn as typed steps.
+ *
+ * The Screens view (`screens.ts`) is already a picture of steps with one step
+ * type: it folds `HomeScreen → ItemsGrid → ItemCard → openObjectDetail` into
+ * one arrow labelled with its condition, because the reader wants the
+ * transition, not the plumbing. This endpoint keeps that fold and widens the
+ * set of things worth a box. Walking FORWARD from the anchor over calls,
+ * renders, handler bindings and navigations, a node is a step when it is:
+ *
+ * - a **screen** (a route reached over a `navigates` edge),
+ * - a **trigger** — a function wired as a value (`onPress={handleX}`,
+ *   `addListener('x', handleX)`), the user's or the platform's way in,
+ * - a **bridge** call — the language changes under the call, JS → native
+ *   (the React Native bridge resolver's edges, or any family crossing),
+ * - a native **event** landing back in JS (`sendEvent(withName:)` → the
+ *   listener, via the RN event channel),
+ * - a **store** action — a function in a store file, the state it writes,
+ * - an **effect** — a call that leaves the index into the network, storage,
+ *   the device or telemetry, drawn as its own box beside the function that
+ *   makes it.
+ *
+ * Everything else — hooks, helpers, services, the components between a
+ * screen and its handlers — is `via`: listed on the link, never a box. The
+ * branch conditions along the folded chain join into the link's `when`, read
+ * from the source at request time exactly as the Screens view reads them.
+ *
+ * The picture is finite because it is ANCHORED and CAPPED, not because the
+ * graph is small: a bounded depth in steps, a bounded fan-out per node, a
+ * bounded number of nodes folded per step, and hubs and shared chrome (a top
+ * bar rendered on ten screens) are dead ends rather than paths. Every cap
+ * that fired is reported on the step it fired at, so a short picture never
+ * reads as "nothing else happens here".
+ *
+ * Read from the graph at request time, never cached: the `when` labels and
+ * the effect sites are read from the source as it stands.
+ */
+
+import type CodeGraph from '../../index';
+import type { Edge, Language, Node, UnresolvedReference } from '../../types';
+import { badRequest, intParam, notFound } from './respond';
+import { createWhenReader } from './when';
+import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
+
+// =============================================================================
+// Wire shapes
+// =============================================================================
+
+export type WireStepKind = 'anchor' | 'screen' | 'trigger' | 'bridge' | 'event' | 'store' | 'effect';
+
+export type WireStepLinkKind = 'calls' | 'navigates' | 'handler' | 'bridge' | 'event' | 'store' | 'effect';
+
+export interface WireStepSite {
+  file: string;
+  line: number;
+  /** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
+  text: string;
+}
+
+export interface WireStep {
+  /** The node's id, or `effect:<function id>:<api>` for a call leaving the index. */
+  id: string;
+  kind: WireStepKind;
+  /** The step the picture starts from. A screen anchor keeps `kind: 'screen'`. */
+  anchor: boolean;
+  /** Null only for an effect, which is a call site rather than a symbol. */
+  node: WireNodeRef | null;
+  /** `/capture/review`, `handleApproveAllImages`, `client.post`. */
+  label: string;
+  /** The component for a screen, the file for a symbol, the category and caller for an effect. */
+  sub: string;
+  /** Steps from the anchor: the row. */
+  depth: number;
+  /**
+   * Why the walk did not go on from this step, when it did not: a cap it hit
+   * (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen is
+   * a chapter of its own, drawn but not entered unless `through` asks.
+   */
+  cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
+  /** The event name a native event step arrived on (`onZipComplete`) — the first, when several land here. */
+  event?: string;
+  /** Every event that lands on this step, in the order the walk met them. */
+  events?: string[];
+  /** For a screen: its path and the component that renders it. */
+  screen?: { path: string; component: WireNodeRef | null };
+  /**
+   * For an effect: the calls one function makes into one category — `api` is
+   * the first, `apis` all of them — and the function that makes them.
+   */
+  effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
+}
+
+export interface WireStepLink {
+  id: string;
+  from: string;
+  to: string;
+  kind: WireStepLinkKind;
+  /** The symbols folded between the two steps, in order. */
+  via: WireNodeRef[];
+  /** Conditions along the whole chain, joined; '' when unconditional. */
+  when: string;
+  /** How the last hop was established when it was not a plain call — `via rn-event-channel · registered at file:line`. */
+  label: string;
+  synthesized: boolean;
+  uncertain: boolean;
+  sites: WireStepSite[];
+}
+
+export interface WireStepsPayload {
+  anchor: WireNodeRef;
+  /** Other symbols that share the anchor's name, when it was given by name. */
+  ambiguous: WireNodeRef[];
+  steps: WireStep[];
+  links: WireStepLink[];
+  depth: number;
+  limit: number;
+  /** Screens reached from the anchor were entered rather than drawn as boundaries. */
+  through: boolean;
+  truncated: {
+    /** Steps not added because the picture reached `limit`. */
+    steps: number;
+    /** Folded walks that stopped at a hub (fan-in ≥ the hub threshold). */
+    hubs: number;
+    /** Folded walks that stopped at shared chrome (a component rendered by several screens). */
+    chrome: number;
+  };
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
+// =============================================================================
+// Caps
+// =============================================================================
+
+export const DEFAULT_DEPTH = 8;
+export const MAX_DEPTH = 14;
+export const DEFAULT_LIMIT = 120;
+export const MAX_LIMIT = 400;
+/** Nodes folded while exploring from ONE step before the walk stops. */
+const MAX_FOLDED_PER_STEP = 300;
+/** Hops of folded plumbing between two steps. */
+const MAX_FOLD_DEPTH = 7;
+/** Outgoing edges followed from one node; past this the node is a god function and the rest is announced. */
+const MAX_FANOUT = 80;
+/** Unresolved-reference scans (for effects) per request. */
+const MAX_EFFECT_SCANS = 800;
+/** Call sites labelled with conditions per request. */
+const MAX_WHEN_SITES = 800;
+/**
+ * A component rendered by this many distinct parents is chrome (a top bar, a
+ * button), not a screen's own behaviour. Higher than the Screens view's 3: that
+ * one attributes navigations, where three screens sharing a link is already
+ * chrome; this one decides what to WALK INTO, and a capture component shared
+ * by three capture flows is the screen's whole body.
+ */
+const SHARED_CHROME_MIN = 5;
+
+/** Edges walked forward. `contains` only function → function (a hook's handlers); `references` only function-as-value. */
+const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'navigates', 'references', 'contains'];
+
+// =============================================================================
+// Classification
+// =============================================================================
+
+const JS_FAMILY: ReadonlySet<Language> = new Set<Language>(['javascript', 'typescript', 'tsx', 'jsx']);
+const NATIVE_FAMILY: ReadonlySet<Language> = new Set<Language>(['swift', 'objc', 'java', 'kotlin']);
+
+/** JS → native is a bridge call; native → JS is an event. Anything else is one family. */
+export function crossing(from: Language, to: Language): 'bridge' | 'event' | null {
+  if (JS_FAMILY.has(from) && NATIVE_FAMILY.has(to)) return 'bridge';
+  if (NATIVE_FAMILY.has(from) && JS_FAMILY.has(to)) return 'event';
+  return null;
+}
+
+/**
+ * A file that holds state: a store, a slice, a reducer. The graph has no
+ * "store" kind — a Zustand action is an ordinary function node — so the file
+ * is the evidence, and the legend says so.
+ */
+export const STORE_FILE = /(?:^|\/)(?:stores?|storage|state|slices?|reducers?)\/|\.(?:store|storage|slice|reducer)\.[cm]?[jt]sx?$/i;
+
+export function isStoreFile(file: string): boolean {
+  return STORE_FILE.test(file.replace(/\\/g, '/'));
+}
+
+/**
+ * Calls that leave the index and change something outside the process. A
+ * curated table, deliberately: "any call into a package" is every `Date` and
+ * `Math.max`, and a box for each would bury the ones that matter. Matched on
+ * the reference text as written at the call.
+ */
+export const EFFECTS: ReadonlyArray<{ category: string; test: RegExp }> = [
+  {
+    category: 'network',
+    test: /^(?:fetch|axios|ky|got|superagent|XMLHttpRequest|WebSocket)$|^(?:axios|api|client|http|https|httpClient|apiClient|instance|request|agent|graphql|apollo|supabase)\.(?:get|post|put|patch|delete|head|request|query|mutate|rpc|invoke)$|^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask)$/,
+  },
+  {
+    category: 'storage',
+    test: /^(?:AsyncStorage|SecureStore|MMKV|localStorage|sessionStorage|indexedDB|UserDefaults|Keychain|KeychainAccess|FileSystem|RNFS|FileManager|fs|fsp)\b/,
+  },
+  {
+    category: 'device',
+    test: /^(?:Linking|Share|Clipboard|Notifications|Camera|ImagePicker|MediaLibrary|Haptics|Alert|Vibration|Location|Geolocation|Permissions|UIApplication|AVCaptureSession|AVAudioSession|CLLocationManager|UNUserNotificationCenter)\b/,
+  },
+  {
+    category: 'telemetry',
+    test: /^(?:DdRum|DdLogs|DdTrace|DdSdkReactNative|CustomerIO|Sentry|Bugsnag|analytics|Analytics|crashlytics|Crashlytics|mixpanel|Mixpanel|amplitude|Amplitude|posthog|PostHog|LDClient|ldClient)\b/,
+  },
+];
+
+export function effectCategory(referenceName: string): string | null {
+  for (const e of EFFECTS) if (e.test.test(referenceName)) return e.category;
+  return null;
+}
+
+// =============================================================================
+// The endpoint
+// =============================================================================
+
+interface Fold {
+  node: Node;
+  /** [first folded node, …, this node]; empty for the step's own root. */
+  chain: Node[];
+  whens: string[];
+}
+
+interface StepRecord extends WireStep {
+  /** Where exploration from this step begins: a screen's component, otherwise the node itself. */
+  root: Node | null;
+}
+
+export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLSearchParams): Promise<WireStepsPayload> {
+  const started = Date.now();
+  const depthCap = intParam(query, 'depth', { min: 1, max: MAX_DEPTH, default: DEFAULT_DEPTH });
+  const limit = intParam(query, 'limit', { min: 20, max: MAX_LIMIT, default: DEFAULT_LIMIT });
+  const through = query.get('through') === '1';
+  const stats = cg.getStats();
+  const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
+
+  const { anchor, ambiguous } = resolveAnchor(cg, query);
+
+  // Route → the component it renders, and the routes by id.
+  const routes = cg.getNodesByKind('route');
+  const renders = routes.length === 0 ? [] : cg.getOutgoingEdgesFrom(routes.map((r) => r.id), ['calls', 'instantiates']);
+  const componentOf = new Map<string, Node>();
+  if (renders.length > 0) {
+    const components = cg.getNodesByIds(renders.map((e) => e.target));
+    for (const edge of renders) {
+      const c = components.get(edge.target);
+      if (c && !componentOf.has(edge.source)) componentOf.set(edge.source, c);
+    }
+  }
+
+  const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
+  const whenAt = (caller: Node, site: { line?: number; column?: number }) => readWhen(caller, site);
+
+  const steps = new Map<string, StepRecord>();
+  const links = new Map<string, WireStepLink>();
+  const truncated = { steps: 0, hubs: 0, chrome: 0 };
+  let effectScans = 0;
+  const fanIn = new Map<string, number>();
+  const chromeParents = new Map<string, number>();
+  const fileScopeRefs = new Map<string, Edge[]>();
+
+  const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
+    const existing = steps.get(node.id);
+    if (existing) {
+      // A listener the screen registers is a handler when first met, and the
+      // native event's landing when the walk arrives from the other side —
+      // the second is the fuller fact, and it names the event.
+      if (existing.kind === 'trigger' && kind === 'event') {
+        existing.kind = 'event';
+        if (extra.event) existing.event = extra.event;
+      }
+      if (kind === 'event' && extra.event) {
+        existing.events = existing.events ?? (existing.event ? [existing.event] : []);
+        if (!existing.events.includes(extra.event)) existing.events.push(extra.event);
+      }
+      return existing;
+    }
+    if (steps.size >= limit) {
+      truncated.steps++;
+      return null;
+    }
+    const isRoute = node.kind === 'route';
+    const record: StepRecord = {
+      id: node.id,
+      kind: isRoute ? 'screen' : kind,
+      anchor: false,
+      node: toNodeRef(node),
+      label: isRoute ? node.name : node.name,
+      sub: isRoute ? (componentOf.get(node.id)?.name ?? posix(node.filePath)) : posix(node.filePath),
+      depth,
+      cut: null,
+      ...extra,
+      root: isRoute ? (componentOf.get(node.id) ?? null) : node,
+    };
+    if (kind === 'event' && extra.event) record.events = [extra.event];
+    if (isRoute) record.screen = { path: node.name, component: componentOf.has(node.id) ? toNodeRef(componentOf.get(node.id)!) : null };
+    steps.set(node.id, record);
+    return record;
+  };
+
+  // One box per (function, category): `uploadARCapture` makes one network
+  // call, three storage calls and three telemetry calls — three boxes, each
+  // listing its calls, not seven.
+  const effectStep = (by: Node, ref: { referenceName: string; line: number }, category: string, depth: number): StepRecord | null => {
+    const id = `effect:${by.id}:${category}`;
+    const existing = steps.get(id);
+    if (existing) {
+      const apis = existing.effect!.apis;
+      if (!apis.includes(ref.referenceName)) {
+        apis.push(ref.referenceName);
+        existing.label = `${apis[0]} +${apis.length - 1}`;
+      }
+      return existing;
+    }
+    if (steps.size >= limit) {
+      truncated.steps++;
+      return null;
+    }
+    const record: StepRecord = {
+      id,
+      kind: 'effect',
+      anchor: false,
+      node: null,
+      label: ref.referenceName,
+      sub: `${category} · ${by.name}`,
+      depth,
+      cut: null,
+      effect: { api: ref.referenceName, apis: [ref.referenceName], category, by: toNodeRef(by), line: ref.line },
+      root: null,
+    };
+    steps.set(id, record);
+    return record;
+  };
+
+  const link = (
+    from: StepRecord,
+    to: StepRecord,
+    kind: WireStepLinkKind,
+    chain: Node[],
+    whens: string[],
+    site: WireStepSite,
+    edge: Edge | null
+  ): void => {
+    const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
+    const synthesized = edge?.provenance === 'heuristic';
+    const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
+    const via = chain.map(toNodeRef);
+    const viaKey = via.map((v) => v.id).join('>');
+    const id = `${from.id} ${to.id} ${viaKey}`;
+    const when = whens.filter((w, i) => w && whens.indexOf(w) === i).join(' && ');
+    const existing = links.get(id);
+    if (existing) {
+      if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(site);
+      if (when !== existing.when) {
+        if (!when || !existing.when) existing.when = '';
+        else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
+      }
+      return;
+    }
+    links.set(id, {
+      id,
+      from: from.id,
+      to: to.id,
+      kind,
+      via,
+      when,
+      label: hopLabel(meta, synthesized),
+      synthesized,
+      uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
+      sites: [site],
+    });
+  };
+
+  // The anchor: a screen keeps its kind and explores from its component.
+  const first = stepFor(anchor, 'anchor', 0)!;
+  first.anchor = true;
+  const queue: StepRecord[] = [first];
+  /** Steps whose exploration has been queued — each is explored once, from the first row it appears on. */
+  const explored = new Set<string>([first.id]);
+
+  while (queue.length > 0) {
+    const step = queue.shift()!;
+    if (step.root === null) continue;
+    // Another screen is a chapter of its own: the Screens view draws the way
+    // between screens, and a picture that walked on through Home would be the
+    // whole app. Drawn as a boundary, entered on request.
+    if (step.kind === 'screen' && !step.anchor && !through) {
+      step.cut = 'screen';
+      continue;
+    }
+    // A native event that lands in a COMPONENT — the capture overlay taking
+    // `onCaptureProgress` — lands on another screen's body: its picture is
+    // that screen's, not this one's. A boundary too, entered on request.
+    if (step.kind === 'event' && !step.anchor && !through && looksLikeComponent(step.root)) {
+      step.cut = 'component';
+      continue;
+    }
+    if (step.depth >= depthCap) {
+      // Something to explore, and no room in the picture for it.
+      if (cg.getOutgoingEdgesFrom([step.root.id], WALK_KINDS).length > 0) step.cut = 'depth';
+      continue;
+    }
+
+    // Breadth-first through the plumbing until the next steps.
+    const visited = new Set<string>([step.root.id]);
+    let frontier: Fold[] = [{ node: step.root, chain: [], whens: [] }];
+    for (let hop = 0; hop <= MAX_FOLD_DEPTH && frontier.length > 0; hop++) {
+      const next: Fold[] = [];
+      const ids = frontier.map((f) => f.node.id);
+      const outgoing = cg.getOutgoingEdgesFrom(ids, WALK_KINDS);
+      const bySource = new Map<string, Edge[]>();
+      for (const e of outgoing) {
+        const list = bySource.get(e.source) ?? [];
+        list.push(e);
+        bySource.set(e.source, list);
+      }
+      // `const Memoized = memo(CaptureComponent)`: the wrapper is a component
+      // node with no edges of its own — the inner component is referenced
+      // from the FILE scope, at the wrapper's line. Lend the wrapper those
+      // references, so the screen that renders `<Memoized/>` walks on into
+      // what the component does.
+      for (const fold of frontier) {
+        if (fold.node.kind !== 'component' || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
+        for (const e of fileScopeFnRefsWithin(cg, fold.node, fileScopeRefs)) {
+          const list = bySource.get(fold.node.id) ?? [];
+          list.push({ ...e, source: fold.node.id });
+          bySource.set(fold.node.id, list);
+        }
+      }
+      const targetIds = new Set<string>();
+      for (const list of bySource.values()) for (const e of list) targetIds.add(e.target);
+      const targets = targetIds.size === 0 ? new Map<string, Node>() : cg.getNodesByIds([...targetIds]);
+      // Hubs and chrome are judged on the nodes about to be entered.
+      const unknownFanIn = [...targetIds].filter((id) => !fanIn.has(id));
+      if (unknownFanIn.length > 0) for (const [id, n] of cg.getFanIn(unknownFanIn)) fanIn.set(id, n);
+
+      for (const fold of frontier) {
+        // Effects made by this node, folded or not.
+        if (effectScans < MAX_EFFECT_SCANS) {
+          effectScans++;
+          let refs: UnresolvedReference[] = [];
+          try {
+            refs = cg.getUnresolvedReferencesFrom(fold.node.id);
+          } catch {
+            refs = [];
+          }
+          for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
+            if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
+            const category = effectCategory(ref.referenceName);
+            if (category === null) continue;
+            const target = effectStep(fold.node, ref, category, step.depth + 1);
+            if (target === null) continue;
+            const when = await whenAt(fold.node, { line: ref.line, column: ref.column });
+            link(step, target, 'effect', fold.chain, [...fold.whens, when], { file: posix(fold.node.filePath), line: ref.line, text: ref.referenceName }, null);
+          }
+        }
+
+        let edges = (bySource.get(fold.node.id) ?? []).slice();
+        edges = edges.filter((e) => {
+          const meta = (e.metadata ?? {}) as Record<string, unknown>;
+          if (e.kind === 'references') return meta.fnRef === true;
+          if (e.kind === 'contains') {
+            const t = targets.get(e.target);
+            return (fold.node.kind === 'function' || fold.node.kind === 'method') && !!t && (t.kind === 'function' || t.kind === 'method');
+          }
+          return true;
+        });
+        edges.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || a.target.localeCompare(b.target));
+        if (edges.length > MAX_FANOUT) {
+          step.cut = 'fan-out';
+          edges = edges.slice(0, MAX_FANOUT);
+        }
+
+        // Two passes: first every edge that arrives at a step, then the rest —
+        // so a node that IS a step (a handler wired to a tap) is never also
+        // folded as plumbing by the `contains` edge from the same component.
+        interface Arrival {
+          e: Edge;
+          target: Node;
+          meta: Record<string, unknown>;
+          site: WireStepSite;
+          kind: WireStepKind | null;
+          linkKind: WireStepLinkKind;
+          extra: Partial<WireStep>;
+        }
+        const arrivals: Arrival[] = [];
+        for (const e of edges) {
+          const target = targets.get(e.target);
+          if (!target || target.kind === 'file' || target.id === fold.node.id) continue;
+          const meta = (e.metadata ?? {}) as Record<string, unknown>;
+          const site: WireStepSite = {
+            file: posix(fold.node.filePath),
+            line: e.line ?? fold.node.startLine,
+            text: siteText(e, meta, target),
+          };
+
+          // What kind of step, if any, this edge arrives at.
+          let kind: WireStepKind | null = null;
+          let linkKind: WireStepLinkKind = 'calls';
+          const extra: Partial<WireStep> = {};
+          if (target.kind === 'route') {
+            kind = 'screen';
+            linkKind = 'navigates';
+          } else {
+            // A language change under the code is a step only on evidence: a
+            // bridge resolver's edge (`bridge`, or a framework resolution), or
+            // a synthesized channel's. A plain name-matched call across the
+            // families (`arr.flat()` landing on a Swift `flat`) is noise, and
+            // is neither drawn nor walked.
+            const cross = crossing(fold.node.language, target.language);
+            const evidenced = e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework';
+            if (cross !== null && !evidenced) continue;
+            if (cross === 'event') {
+              kind = 'event';
+              linkKind = 'event';
+              if (typeof meta.event === 'string') extra.event = meta.event;
+            } else if (cross === 'bridge') {
+              kind = 'bridge';
+              linkKind = 'bridge';
+            } else if (e.kind === 'references' && meta.fnRef === true && !looksLikeComponent(target)) {
+              // A function passed as a value is a handler — unless it is a
+              // component (`memo(CaptureComponent)`, `component={Home}`),
+              // which is a render hop and folds like one.
+              kind = 'trigger';
+              linkKind = 'handler';
+            } else if (
+              (target.kind === 'function' || target.kind === 'method') &&
+              isStoreFile(target.filePath) &&
+              !isStoreFile(fold.node.filePath)
+            ) {
+              kind = 'store';
+              linkKind = 'store';
+            }
+          }
+          arrivals.push({ e, target, meta, site, kind, linkKind, extra });
+        }
+
+        for (const a of arrivals) {
+          if (a.kind === null) continue;
+          const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
+          if (to === null) continue;
+          const when = await whenAt(fold.node, { line: a.e.line, column: a.e.column });
+          link(step, to, a.linkKind, fold.chain, [...fold.whens, when], a.site, a.e);
+          if (to.root !== null && !explored.has(to.id)) {
+            explored.add(to.id);
+            queue.push(to);
+          }
+        }
+
+        for (const a of arrivals) {
+          if (a.kind !== null) continue;
+          const { e, target, meta } = a;
+
+          // A call through a VALUE the effect table knows — `client.post` on
+          // the axios instance the project made itself resolves to the
+          // `client` constant, not to anything outside the index. The call
+          // text is the evidence: the call is the effect, the constant is not
+          // a place to walk into.
+          if (e.kind === 'calls' && (target.kind === 'constant' || target.kind === 'variable')) {
+            const api = typeof meta.refName === 'string' ? meta.refName : null;
+            const category = api === null ? null : effectCategory(api);
+            if (api !== null && category !== null) {
+              const to = effectStep(fold.node, { referenceName: api, line: e.line ?? fold.node.startLine }, category, step.depth + 1);
+              if (to === null) continue;
+              const when = await whenAt(fold.node, { line: e.line, column: e.column });
+              link(step, to, 'effect', fold.chain, [...fold.whens, when], { file: posix(fold.node.filePath), line: e.line ?? fold.node.startLine, text: api }, null);
+              continue;
+            }
+          }
+
+          // Already a step, reached here by a plain call: a link, not a fold.
+          const known = steps.get(target.id);
+          if (known) {
+            if (known.id !== step.id) {
+              const when = await whenAt(fold.node, { line: e.line, column: e.column });
+              link(step, known, 'calls', fold.chain, [...fold.whens, when], a.site, e);
+            }
+            continue;
+          }
+
+          // Plumbing: fold it and keep walking, unless it is a dead end.
+          if (visited.has(target.id)) continue;
+          if ((fanIn.get(target.id) ?? 0) >= HUB_THRESHOLD) {
+            truncated.hubs++;
+            continue;
+          }
+          if (meta.synthesizedBy === 'jsx-render' && isSharedChrome(cg, target, chromeParents)) {
+            truncated.chrome++;
+            continue;
+          }
+          if (visited.size >= MAX_FOLDED_PER_STEP) {
+            step.cut = step.cut ?? 'folded';
+            continue;
+          }
+          visited.add(target.id);
+          const when = await whenAt(fold.node, { line: e.line, column: e.column });
+          next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, when] });
+        }
+      }
+      frontier = next;
+    }
+  }
+
+  const ordered = [...steps.values()].sort((a, b) => a.depth - b.depth || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
+  return {
+    anchor: toNodeRef(anchor),
+    ambiguous,
+    steps: ordered.map(({ root: _root, ...step }) => step),
+    links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
+    depth: depthCap,
+    limit,
+    through,
+    truncated,
+    index,
+    timing: { elapsedMs: Date.now() - started },
+  };
+}
+
+// =============================================================================
+// Helpers
+// =============================================================================
+
+/**
+ * The anchor: `anchor=<id>`, or `symbol=<name>` resolved to the most
+ * screen-like symbol of that name — a route first, then a component or
+ * function, then a method — with the rest reported as `ambiguous`.
+ */
+function resolveAnchor(cg: CodeGraph, query: URLSearchParams): { anchor: Node; ambiguous: WireNodeRef[] } {
+  const id = query.get('anchor');
+  if (id !== null && id.trim() !== '') {
+    const node = cg.getNode(id);
+    if (!node) throw notFound(`No symbol with id "${id}" in this index.`, 'It may have moved in a re-index; open it from search or the Screens view.');
+    return { anchor: node, ambiguous: [] };
+  }
+  const name = query.get('symbol');
+  if (name === null || name.trim() === '') throw badRequest('Give the picture an anchor: ?anchor=<node id> or ?symbol=<name>.');
+  const rank: Record<string, number> = { route: 0, component: 1, function: 2, method: 3, class: 4, constant: 5, variable: 6 };
+  const matches = cg
+    .getNodesByName(name.trim())
+    .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
+    .sort((a, b) => (rank[a.kind] ?? 9) - (rank[b.kind] ?? 9) || a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
+  const anchor = matches[0];
+  if (!anchor) throw notFound(`Nothing in this index is named "${name}".`, 'Try the search box; names are matched exactly.');
+  return { anchor, ambiguous: matches.slice(1, 9).map(toNodeRef) };
+}
+
+/** How many distinct parents render this node as a JSX child. Memoised per request. */
+function renderParents(cg: CodeGraph, node: Node, memo: Map<string, number>): number {
+  let parents = memo.get(node.id);
+  if (parents === undefined) {
+    const incoming = cg.getIncomingEdgesTo([node.id], ['calls']);
+    const sources = new Set<string>();
+    for (const e of incoming) {
+      if ((e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'jsx-render') sources.add(e.source);
+    }
+    parents = sources.size;
+    memo.set(node.id, parents);
+  }
+  return parents;
+}
+
+/** A component rendered by several distinct parents is chrome. */
+function isSharedChrome(cg: CodeGraph, component: Node, memo: Map<string, number>): boolean {
+  return renderParents(cg, component, memo) >= SHARED_CHROME_MIN;
+}
+
+/** A React component, by the convention that names one: a PascalCase function in a JS-family file. */
+function looksLikeComponent(node: Node): boolean {
+  if (node.kind === 'component') return true;
+  if (node.kind !== 'function') return false;
+  return JS_FAMILY.has(node.language) && /^[A-Z]/.test(node.name);
+}
+
+/**
+ * Function-as-value references made at a file's top level within a node's
+ * lines — what `const Memoized = memo(CaptureComponent)` leaves behind: the
+ * reference belongs to the file scope, the wrapper node spans the line.
+ */
+function fileScopeFnRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>): Edge[] {
+  let refs = memo.get(node.filePath);
+  if (refs === undefined) {
+    const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
+    refs = file
+      ? cg.getOutgoingEdgesFrom([file.id], ['references']).filter((e) => (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
+      : [];
+    memo.set(node.filePath, refs);
+  }
+  return refs.filter((e) => typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
+}
+
+/** `push /capture`, `renders <Button>`, `via rn-event-channel`, `calls`. */
+function siteText(edge: Edge, meta: Record<string, unknown>, target: Node): string {
+  if (edge.kind === 'navigates') {
+    const method = edge.provenance === 'heuristic' ? 'returns' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push';
+    return `${method} ${typeof meta.href === 'string' ? meta.href : target.name}`;
+  }
+  if (meta.synthesizedBy === 'jsx-render') return `renders <${target.name}>`;
+  if (edge.kind === 'references') return `passes ${target.name}`;
+  if (edge.kind === 'contains') return `defines ${target.name}`;
+  if (edge.kind === 'instantiates') return `new ${target.name}`;
+  if (meta.bridge === 'react-native') return `bridge ${typeof meta.module === 'string' ? meta.module + '.' : ''}${target.name}`;
+  if (typeof meta.synthesizedBy === 'string') return `via ${meta.synthesizedBy}`;
+  return `calls ${target.name}`;
+}
+
+/** The words on a hop that was not a plain call — the Flow strip's connector label, in short. */
+function hopLabel(meta: Record<string, unknown>, synthesized: boolean): string {
+  const parts: string[] = [];
+  if (typeof meta.synthesizedBy === 'string') parts.push(`via ${meta.synthesizedBy}`);
+  else if (synthesized) parts.push('inferred');
+  if (typeof meta.event === 'string') parts.push(`event ${meta.event}`);
+  if (meta.bridge === 'react-native') parts.push(`React Native bridge${typeof meta.module === 'string' ? ` · ${meta.module}` : ''}`);
+  if (typeof meta.registeredAt === 'string') parts.push(`registered at ${meta.registeredAt}`);
+  return parts.join(' · ');
+}
+
+function posix(p: string): string {
+  return p.replace(/\\/g, '/');
+}

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

@@ -73,3 +73,41 @@ export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches:
     }
   }
 }
+
+/**
+ * A per-request reader of the conditions ONE call site sits under, for the
+ * endpoints that walk chains rather than annotate rails (the Screens view's
+ * transitions, the Steps view's links). Files are resolved once, drifted
+ * files yield no label, and the count of sites labelled is bounded so a wide
+ * walk cannot turn one request into a parse of the repository.
+ */
+export function createWhenReader(
+  cg: CodeGraph,
+  projectRoot: string,
+  maxSites = 600
+): (caller: { filePath: string; language: Language }, site: { line?: number; column?: number }) => Promise<string> {
+  const files = new Map<string, { abs: string; language: Language } | null>();
+  let sites = 0;
+  return async (caller, site): Promise<string> => {
+    if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
+    const posix = caller.filePath.replace(/\\/g, '/');
+    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 key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
+    const g = (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key));
+    return g ? guardLabel(g) : '';
+  };
+}

+ 3 - 0
ui/src/App.svelte

@@ -8,6 +8,7 @@
   import FileCodeView from './views/FileCodeView.svelte';
   import MapView from './views/MapView.svelte';
   import ScreensView from './views/ScreensView.svelte';
+  import StepsView from './views/StepsView.svelte';
   import FlowView from './views/FlowView.svelte';
   import EntryView from './views/EntryView.svelte';
   import DeadCodeView from './views/DeadCodeView.svelte';
@@ -175,6 +176,8 @@
     <EntryView project={project.name} />
   {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
     <ScreensView />
+  {:else if route.view === 'steps'}
+    <StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} />
   {:else if route.view === 'dead'}
     <DeadCodeView exported={route.exported} />
   {:else if route.view === 'unknown'}

+ 2 - 1
ui/src/components/TopBar.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { router, mapHref, flowHref, entryHref, screensHref, deadHref, symbolHref } from '../lib/router.svelte';
+  import { router, mapHref, flowHref, entryHref, screensHref, stepsHref, deadHref, symbolHref } from '../lib/router.svelte';
   import { trail } from '../lib/trail.svelte';
   import SearchPalette from './SearchPalette.svelte';
   import { live } from '../lib/live.svelte';
@@ -68,6 +68,7 @@
 
   <nav class="views" aria-label="Views">
     {#if showScreens}<a href={screensHref()} class:active={view === 'screens' || view === 'home'}>Screens</a>{/if}
+    <a href={stepsHref()} class:active={view === 'steps'}>Steps</a>
     <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' && !showScreens)}>Symbol</a>

+ 3 - 2
ui/src/components/screens/ScreenEdge.svelte

@@ -22,14 +22,15 @@
    */
   import { BaseEdge, EdgeLabel, type EdgeProps } from '@xyflow/svelte';
   import type { MapEdgeLayout } from '../../lib/map-model';
-  import { pathOf, type Curve, type PillPlacement, type ScreenEdgeInfo } from '../../lib/screens-model';
+  import { pathOf, type Curve, type PillPlacement } from '../../lib/screens-model';
 
   let { data }: EdgeProps = $props();
 
   const d = $derived(
     data as unknown as {
       edge: MapEdgeLayout;
-      info: ScreenEdgeInfo;
+      /** The Screens view's edge info, or the Steps view's — only `synthesized` is read. */
+      info: { synthesized: boolean };
       curve: Curve;
       /** One of the selected screen's, or under the pointer. */
       hot: boolean;

+ 184 - 0
ui/src/components/steps/StepNode.svelte

@@ -0,0 +1,184 @@
+<script lang="ts">
+  /**
+   * One step on the Steps view. The box is the Screens view's screen box with
+   * a kind: a screen is drawn exactly as there; a handler is a plain box; a
+   * native call or a native event carries an accent rule on its left, where
+   * the language changes under the code; a store action sits on `--paper-2`;
+   * a call that leaves the index is dashed, like a trigger no screen reaches
+   * on the Screens view — a place the graph cannot follow into. The anchor
+   * carries the entry mark. A step the walk was cut at ends its name with an
+   * ellipsis, and its tooltip says which cap.
+   *
+   * Hidden handles along the top and bottom, one per port the layout decided
+   * (`directional` ports), exactly as the screen box.
+   */
+  import { Handle, Position, type NodeProps } from '@xyflow/svelte';
+  import type { MapNodeLayout } from '../../lib/map-model';
+  import { kindWord, type StepNodeInfo } from '../../lib/steps-model';
+
+  let { data }: NodeProps = $props();
+
+  const node = $derived(
+    data as unknown as {
+      layout: MapNodeLayout;
+      info: StepNodeInfo;
+      selected: boolean;
+      dimmed: boolean;
+      onSelect: (id: string) => void;
+    }
+  );
+  const layout = $derived(node.layout);
+  const info = $derived(node.info);
+  const step = $derived(info.step);
+
+  const cutNote = $derived.by(() => {
+    switch (step.cut) {
+      case 'depth':
+        return ' More happens past the depth of this picture — start here to see it.';
+      case 'fan-out':
+        return ' It reaches more than the walk follows from one node.';
+      case 'folded':
+        return ' The walk folded as much plumbing as it allows from one step.';
+      case 'steps':
+        return ' The picture reached its size limit here.';
+      case 'screen':
+        return ' Another screen — a chapter of its own. Start here to see what happens on it.';
+      case 'component':
+        return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
+      default:
+        return '';
+    }
+  });
+
+  function portStyle(index: number, total: number): string {
+    return `left:${((index + 1) / (total + 1)) * 100}%`;
+  }
+</script>
+
+{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
+  <Handle
+    type={port.type}
+    id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
+    position={Position.Top}
+    style={portStyle(i, layout.ports.top.length)}
+    isConnectable={false}
+  />
+{/each}
+
+<button
+  class={`snode k-${step.kind}`}
+  class:sel={node.selected}
+  class:dimmed={node.dimmed}
+  class:anchor={step.anchor}
+  style={`width:${layout.width}px;height:${layout.height}px`}
+  onclick={() => node.onSelect(info.id)}
+  aria-pressed={node.selected}
+  title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind)}. ${info.sub}.${cutNote}`}
+>
+  <span class="name"
+    >{#if step.anchor}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}{#if step.cut !== null}<span
+        class="more"
+        aria-hidden="true"> …</span
+      >{/if}</span
+  >
+  <span class="sub">{info.sub}</span>
+</button>
+
+{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
+  <Handle
+    type={port.type}
+    id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
+    position={Position.Bottom}
+    style={portStyle(i, layout.ports.bottom.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);
+  }
+  /* The language changes under the code: a rule where it does. */
+  .snode.k-bridge,
+  .snode.k-event {
+    border-left: 3px solid var(--accent);
+    padding-left: 7px;
+  }
+  .snode.k-bridge:hover,
+  .snode.k-bridge.sel,
+  .snode.k-event:hover,
+  .snode.k-event.sel {
+    border-left-width: 3px;
+    padding-left: 7px;
+  }
+  .snode.k-bridge.dimmed,
+  .snode.k-event.dimmed {
+    border-left-color: var(--accent-line);
+  }
+  .snode.k-store {
+    background: var(--paper-2);
+  }
+  .snode.k-store:hover,
+  .snode.k-store.sel {
+    background: var(--press);
+  }
+  /* Outside the index: a place the graph cannot follow into. */
+  .snode.k-effect {
+    border-style: dashed;
+    border-color: var(--ink-3);
+  }
+  .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;
+  }
+  .more {
+    color: var(--ink-3);
+  }
+  .sub {
+    font: 400 11px var(--sans);
+    line-height: 13px;
+    color: var(--ink-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+</style>

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

@@ -36,6 +36,7 @@ import type {
   WireFlowPayload,
   WireMapPayload,
   WireScreensPayload,
+  WireStepsPayload,
   WireNodeRefs,
   WireRoutes,
   WireSearch,
@@ -178,6 +179,16 @@ export interface LiveHandlers {
   error(): void;
 }
 
+/** What happens from an anchor: by id, or by name (the first screen-like match). */
+export interface StepsRequest {
+  anchor?: string;
+  symbol?: string;
+  depth?: number;
+  limit?: number;
+  /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
+  through?: boolean;
+}
+
 /* -------------------------------------------------------------- adapter -- */
 
 /**
@@ -213,6 +224,11 @@ export interface GraphAdapter {
   map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
   /** The app's screens and the transitions between them, with their conditions. */
   screens(signal?: AbortSignal): Promise<WireScreensPayload>;
+  /**
+   * What happens from a screen or a symbol, as typed steps. Optional: a host
+   * that has not wired it renders the Steps view as absent-and-explained.
+   */
+  steps?(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload>;
   /** The URL → handler map. */
   routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
   /** Where a reader starts: routes, files that run something, tests, hubs. */
@@ -400,6 +416,16 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
       return getJson<WireScreensPayload>('api/screens', signal);
     },
 
+    steps(request = {}, signal) {
+      const params = new URLSearchParams();
+      if (request.anchor) params.set('anchor', request.anchor);
+      else if (request.symbol) params.set('symbol', request.symbol);
+      if (request.depth) params.set('depth', String(request.depth));
+      if (request.limit) params.set('limit', String(request.limit));
+      if (request.through) params.set('through', '1');
+      return getJson<WireStepsPayload>(`api/steps${query(params)}`, signal);
+    },
+
     entryPoints(request = {}, signal) {
       const params = new URLSearchParams();
       if (request.limit) params.set('limit', String(request.limit));

+ 21 - 1
ui/src/lib/api.ts

@@ -20,6 +20,7 @@ import type {
   WireFlowPayload,
   WireMapPayload,
   WireScreensPayload,
+  WireStepsPayload,
   WireNodeRefs,
   WireRoutes,
   WireSearch,
@@ -28,7 +29,7 @@ import type {
   WireSymbolPayload,
   WireTrails,
 } from './wire';
-import type { SaveTrailRequest } from './adapter';
+import type { SaveTrailRequest, StepsRequest } from './adapter';
 
 export * from './wire';
 export { ApiFailure } from './adapter';
@@ -44,6 +45,7 @@ export type {
   SaveTrailRequest,
   SearchRequest,
   SourceRequest,
+  StepsRequest,
 } from './adapter';
 
 export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
@@ -146,6 +148,24 @@ export function fetchScreens(signal?: AbortSignal): Promise<WireScreensPayload>
   return getGraphAdapter().screens(signal);
 }
 
+/**
+ * What happens from an anchor — a screen, a handler, any symbol — as typed
+ * steps with the conditions between them. Refused, not thrown at random, by
+ * an adapter that never offered it (see {@link canDrawSteps}).
+ */
+export function fetchSteps(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload> {
+  const adapter = getGraphAdapter();
+  if (typeof adapter.steps !== 'function') {
+    return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot draw steps.', null));
+  }
+  return adapter.steps(request, signal);
+}
+
+/** Whether the installed adapter can answer {@link fetchSteps} at all. */
+export function canDrawSteps(): boolean {
+  return typeof getGraphAdapter().steps === 'function';
+}
+
 export function fetchMap(
   opts: { root?: string | null; depth?: number } = {},
   signal?: AbortSignal

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

@@ -55,6 +55,16 @@ export interface FlowHrefOptions {
   trail?: string;
 }
 
+export interface StepsHrefOptions {
+  /** A node id — a screen's route, a handler, any symbol. */
+  anchor?: string;
+  /** A name, when no id is at hand; the answering side picks the most screen-like match. */
+  symbol?: string;
+  depth?: number;
+  /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
+  through?: boolean;
+}
+
 /**
  * Where the components send the reader.
  *
@@ -69,6 +79,7 @@ export interface NavigationDriver {
   flowHref(opts?: FlowHrefOptions): string;
   entryHref(): string;
   screensHref(): string;
+  stepsHref(opts?: StepsHrefOptions): string;
   deadHref(opts?: DeadCodeHrefOptions): string;
   /** Go to an href this driver built. */
   navigate(href: string, opts?: { replace?: boolean }): void;
@@ -138,6 +149,15 @@ export const hashNavigation: NavigationDriver = {
     return '#/screens';
   },
 
+  stepsHref(opts = {}) {
+    const params = new URLSearchParams();
+    if (opts.anchor) params.set('anchor', opts.anchor);
+    else if (opts.symbol) params.set('symbol', opts.symbol);
+    if (opts.depth) params.set('depth', String(opts.depth));
+    if (opts.through) params.set('through', '1');
+    return `#/steps${query(params)}`;
+  },
+
   deadHref(opts = {}) {
     const params = new URLSearchParams();
     if (opts.exported) params.set('exported', '1');
@@ -221,6 +241,10 @@ export function screensHref(): string {
   return driver.screensHref();
 }
 
+export function stepsHref(opts: StepsHrefOptions = {}): string {
+  return driver.stepsHref(opts);
+}
+
 export function deadHref(opts: DeadCodeHrefOptions = {}): string {
   return driver.deadHref(opts);
 }

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

@@ -12,6 +12,7 @@
  *   #/flow                 flow strip       (?from=&to= | ?symbols= | ?t=<trail>)
  *   #/entry                entry points     (where a flow starts)
  *   #/screens              screens          (the app's screens and transitions)
+ *   #/steps                steps            (?anchor=<id> | ?symbol=<name>: what happens from there)
  *   #/dead                 dead code        (?exported=1 widens the claim)
  *
  * Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -43,6 +44,7 @@ export {
   navigate,
   screensHref,
   setNavigationDriver,
+  stepsHref,
   symbolHref,
 } from './navigation';
 export type {
@@ -51,6 +53,7 @@ export type {
   FlowHrefOptions,
   MapHrefOptions,
   NavigationDriver,
+  StepsHrefOptions,
   SymbolHrefOptions,
 } from './navigation';
 
@@ -77,6 +80,14 @@ export type Route =
     }
   | { view: 'entry' }
   | { view: 'screens' }
+  | {
+      view: 'steps';
+      /** The anchor by id; null with `symbol` set, or on the bare tab. */
+      anchor: string | null;
+      symbol: string | null;
+      depth: number | null;
+      through: boolean;
+    }
   | {
       view: 'dead';
       /** Symbols reachable from outside the index are on the list. */
@@ -141,6 +152,17 @@ export function parseHash(hash: string): RouterLocation {
     route = { view: 'entry' };
   } else if (head === 'screens' && rest.length === 0) {
     route = { view: 'screens' };
+  } else if (head === 'steps' && rest.length === 0) {
+    // The anchor travels in the URL, so "what happens on the review screen"
+    // is a link that reopens as the same picture.
+    const depth = Number.parseInt(params.get('depth') ?? '', 10);
+    route = {
+      view: 'steps',
+      anchor: params.get('anchor'),
+      symbol: params.get('symbol'),
+      depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null,
+      through: params.get('through') === '1',
+    };
   } 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.

+ 19 - 6
ui/src/lib/screens-model.ts

@@ -123,6 +123,19 @@ export interface Point {
   y: number;
 }
 
+/**
+ * What the label placement and the pointer need from a picture: the Screens
+ * view's model, or any other drawn with its machinery (the Steps view draws
+ * typed steps with the same layout, curves, pills and hit-testing).
+ */
+export interface Picture {
+  layout: MapLayout;
+  layerGap: number;
+  edges: Map<string, { label: string }>;
+  curves: Map<string, Curve>;
+  polylines: Map<string, Point[]>;
+}
+
 /* ------------------------------------------------------------- layering -- */
 
 /**
@@ -286,7 +299,7 @@ export function clauses(when: string): string[] {
  * …` on both arms of a fork); the last clause is the one that tells the two
  * apart, and the full text is a hover away.
  */
-export function edgeLabel(links: readonly WireScreenLink[]): string {
+export function edgeLabel(links: ReadonlyArray<{ when: string }>): string {
   if (links.length === 1) {
     const when = links[0]!.when;
     if (!when) return '';
@@ -630,7 +643,7 @@ export interface EdgeHit {
  * the smaller id, so two visits agree.
  */
 export function nearestEdge(
-  model: ScreensModel,
+  model: Picture,
   point: Point,
   among: ReadonlySet<string> | null,
   reach: number
@@ -713,7 +726,7 @@ export function laneCount(layerGap: number): number {
  * the selected screen — `→` leaving it, `←` arriving — and the edge's label.
  * Empty when the edge has nothing to say (a single, unconditional transition).
  */
-export function pillText(info: ScreenEdgeInfo, edge: MapEdgeLayout, selected: string | null): string {
+export function pillText(info: { label: string }, edge: MapEdgeLayout, selected: string | null): string {
   if (!info.label) return '';
   const arriving = selected !== null && edge.target === selected && edge.source !== selected;
   return `${arriving ? '←' : '→'} ${info.label}`;
@@ -737,7 +750,7 @@ function intersects(a: Rect, b: Rect, gapX: number): boolean {
  * lane is free — or when `lanes` is 1 and that lane is taken.
  */
 function layPill(
-  model: ScreensModel,
+  model: Picture,
   edge: MapEdgeLayout,
   end: 'source' | 'target',
   text: string,
@@ -781,7 +794,7 @@ function layPill(
  * pill: the pill for a hovered edge that is not the selected screen's is
  * placed separately by {@link hoverPill}.
  */
-export function placeLabels(model: ScreensModel, selected: string | null): PillLayout {
+export function placeLabels(model: Picture, selected: string | null): PillLayout {
   const pills = new Map<string, PillPlacement>();
   if (selected === null) return { pills, hidden: 0 };
   const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
@@ -827,7 +840,7 @@ export function placeLabels(model: ScreensModel, selected: string | null): PillL
  * with the whole condition, not the connector's short label.
  */
 export function hoverPill(
-  model: ScreensModel,
+  model: Picture,
   edgeId: string,
   selected: string | null,
   text?: string,

+ 238 - 0
ui/src/lib/steps-model.ts

@@ -0,0 +1,238 @@
+/**
+ * The Steps view's model — what happens from an anchor, as typed steps laid
+ * out so that a step sits above the steps it sets in motion.
+ *
+ * Everything geometric is the Screens view's (`screens-model.ts`): the Map's
+ * layout with directional ports, a curve per edge on a track of its own, the
+ * pills that label a selected step's links at the far end of each line, and
+ * the nearest-line pointer. What is this file's own is small: the row a step
+ * sits on is its distance from the anchor, which the server already counted
+ * (`WireStep.depth`), so the layering is a lookup rather than a search; the
+ * words in a box come from the step's kind; and the side panel's two lists
+ * are the links into and out of the selected step.
+ */
+
+import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepsPayload } from './wire';
+import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
+import {
+  edgeLabel,
+  samplePolyline,
+  trackedCurves,
+  SCREEN_LAYER_GAP,
+  type Curve,
+  type Picture,
+  type Point,
+} from './screens-model';
+
+export interface StepNodeInfo {
+  id: string;
+  step: WireStep;
+  /** What the box prints on its first line. */
+  label: string;
+  /** …and on its second. */
+  sub: string;
+}
+
+export interface StepEdgeInfo {
+  id: string;
+  from: string;
+  to: string;
+  /** Every link between the pair — one connector, several stories. */
+  links: WireStepLink[];
+  /** The connector's short label: the innermost condition, or how many links. */
+  label: string;
+  /** Every link behind it was synthesized (a dynamic-dispatch bridge). */
+  synthesized: boolean;
+  /** The kind the links agree on, or `calls` when they differ. */
+  kind: WireStepLink['kind'];
+}
+
+export interface StepsModel extends Picture {
+  layout: MapLayout;
+  nodes: Map<string, StepNodeInfo>;
+  edges: Map<string, StepEdgeInfo>;
+  layerGap: number;
+  curves: Map<string, Curve>;
+  polylines: Map<string, Point[]>;
+  /** Steps per kind, for the panel's summary. */
+  counts: Record<WireStep['kind'], number>;
+}
+
+/** Points a curve is sampled at for hit-testing (as the Screens view's). */
+const HIT_SAMPLES = 24;
+
+/* ---------------------------------------------------------------- words -- */
+
+/** A short word for a step's kind, as the panel and the legend say it. */
+export function kindWord(kind: WireStep['kind']): string {
+  switch (kind) {
+    case 'screen':
+      return 'screen';
+    case 'trigger':
+      return 'handler';
+    case 'bridge':
+      return 'native call';
+    case 'event':
+      return 'native event';
+    case 'store':
+      return 'store action';
+    case 'effect':
+      return 'outside the index';
+    default:
+      return 'start';
+  }
+}
+
+/** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
+export function stepLabel(step: WireStep): string {
+  switch (step.kind) {
+    case 'bridge':
+      return `⇢ ${step.label}`;
+    case 'event': {
+      const events = step.events ?? (step.event ? [step.event] : []);
+      if (events.length === 0) return `⇠ ${step.label}`;
+      return events.length === 1 ? `⇠ ${events[0]}` : `⇠ ${events[0]} +${events.length - 1}`;
+    }
+    default:
+      return step.label;
+  }
+}
+
+/** The second line: what the step is, then where it is. */
+export function stepSub(step: WireStep): string {
+  const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
+  switch (step.kind) {
+    case 'screen':
+      return step.sub;
+    case 'trigger':
+      return `handler · ${file}`;
+    case 'bridge':
+      return `native · ${file}`;
+    case 'event':
+      return `${step.label} · ${file}`;
+    case 'store':
+      return `store · ${file}`;
+    case 'effect':
+      return step.sub;
+    default:
+      return step.sub;
+  }
+}
+
+/* ---------------------------------------------------------------- build -- */
+
+export function buildStepsModel(payload: WireStepsPayload): StepsModel {
+  const nodes = new Map<string, StepNodeInfo>();
+  const modules: WireMapModule[] = [];
+  const counts: Record<WireStep['kind'], number> = {
+    anchor: 0,
+    screen: 0,
+    trigger: 0,
+    bridge: 0,
+    event: 0,
+    store: 0,
+    effect: 0,
+  };
+  const degree = new Map<string, number>();
+  for (const link of payload.links) {
+    degree.set(link.from, (degree.get(link.from) ?? 0) + 1);
+    degree.set(link.to, (degree.get(link.to) ?? 0) + 1);
+  }
+  for (const step of payload.steps) {
+    counts[step.kind]++;
+    const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step) };
+    nodes.set(step.id, info);
+    modules.push({
+      id: step.id,
+      label: info.label,
+      files: 1,
+      symbols: degree.get(step.id) ?? 0,
+      languages: [],
+      test: false,
+      generated: 0,
+      generatedFiles: [],
+      facade: false,
+      fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
+    });
+  }
+
+  // One layout link per (from, to); the links behind it stay listed.
+  const byPair = new Map<string, WireStepLink[]>();
+  for (const link of payload.links) {
+    if (!nodes.has(link.from) || !nodes.has(link.to) || link.from === link.to) continue;
+    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, StepEdgeInfo>();
+  for (const [key, group] of byPair) {
+    const first = group[0]!;
+    links.push({
+      source: first.from,
+      target: first.to,
+      count: group.length,
+      declared: group.length,
+      byKind: [{ kind: 'calls', 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),
+      kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
+    });
+  }
+
+  // Layer = distance from the anchor, counted by the server. Layer 0 is the
+  // bottom, so the deepest row is 0 and the anchor is on top.
+  const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
+  const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
+  const layering = (ids: string[]): Map<string, number> =>
+    new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
+
+  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,
+      layerGap: SCREEN_LAYER_GAP,
+      portPitch: PORT_PITCH,
+      ports: 'directional',
+    }
+  );
+  const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
+  const polylines = new Map<string, Point[]>();
+  for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
+  return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
+}
+
+/** The side panel's two lists for a selected step. */
+export function stepNeighbourhood(
+  payload: WireStepsPayload,
+  id: string
+): { arrivesFrom: WireStepLink[]; leadsTo: WireStepLink[] } {
+  return {
+    arrivesFrom: payload.links.filter((l) => l.to === id),
+    leadsTo: payload.links.filter((l) => l.from === id),
+  };
+}
+
+/** `useReviewHandlers → handleApproveAllImages`, or '' when nothing was folded. */
+export function stepViaText(link: WireStepLink): string {
+  return link.via.map((v) => v.name).join(' → ');
+}
+
+/** The layout edge a link draws as, or null when it is a self-loop. */
+export function stepPairId(link: WireStepLink): string | null {
+  return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
+}

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

@@ -694,6 +694,76 @@ export interface WireScreensPayload {
   timing: { elapsedMs: number };
 }
 
+/* ------------------------------------------------------------------ steps -- */
+
+export type WireStepKind = 'anchor' | 'screen' | 'trigger' | 'bridge' | 'event' | 'store' | 'effect';
+
+export type WireStepLinkKind = 'calls' | 'navigates' | 'handler' | 'bridge' | 'event' | 'store' | 'effect';
+
+export interface WireStepSite {
+  file: string;
+  line: number;
+  /** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
+  text: string;
+}
+
+export interface WireStep {
+  /** The node's id, or `effect:<function id>:<api>` for a call leaving the index. */
+  id: string;
+  kind: WireStepKind;
+  /** The step the picture starts from. A screen anchor keeps `kind: 'screen'`. */
+  anchor: boolean;
+  /** Null only for an effect, which is a call site rather than a symbol. */
+  node: WireNodeRef | null;
+  label: string;
+  sub: string;
+  /** Steps from the anchor: the row. */
+  depth: number;
+  /**
+   * Why the walk did not go on from this step: a cap (`depth`, `fan-out`,
+   * `folded`, `steps`), or `screen` — another screen, drawn as a boundary.
+   */
+  cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
+  /** The event name a native event step arrived on — the first, when several land here. */
+  event?: string;
+  /** Every event that lands on this step. */
+  events?: string[];
+  screen?: { path: string; component: WireNodeRef | null };
+  /** The calls one function makes into one category, and the function. */
+  effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
+}
+
+export interface WireStepLink {
+  id: string;
+  from: string;
+  to: string;
+  kind: WireStepLinkKind;
+  /** The symbols folded between the two steps, in order. */
+  via: WireNodeRef[];
+  /** Conditions along the whole chain, joined; '' when unconditional. */
+  when: string;
+  /** How the last hop was established when it was not a plain call. */
+  label: string;
+  synthesized: boolean;
+  uncertain: boolean;
+  sites: WireStepSite[];
+}
+
+export interface WireStepsPayload {
+  anchor: WireNodeRef;
+  /** Other symbols that share the anchor's name, when it was given by name. */
+  ambiguous: WireNodeRef[];
+  steps: WireStep[];
+  links: WireStepLink[];
+  depth: number;
+  limit: number;
+  /** Screens reached from the anchor were entered rather than drawn as boundaries. */
+  through: boolean;
+  truncated: { steps: number; hubs: number; chrome: number };
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number };
+}
+
 /* -------------------------------------------------------------- dead code -- */
 
 /** One symbol nothing in the index reaches. */

+ 5 - 1
ui/src/views/ScreensView.svelte

@@ -24,7 +24,7 @@
   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 { symbolHref, fileHref, stepsHref } from '../lib/navigation';
   import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
   import {
     buildScreensModel,
@@ -392,6 +392,7 @@
             {#if selectedInfo.screen}
               <a class="sub dim" href={fileHref(selectedInfo.screen.file)}>{selectedInfo.screen.file}</a>
             {/if}
+            <a class="sub act" href={stepsHref({ anchor: selectedInfo.id })}>What happens here →</a>
           </div>
           <button class="clear" onclick={() => (selected = null)}>clear</button>
         </div>
@@ -664,6 +665,9 @@
   .sub:hover {
     text-decoration: underline;
   }
+  .act {
+    color: var(--accent);
+  }
   .clear {
     border: 1px solid var(--rule);
     background: transparent;

+ 928 - 0
ui/src/views/StepsView.svelte

@@ -0,0 +1,928 @@
+<!--
+  The Steps view (`#/steps?anchor=…`): what happens from here. One box per
+  step — a screen, a handler, a call into native code, a native event landing
+  back in JS, a store action, a call that leaves the index — an arrow for
+  every way one leads to the next, and on each arrow the condition under
+  which it happens, with the plumbing between two steps folded into the arrow
+  and listed in the panel.
+
+  Everything drawn comes from `/api/steps`: the anchor's forward walk through
+  calls, renders, handler bindings and navigations, classified as it goes, and
+  branch guards read from the source. The canvas is the Screens view's
+  machinery with a different node universe (see `steps-model.ts`); the side
+  panel is where the sentences are, and where a step becomes the next anchor
+  or a Flow strip between two steps.
+-->
+<script lang="ts">
+  import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
+  import '@xyflow/svelte/dist/style.css';
+  import StepNode from '../components/steps/StepNode.svelte';
+  import ScreenEdge from '../components/screens/ScreenEdge.svelte';
+  import KindGlyph from '../components/KindGlyph.svelte';
+  import {
+    canDrawSteps,
+    fetchScreens,
+    fetchSteps,
+    type WireScreen,
+    type WireStepLink,
+    type WireStepsPayload,
+  } from '../lib/api';
+  import { live } from '../lib/live.svelte';
+  import { fileHref, flowHref, navigate, stepsHref, symbolHref } from '../lib/navigation';
+  import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
+  import { hoverPill, nearestEdge, placeLabels } from '../lib/screens-model';
+  import {
+    buildStepsModel,
+    kindWord,
+    stepNeighbourhood,
+    stepPairId,
+    stepViaText,
+    type StepsModel,
+  } from '../lib/steps-model';
+
+  interface Props {
+    anchor: string | null;
+    symbol: string | null;
+    depth: number | null;
+    /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
+    through: boolean;
+  }
+  let { anchor, symbol, depth, through }: Props = $props();
+
+  let payload = $state<WireStepsPayload | 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);
+  /** The panel row under the pointer: its edge on the canvas, and the one link it names. */
+  let panelHot = $state<{ edge: string; link: WireStepLink } | null>(null);
+  let stage = $state<HTMLDivElement | null>(null);
+  let viewport = $state<Viewport | undefined>(undefined);
+  const HOVER_REACH = 10;
+
+  /** The chooser's list, when the view opens without an anchor. */
+  let screens = $state<WireScreen[] | null>(null);
+
+  const LEGEND_KEY = 'codegraph-ui:steps-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 = { step: StepNode };
+  const edgeTypes = { screen: ScreenEdge };
+  const DEPTHS = [4, 6, 8, 10, 12];
+
+  const asked = $derived(anchor !== null || symbol !== null);
+  const supported = canDrawSteps();
+
+  $effect(() => {
+    void live.indexTick;
+    const request =
+      anchor !== null
+        ? { anchor, depth: depth ?? undefined, through }
+        : symbol !== null
+          ? { symbol, depth: depth ?? undefined, through }
+          : null;
+    const controller = new AbortController();
+    selected = null;
+    hovered = null;
+    panelHot = null;
+    if (request === null) {
+      payload = null;
+      loading = false;
+      error = null;
+      fetchScreens(controller.signal)
+        .then((next) => {
+          screens = next.routed ? next.screens : [];
+        })
+        .catch(() => {
+          screens = [];
+        });
+      return () => controller.abort();
+    }
+    loading = true;
+    error = null;
+    fetchSteps(request, 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<StepsModel | null>(payload === null ? null : buildStepsModel(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 pills = $derived(model === null ? null : placeLabels(model, selected));
+  const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
+  const focusPill = $derived.by(() => {
+    if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
+    const full = panelHot?.edge === focusId ? fullText(panelHot.link) : undefined;
+    return hoverPill(model, focusId, selected, full, pills ?? undefined);
+  });
+
+  const nodes = $derived.by<Node[]>(() => {
+    if (model === null) return [];
+    return model.layout.nodes.map((node) => ({
+      id: node.id,
+      type: 'step',
+      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;
+          panelHot = null;
+        },
+      },
+    }));
+  });
+
+  const edges = $derived.by<Edge[]>(() => {
+    if (model === null) return [];
+    const focus = focusId;
+    return model.layout.edges
+      .filter((edge) => isEdgeVisible(edge, selected))
+      .map((edge) => {
+        const touches = selected !== null && (edge.source === selected || edge.target === selected);
+        const isFocus = focus === edge.id;
+        const hot = isFocus || touches;
+        return {
+          id: edge.id,
+          source: edge.source,
+          target: edge.target,
+          sourceHandle: edge.sourceHandle,
+          targetHandle: edge.targetHandle,
+          type: 'screen',
+          selectable: false,
+          deletable: false,
+          zIndex: isFocus ? 3 : hot ? 2 : 1,
+          data: {
+            edge,
+            info: model.edges.get(edge.id)!,
+            curve: model.curves.get(edge.id)!,
+            hot,
+            soft: hot && focus !== null && !isFocus,
+            focus: isFocus,
+            dimmed: selected !== null && !touches,
+            pill: pills?.pills.get(edge.id) ?? (isFocus ? focusPill : null),
+            full: panelHot?.edge === edge.id ? fullText(panelHot.link) : null,
+            onHover: onEdgeHover,
+          },
+        };
+      });
+  });
+
+  const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
+  const lists = $derived(selected === null || payload === null ? null : stepNeighbourhood(payload, selected));
+  const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
+  const edgeById = $derived(
+    model === null ? new Map<string, MapEdgeLayout>() : new Map(model.layout.edges.map((e) => [e.id, e]))
+  );
+  const visibleIds = $derived(new Set(edges.map((e) => e.id)));
+
+  /** The same picture with one setting changed: the anchor as the URL asked for it, the rest kept. */
+  function rewrite(changes: { depth?: number; through?: boolean }): string {
+    const opts = {
+      anchor: anchor ?? undefined,
+      symbol: anchor === null ? (symbol ?? undefined) : undefined,
+      depth: changes.depth ?? depth ?? undefined,
+      through: changes.through ?? through,
+    };
+    return stepsHref(opts);
+  }
+
+  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 onStageMove(event: MouseEvent): void {
+    if (model === null || stage === null) return;
+    const target = event.target as Element | null;
+    if (target?.closest('.spill')) return;
+    if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
+      hovered = null;
+      return;
+    }
+    const view = viewport ?? readViewport();
+    if (!view) return;
+    const box = stage.getBoundingClientRect();
+    const point = {
+      x: (event.clientX - box.left - view.x) / view.zoom,
+      y: (event.clientY - box.top - view.y) / view.zoom,
+    };
+    const hit = nearestEdge(model, point, visibleIds, HOVER_REACH / view.zoom);
+    const edge = hit === null ? undefined : edgeById.get(hit.id);
+    if (!edge) {
+      hovered = null;
+      return;
+    }
+    hovered = {
+      edge,
+      x: Math.min(event.clientX - box.left + 14, box.width - 360),
+      y: event.clientY - box.top + 14,
+    };
+  }
+
+  function readViewport(): Viewport | null {
+    const el = stage?.querySelector<HTMLElement>('.svelte-flow__viewport');
+    const m = el?.style.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)\s*scale\(([-\d.]+)\)/);
+    return m ? { x: Number(m[1]), y: Number(m[2]), zoom: Number(m[3]) } : null;
+  }
+
+  function onRowHover(link: WireStepLink | null): void {
+    const edge = link === null ? null : stepPairId(link);
+    panelHot = link === null || edge === null ? null : { edge, link };
+  }
+
+  /** The words a panel row puts on its line: the arrow, and the whole condition. */
+  function fullText(link: WireStepLink): string {
+    const arriving = selected !== null && link.to === selected && link.from !== selected;
+    return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
+  }
+
+  function rowHot(link: WireStepLink): boolean {
+    if (panelHot !== null) return panelHot.link.id === link.id;
+    return hovered !== null && stepPairId(link) === hovered.edge.id;
+  }
+
+  function nameOf(id: string): string {
+    return model?.nodes.get(id)?.label ?? id;
+  }
+
+  /** A Flow strip between the two symbols of a link, when both are symbols. */
+  function stripHref(link: WireStepLink): string | null {
+    const from = payload?.steps.find((s) => s.id === link.from)?.node;
+    const to = payload?.steps.find((s) => s.id === link.to)?.node;
+    if (!from || !to) return null;
+    return flowHref({ from: from.name, to: to.name });
+  }
+
+  /** The symbol a site's line belongs to: the last folded symbol, else the step's own. */
+  function siteHref(link: WireStepLink, site: { file: string; line: number }, fallback: string | null): string | null {
+    const last = link.via[link.via.length - 1];
+    const id = last?.id ?? fallback;
+    return id === null ? null : symbolHref(id, { line: site.line });
+  }
+
+  function basename(file: string): string {
+    return file.slice(file.lastIndexOf('/') + 1);
+  }
+</script>
+
+<div class="steps">
+  <div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
+    {#if !supported}
+      <div class="state">
+        <h2>This viewer cannot draw steps</h2>
+        <p>The host it runs in has not wired the steps question. The Screens and Flow views still work.</p>
+      </div>
+    {:else if !asked}
+      <div class="state chooser">
+        <h2>What happens from where?</h2>
+        <p>
+          Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
+          cross into native code, the events that come back, the state it writes, the requests that leave
+          the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
+          condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
+        </p>
+        {#if screens === null}
+          <p class="dim">Reading screens…</p>
+        {:else if screens.length === 0}
+          <p class="dim">
+            No screens in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
+            or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
+          </p>
+        {:else}
+          <div class="chooser-list">
+            {#each [...screens].sort((a, b) => b.outgoing + b.incoming - (a.outgoing + a.incoming) || a.path.localeCompare(b.path)) as screen (screen.id)}
+              <a class="pick mono" href={stepsHref({ anchor: screen.id })}
+                >{screen.path} <span class="dim sans">{screen.component?.name ?? basename(screen.file)}</span></a
+              >
+            {/each}
+          </div>
+        {/if}
+      </div>
+    {:else if error !== null}
+      <div class="state">
+        <h2>The steps could not be read</h2>
+        <p>{error}</p>
+      </div>
+    {:else if loading && payload === null}
+      <div class="state"><p class="dim">Walking from the anchor…</p></div>
+    {:else if model !== null && payload !== null}
+      <SvelteFlow
+        {nodes}
+        {edges}
+        {nodeTypes}
+        {edgeTypes}
+        fitView
+        {...FIT}
+        bind:viewport
+        minZoom={0.2}
+        maxZoom={3}
+        nodesDraggable={false}
+        nodesConnectable={false}
+        elementsSelectable={false}
+        panOnDrag
+        proOptions={{ hideAttribution: true }}
+        onpaneclick={() => {
+          selected = null;
+          hovered = null;
+          panelHot = null;
+        }}
+      >
+        <Controls position="bottom-right" showLock={false} />
+      </SvelteFlow>
+
+      <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">
+              <span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
+              <span>Where the picture starts; each row down is one more step away</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box mono">/path</span>
+              <span>A screen, or a handler — a function wired to a tap or a listener</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box k-cross mono">⇢ fn</span>
+              <span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box k-store mono">set</span>
+              <span>A store action — a function in a store file</span>
+            </div>
+            <div class="lrow">
+              <span class="k-box k-effect mono">api</span>
+              <span>A call that leaves the index: the network, storage, the device, telemetry</span>
+            </div>
+            <div class="lrow">
+              <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
+              <span>Leads to — the plumbing between the two is folded into the line</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>Established by a synthesized hop (an event channel, a callback, 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 — leaves the top of its box, arrives at the bottom of the other</span>
+            </div>
+            <div class="lrow">
+              <span class="k-label mono">→ …x</span>
+              <span>The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always</span>
+            </div>
+            <div class="lrow">
+              <span class="k-label mono">name …</span>
+              <span>Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on</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 {stepViaText(link)}</span>{/if}
+              {#if link.label}<span class="dim">{link.label}</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>
+            <div class="sub dim">{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
+            {#if selectedInfo.step.screen?.component}
+              <a class="sub" href={symbolHref(selectedInfo.step.screen.component.id)}>
+                <KindGlyph kind={selectedInfo.step.screen.component.kind} />
+                {selectedInfo.step.screen.component.name}
+              </a>
+            {:else if selectedInfo.step.node && selectedInfo.step.kind !== 'screen'}
+              <a class="sub" href={symbolHref(selectedInfo.step.node.id)}>
+                <KindGlyph kind={selectedInfo.step.node.kind} />
+                {selectedInfo.step.node.name}
+              </a>
+            {/if}
+            {#if selectedInfo.step.effect}
+              <a class="sub" href={symbolHref(selectedInfo.step.effect.by.id, { line: selectedInfo.step.effect.line })}>
+                <KindGlyph kind={selectedInfo.step.effect.by.kind} />
+                {selectedInfo.step.effect.by.name} · line {selectedInfo.step.effect.line}
+              </a>
+            {/if}
+            {#if selectedInfo.step.node}
+              <a class="sub dim" href={fileHref(selectedInfo.step.node.file)}>{selectedInfo.step.node.file}</a>
+            {/if}
+            {#if selectedInfo.step.node && !selectedInfo.step.anchor}
+              <a class="sub act" href={stepsHref({ anchor: selectedInfo.step.node.id })}>Start here →</a>
+            {/if}
+          </div>
+          <button class="clear" onclick={() => (selected = null)}>clear</button>
+        </div>
+        {#if selectedInfo.step.cut === 'screen'}
+          <p class="dim note">Another screen — a chapter of its own. Start here to see what happens on it, or continue through screens from the summary.</p>
+        {:else if selectedInfo.step.cut === 'component'}
+          <p class="dim note">The event lands in a component of another screen — a picture of its own. Start here to see it, or continue through screens from the summary.</p>
+        {:else if selectedInfo.step.cut !== null}
+          <p class="dim note">
+            The walk was cut at this step ({selectedInfo.step.cut === 'depth'
+              ? 'the picture’s depth'
+              : selectedInfo.step.cut === 'fan-out'
+                ? 'more calls than the walk follows from one node'
+                : selectedInfo.step.cut === 'folded'
+                  ? 'as much plumbing as it folds from one step'
+                  : 'the picture’s size'}). Start here to see on.
+          </p>
+        {/if}
+        {#if selectedInfo.step.effect && selectedInfo.step.effect.apis.length > 1}
+          <p class="dim note mono">{selectedInfo.step.effect.apis.join(' · ')}</p>
+        {/if}
+        {#if selectedInfo.step.events && selectedInfo.step.events.length > 1}
+          <p class="dim note mono">⇠ {selectedInfo.step.events.join(' · ')}</p>
+        {/if}
+        {#if pills !== null && pills.hidden > 0}
+          <p class="dim note">
+            {pills.hidden} condition{pills.hidden === 1 ? '' : 's'} not drawn on the picture for want of
+            room — hover a row below to see {pills.hidden === 1 ? 'it' : 'each'} on its line.
+          </p>
+        {/if}
+
+        <h4>Arrives from <span class="dim">{lists.arrivesFrom.length}</span></h4>
+        {#if lists.arrivesFrom.length === 0}
+          <p class="dim">{selectedInfo.step.anchor ? 'The anchor — the picture starts here.' : 'Nothing in the picture leads here.'}</p>
+        {/if}
+        {#each lists.arrivesFrom as link (link.id)}
+          <div
+            class="row"
+            class:hot={rowHot(link)}
+            role="presentation"
+            onmouseenter={() => onRowHover(link)}
+            onmouseleave={() => onRowHover(null)}
+            onfocusin={() => onRowHover(link)}
+            onfocusout={() => onRowHover(null)}
+          >
+            <button class="peer mono" onclick={() => (selected = link.from)}>{nameOf(link.from)}</button>
+            {#if link.when}<div class="when">when {link.when}</div>{/if}
+            {#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
+            {#if link.label}<div class="via dim">{link.label}</div>{/if}
+            {#each link.sites as site (site.file + site.line)}
+              {@const href = siteHref(link, site, payload.steps.find((s) => s.id === link.from)?.node?.id ?? null)}
+              {#if href}
+                <a class="site dim" {href}>{site.text} · {basename(site.file)}:{site.line}</a>
+              {:else}
+                <span class="site dim">{site.text} · {basename(site.file)}:{site.line}</span>
+              {/if}
+            {/each}
+            {#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
+          </div>
+        {/each}
+
+        <h4>Leads to <span class="dim">{lists.leadsTo.length}</span></h4>
+        {#if lists.leadsTo.length === 0}
+          <p class="dim">
+            {selectedInfo.step.kind === 'effect' ? 'Outside the index: the graph cannot follow it further.' : 'Nothing the walk follows leaves this step.'}
+          </p>
+        {/if}
+        {#each lists.leadsTo as link (link.id)}
+          <div
+            class="row"
+            class:hot={rowHot(link)}
+            role="presentation"
+            onmouseenter={() => onRowHover(link)}
+            onmouseleave={() => onRowHover(null)}
+            onfocusin={() => onRowHover(link)}
+            onfocusout={() => onRowHover(null)}
+          >
+            <button class="peer mono" onclick={() => (selected = link.to)}>{nameOf(link.to)}</button>
+            {#if link.when}<div class="when">when {link.when}</div>{/if}
+            {#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
+            {#if link.label}<div class="via dim">{link.label}</div>{/if}
+            {#each link.sites as site (site.file + site.line)}
+              {@const href = siteHref(link, site, selectedInfo.step.screen?.component?.id ?? selectedInfo.step.node?.id ?? null)}
+              {#if href}
+                <a class="site dim" {href}>{site.text} · {basename(site.file)}:{site.line}</a>
+              {:else}
+                <span class="site dim">{site.text} · {basename(site.file)}:{site.line}</span>
+              {/if}
+            {/each}
+            {#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
+          </div>
+        {/each}
+      {:else}
+        <div class="head">
+          <div>
+            <div class="big">What happens from <span class="mono">{payload.anchor.name}</span></div>
+            <a class="sub" href={symbolHref(payload.anchor.id)}>
+              <KindGlyph kind={payload.anchor.kind} />
+              {payload.anchor.qualifiedName}
+            </a>
+            <a class="sub dim" href={fileHref(payload.anchor.file)}>{payload.anchor.file}</a>
+          </div>
+        </div>
+        {#if payload.ambiguous.length > 0}
+          <p class="dim note">
+            {payload.ambiguous.length} other symbol{payload.ambiguous.length === 1 ? '' : 's'} share this name:
+            {#each payload.ambiguous as other, i (other.id)}
+              {#if i > 0},{/if}
+              <a href={stepsHref({ anchor: other.id })}>{other.kind} in {basename(other.file)}</a>
+            {/each}
+          </p>
+        {/if}
+        <p>
+          <b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
+          <select
+            class="depth"
+            value={String(payload.depth)}
+            onchange={(e) => navigate(rewrite({ depth: Number((e.currentTarget as HTMLSelectElement).value) }))}
+          >
+            {#each DEPTHS as d (d)}
+              <option value={String(d)}>{d}</option>
+            {/each}
+            {#if !DEPTHS.includes(payload.depth)}<option value={String(payload.depth)}>{payload.depth}</option>{/if}
+          </select>
+        </p>
+        <p>
+          <label class="opt">
+            <input type="checkbox" checked={payload.through} onchange={(e) => navigate(rewrite({ through: (e.currentTarget as HTMLInputElement).checked }))} />
+            Continue through screens
+          </label>
+          <span class="dim">— otherwise another screen is drawn as a boundary, and is a click from being the next anchor.</span>
+        </p>
+        <p class="counts">
+          {#each ['screen', 'trigger', 'bridge', 'event', 'store', 'effect'] as const as kind (kind)}
+            {#if model.counts[kind] > 0}
+              <span><b>{model.counts[kind]}</b> {kindWord(kind)}{model.counts[kind] === 1 ? '' : 's'}</span>
+            {/if}
+          {/each}
+        </p>
+        <p class="dim">
+          <span class="mark">●</span> The anchor is at the top; each row down is one more step away from
+          it. Click a step and each of its links is labelled at the far end of its line with the last
+          condition checked before it happens; hover the line, or its row here, for the whole chain and the
+          plumbing it travels through. A step is the next anchor, and any link opens as a Flow strip.
+        </p>
+        {#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
+          <p class="dim">
+            Not drawn:
+            {#if payload.truncated.steps > 0}<b>{payload.truncated.steps}</b> step{payload.truncated.steps === 1 ? '' : 's'} past the picture’s size limit;{/if}
+            {#if payload.truncated.hubs > 0}<b>{payload.truncated.hubs}</b> walk{payload.truncated.hubs === 1 ? '' : 's'} that reached a hub;{/if}
+            {#if payload.truncated.chrome > 0}<b>{payload.truncated.chrome}</b> into shared chrome.{/if}
+          </p>
+        {/if}
+        <h4>Most connected</h4>
+        {#each [...payload.steps].sort((a, b) => (model.layout.nodes.find((n) => n.id === b.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === b.id)?.ports.bottom.length ?? 0) - ((model.layout.nodes.find((n) => n.id === a.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === a.id)?.ports.bottom.length ?? 0))).slice(0, 8) as step (step.id)}
+          <button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind)}</span></button>
+        {/each}
+      {/if}
+    </aside>
+  {/if}
+</div>
+
+<style>
+  .steps {
+    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__edge-labels) {
+    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;
+  }
+  .chooser {
+    max-width: 720px;
+    overflow: auto;
+    height: 100%;
+    box-sizing: border-box;
+  }
+  .chooser-list {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+    gap: 0;
+    margin-top: 12px;
+    border-top: 1px solid var(--rule-soft);
+  }
+  .pick {
+    display: block;
+    padding: 7px 8px;
+    border-bottom: 1px solid var(--rule-soft);
+    color: var(--ink);
+    text-decoration: none;
+    font-size: 12.5px;
+  }
+  .pick:hover {
+    background: var(--press);
+  }
+  .legend {
+    position: absolute;
+    left: 12px;
+    bottom: 12px;
+    z-index: 4;
+    max-width: 400px;
+    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-cross {
+    border-left: 3px solid var(--accent);
+  }
+  .k-box.k-store {
+    background: var(--paper-2);
+  }
+  .k-box.k-effect {
+    border-style: dashed;
+    border-color: var(--ink-3);
+  }
+  .k-anchor .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;
+  }
+  a.sub:hover {
+    text-decoration: underline;
+  }
+  .act {
+    color: var(--accent);
+  }
+  .clear {
+    border: 1px solid var(--rule);
+    background: transparent;
+    color: var(--ink-2);
+    font: inherit;
+    font-size: 11.5px;
+    padding: 1px 7px;
+    cursor: pointer;
+  }
+  .note {
+    margin: 0 0 6px;
+  }
+  .opt {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    cursor: pointer;
+  }
+  .opt input {
+    margin: 0;
+    accent-color: var(--accent);
+  }
+  .depth {
+    font: inherit;
+    font-size: 12px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper-2);
+    color: var(--ink);
+    padding: 0 4px;
+  }
+  .counts {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px 12px;
+    color: var(--ink-2);
+  }
+  h4 {
+    margin: 16px 0 6px;
+    font: 600 12.5px var(--sans);
+  }
+  .row {
+    padding: 7px 8px;
+    margin: 0 -8px;
+    border-top: 1px solid var(--rule-soft);
+    transition: background 90ms linear;
+  }
+  .row.hot {
+    background: var(--press);
+  }
+  .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;
+  }
+  a.site:hover {
+    text-decoration: underline;
+  }
+  .mono {
+    font-family: var(--mono);
+  }
+  .sans {
+    font-family: var(--sans);
+  }
+  .dim {
+    color: var(--ink-3);
+  }
+  .mark {
+    color: var(--accent);
+  }
+</style>