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

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

- Introduces trigger metadata for steps and edges to capture what fires a site (JS prop, on* option, or callback) to improve cross-boundary flow analysis.
- Extends parsing/analysis to detect triggers in JSX attributes, on* bindings, and late-bound callbacks; adds utilities (calleeText, lastSegment) to extract trigger sources.
- Ships new trigger structures (WireStepTrigger, trigger on WireStepSite/WireStep) and propagates trigger through built steps; updates step labeling to reflect trigger information.
- Adds triggerWords helper and uses it to render human-readable trigger descriptions in Steps UI, including edge labels and per-site visuals.
- Updates UI (ScreensView, StepsView) to display FIRES FROM information, with styling tweaks to highlight triggers and related elements; enhances tooltips and inline text wrapping for readability.
- Extends tests to cover trigger detection and rendering across various binding patterns (prop, option, callback) and inline RN listeners.
- Updates design/docs and changelog to reflect Expo Router integration, per-site trigger metadata, and the new Steps surface.
Colby McHenry 1 неделя назад
Родитель
Сommit
5e06204deb

+ 1 - 1
CHANGELOG.md

@@ -14,7 +14,7 @@ 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 — and every call the panel lists says what it passes, read from the source as written (`SecureStore.setItemAsync('userEmail', values.email)`, `axios.post('/auth/login', { email, password })`), so a step is not just *that* something was stored or sent but *what*. 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.
+- **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 — and every call the panel lists says what it passes, read from the source as written (`SecureStore.setItemAsync('userEmail', values.email)`, `axios.post('/auth/login', { email, password })`), so a step is not just *that* something was stored or sent but *what*. And each handler says what fires it — the JSX prop and its element (`onPress · <Button>`), the option it is written under (`onSubmit · useFormik(…)`), the listener or effect it runs from — read from the source at the call site, so `onPress={() => handleLogin(values)}` and Formik's `onSubmit` make `handleLogin` a step of its own with the event on the arrow into it. 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.
 

+ 79 - 1
__tests__/branch-guards.test.ts

@@ -4,7 +4,7 @@ import * as os from 'os';
 import * as path from 'path';
 import { CodeGraph } from '../src';
 import { initGrammars } from '../src/extraction/grammars';
-import { callArgumentsInSource, guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards';
+import { callArgumentsInSource, guardsInSource, guardLabel, supportsBranchGuards, triggerInSource } from '../src/graph/branch-guards';
 import { buildNode } from '../src/ui-server/api/node';
 import { buildFlow } from '../src/ui-server/api/flow';
 
@@ -307,3 +307,81 @@ class CaptureEvents {
     expect(await argsAt(src, 'DispatchQueue.main.async', 'swift')).toBe('{ … }');
   });
 });
+
+
+// =============================================================================
+// Triggers — what fires a site
+// =============================================================================
+
+async function triggerAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return triggerInSource(src, language, line, column);
+}
+
+describe('triggers', () => {
+  const login = `
+function LoginButton({ values }) {
+  const formik = useFormik({
+    initialValues: values,
+    onSubmit: (v) => {
+      handleLogin(v.email, v.password)
+    },
+  })
+  useEffect(() => {
+    warmUp()
+  }, [])
+  useEffect(() => {
+    const sub = nativeEmitter.addListener('onZipComplete', (data) => { finish(data) })
+    return () => sub.remove()
+  }, [])
+  const handleRemove = useCallback(() => {
+    removeCredential(values.email)
+  }, [values])
+  fetchThing().then(() => done())
+  return (
+    <View>
+      <Button onPress={formik.submitForm} />
+      <TouchableOpacity onPress={() => handleSelectAccount(account)} />
+      <Pressable onPress={handleRemove} />
+      <Row.Item onLongPress={() => { if (ok) confirm() }} />
+      <KeyboardAvoidingView behavior={isAndroid() ? 'height' : 'padding'} />
+      <FlatList renderItem={({ item }) => renderRow(item)} keyExtractor={keyOf} />
+    </View>
+  )
+}
+function warn() {
+  Alert.alert('Remove?', 'Sure?', [{ text: 'OK', onPress: () => removeAll() }], { cancelable: true })
+}
+`;
+
+  it('a call under a JSX prop: the prop and the element', async () => {
+    expect(await triggerAt(login, 'handleSelectAccount(')).toEqual({ kind: 'prop', name: 'onPress', of: 'TouchableOpacity' });
+    expect(await triggerAt(login, 'confirm()')).toEqual({ kind: 'prop', name: 'onLongPress', of: 'Row.Item' });
+    // A handler passed as a value: the site IS the attribute.
+    expect(await triggerAt(login, 'handleRemove} />')).toEqual({ kind: 'prop', name: 'onPress', of: 'Pressable' });
+    // A function under any prop fires later; a value computed in a prop runs at render.
+    expect(await triggerAt(login, 'renderRow(item)')).toEqual({ kind: 'prop', name: 'renderItem', of: 'FlatList' });
+    expect(await triggerAt(login, 'isAndroid()')).toBeNull();
+    expect(await triggerAt(login, 'keyOf}')).toBeNull();
+  });
+
+  it('a call under an on* option: the key and the call it configures', async () => {
+    expect(await triggerAt(login, 'handleLogin(')).toEqual({ kind: 'option', name: 'onSubmit', of: 'useFormik' });
+    // The option's object inside an array argument: still the call it configures.
+    expect(await triggerAt(login, 'removeAll()')).toEqual({ kind: 'option', name: 'onPress', of: 'Alert.alert' });
+  });
+
+  it('a call inside a runs-later callback: the callee and its first literal', async () => {
+    expect(await triggerAt(login, 'warmUp()')).toEqual({ kind: 'callback', name: 'useEffect', of: null });
+    expect(await triggerAt(login, 'finish(data)')).toEqual({ kind: 'callback', name: 'addListener', of: "'onZipComplete'" });
+    expect(await triggerAt(login, 'done()')).toEqual({ kind: 'callback', name: 'then', of: null });
+  });
+
+  it('a named handler is its own story: nothing fires the call inside it, from here', async () => {
+    expect(await triggerAt(login, 'removeCredential(')).toBeNull();
+    // A plain call in a component body is fired by nothing in particular.
+    expect(await triggerAt(login, 'fetchThing()')).toBeNull();
+    expect(await triggerAt(login, 'handleLogin(', 'swift')).toBeNull();
+  });
+});

+ 20 - 3
__tests__/ui-steps-api.test.ts

@@ -80,6 +80,10 @@ beforeAll(async () => {
       "    const sub = nativeEmitter.addListener('onZipComplete', handleZipComplete)\n" +
       '    return () => sub.remove()\n' +
       '  }, [handleZipComplete])\n' +
+      '  const form = useForm({ onSubmit: () => handleSubmit() })\n' +
+      '  function handleSubmit() {\n' +
+      '    captureView.finalizeCaptureSession()\n' +
+      '  }\n' +
       '  return <Button onPress={handleApprove} />\n' +
       '}\n'
   );
@@ -91,7 +95,7 @@ beforeAll(async () => {
       '  const handleOpen = useCallback(() => {\n' +
       '    captureView.finalizeCaptureSession()\n' +
       '  }, [])\n' +
-      '  return <Button onPress={handleOpen} />\n' +
+      '  return <Button onPress={() => handleOpen()} />\n' +
       '}\n' +
       'const MemoizedCaptureComponent = memo(CaptureComponent)\n' +
       'export default function CapturePage() {\n' +
@@ -194,7 +198,18 @@ describe('buildSteps', () => {
     const link = (from: string, to: string) =>
       payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id);
     const req = link('handleZipComplete', 'client.post +1');
-    expect(link('/capture/review', 'handleApprove')?.kind).toBe('handler');
+    const tap = link('/capture/review', 'handleApprove');
+    expect(tap?.kind).toBe('handler');
+    // What fires it — read at the site: the JSX prop and its element, and the
+    // function that writes the binding.
+    expect(tap?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
+    expect(byLabel.get('handleApprove')?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
+    // A function called from under an `on*` option is a handler too — the
+    // Formik shape — and the option names what fires it.
+    expect(kinds['handleSubmit']).toBe('trigger');
+    expect(link('/capture/review', 'handleSubmit')?.trigger).toEqual({ kind: 'option', name: 'onSubmit', of: 'useForm', in: 'ReviewScreen' });
+    // The listener registration is a callback binding on the handler link.
+    expect(link('/capture/review', 'handleZipComplete')?.trigger).toEqual({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'ReviewScreen' });
     expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
     const evt = link('finalizeCaptureSession', 'handleZipComplete');
     expect(evt?.kind).toBe('event');
@@ -245,11 +260,13 @@ describe('buildSteps', () => {
     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.
+    // the handler — called from an inline arrow under `onPress` — 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(toHandler.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'CaptureComponent' });
     expect(payload.steps.map((s) => s.label)).not.toContain('CaptureComponent');
   });
 

+ 9 - 2
__tests__/ui-steps-model.test.ts

@@ -4,7 +4,7 @@
  * 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 { buildStepsModel, kindWord, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } 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';
 
@@ -45,7 +45,7 @@ describe('steps model', () => {
   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(screen, handler, { kind: 'handler', trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } }),
     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')] }),
@@ -69,6 +69,9 @@ describe('steps model', () => {
   it('one edge per pair, labelled with the innermost condition or a count', () => {
     const edges = [...model.edges.values()];
     expect(edges).toHaveLength(6);
+    // A link into a handler says the event, not the conditions.
+    const toHandler = edges.find((e) => e.to === handler.id)!;
+    expect(toHandler.label).toBe('onPress · <Button>');
     const toBridge = edges.find((e) => e.to === bridge.id)!;
     expect(toBridge.label).toBe('NOT busy');
     expect(toBridge.kind).toBe('bridge');
@@ -95,6 +98,10 @@ describe('steps model', () => {
     expect(stepSub(store)).toBe('store · capture.storage.ts');
     expect(stepSub(effect)).toBe('network · uploadARCapture');
     expect(kindWord('effect')).toBe('outside the index');
+    expect(triggerWords({ kind: 'option', name: 'onSubmit', of: 'useFormik', in: 'LoginButton' })).toBe('onSubmit · useFormik(…)');
+    expect(triggerWords({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'X' })).toBe("addListener('onZipComplete')");
+    expect(triggerWords({ kind: 'callback', name: 'useEffect', of: null, in: 'X' })).toBe('useEffect');
+    expect(stepSub({ ...handler, trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } })).toBe('onPress · <Button> · a.tsx');
     expect(stepViaText(links[2]!)).toBe('emitZipComplete');
   });
 

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

@@ -466,7 +466,17 @@ guards: string literals and names whole, an object as its keys (`{ email, passwo
 `() => …`, nested calls `f(…)`, Swift labels kept (`withName: "onZipComplete"`), ≤ 96 chars — printed on the panel's
 site rows (`SecureStore.setItemAsync('userEmail', values.email) · index.tsx:226`) and in the tooltip, and an effect
 box with exactly one call behind it wears it as its label (`axios.post('/auth/login', { email, password })`, ≤ 56).
-The conditions say when a step runs; the arguments say with what. Caps, each announced: depth in steps (default 8, ≤ 14, `cut: 'depth'` on the step it stopped
+The conditions say when a step runs; the arguments say with what; a **trigger** says what fires it. Read at the
+site the same way (`triggersForFile`): climb from the call through inline arrows to the first thing that binds it —
+a JSX attribute (`onPress` of `<Button>`), an `on*` option key (`onSubmit` of `useFormik({…})`), or an argument of a
+runs-later call (`useEffect`, `setTimeout`, `addListener('onZipComplete')`, `.then`); a named handler (`const
+handleX = useCallback(…)`) is a boundary, its own story. A function called from under such a binding is a
+**handler step** even though nothing passed it as a value (`onPress={() => handleLogin(values)}` — the common
+case, and the Formik case), and every call-shaped link carries its trigger: a store action or an effect fired
+straight from a tap says so. The pill on a handler link says the event (`onPress · <Button>`,
+`onSubmit · useFormik(…)`), not the conditions; the box's second line says it before the file; the panel prints
+`FIRES FROM onPress · <Button> in LoginButton` above the `via` chain, which is set in `--ink-2` at the
+condition's size — it is the answer to "where on the screen", not an afterthought. 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

+ 30 - 15
src/graph/branch-guards.ts

@@ -520,38 +520,49 @@ export function triggerInTree(root: SyntaxNode, source: string, line: number, co
   const col = column ?? firstNonBlankColumn(source, row);
   let node: SyntaxNode | null = innermostAt(root, row, col);
   let prev: SyntaxNode | null = null;
+  // Whether the climb crossed an inline function: `onPress={() => go()}`
+  // fires later, `behavior={isAndroid() ? 'a' : 'b'}` runs at render.
+  let deferred = false;
   for (let up = 0; node && up < MAX_TRIGGER_CLIMB; up++, prev = node, node = node.parent) {
     const type = node.type;
     if (TRIGGER_BOUNDARIES.has(type)) return null;
     // A named handler is its own story: `const handleX = useCallback(() => …)`
     // binds a name, and whoever uses the name is the trigger of what is inside.
-    if ((type === 'arrow_function' || type === 'function_expression') && node.parent) {
+    if (type === 'arrow_function' || type === 'function_expression') {
       const p = node.parent;
-      if (p.type === 'variable_declarator') return null;
-      if (p.type === 'arguments' && p.parent) {
-        const callee = calleeName(p.parent);
+      if (p?.type === 'variable_declarator') return null;
+      if (p?.type === 'arguments' && p.parent) {
+        const callee = lastSegment(calleeText(p.parent));
         if (callee === 'useCallback' || callee === 'useMemo' || callee === 'useEffectEvent' || callee === 'useEvent') return null;
       }
+      deferred = true;
     }
     if (type === 'jsx_attribute') {
       const name = node.namedChild(0);
+      const propName = name ? name.text : 'prop';
+      // An event prop, or any prop given a function: fired later. A value
+      // computed in the attribute (`behavior={isAndroid() ? …}`) is not.
+      if (!deferred && !/^on[A-Z]/.test(propName)) return null;
       const element = node.parent;
       const tag = element ? element.childForFieldName('name') : null;
-      return { kind: 'prop', name: name ? name.text : 'prop', of: tag ? collapseText(tag.text) : null };
+      return { kind: 'prop', name: propName, of: tag ? collapseText(tag.text) : null };
     }
     if (type === 'pair') {
       const key = node.childForFieldName('key');
       const keyText = key ? key.text.replace(/^['"`]|['"`]$/g, '') : '';
       if (/^on[A-Z]\w*$/.test(keyText)) {
-        // `useFormik({ onSubmit: … })`: the object is an argument of a call.
-        const object = node.parent;
-        const args = object?.parent;
-        const call = args?.type === 'arguments' ? args.parent : null;
-        return { kind: 'option', name: keyText, of: call && CALL_TYPES.has(call.type) ? calleeName(call) : null };
+        // `useFormik({ onSubmit: … })`, `Alert.alert(t, m, [{ onPress: … }])`:
+        // the object — possibly inside an array — is an argument of a call.
+        let holder: SyntaxNode | null = node.parent;
+        for (let hop = 0; holder && hop < 4 && (holder.type === 'object' || holder.type === 'array' || holder.type === 'pair'); hop++) {
+          holder = holder.parent;
+        }
+        const call = holder?.type === 'arguments' ? holder.parent : null;
+        return { kind: 'option', name: keyText, of: call && CALL_TYPES.has(call.type) ? calleeText(call) : null };
       }
     }
     if (type === 'arguments' && node.parent && CALL_TYPES.has(node.parent.type) && prev !== null) {
-      const callee = calleeName(node.parent);
+      const callee = lastSegment(calleeText(node.parent));
       if (callee !== null && LATER_CALLEES.has(callee)) {
         const first = node.namedChild(0);
         const of = first && STRING_TYPES.has(first.type) ? cut(collapseText(first.text), MAX_ARG_TEXT) : null;
@@ -562,11 +573,15 @@ export function triggerInTree(root: SyntaxNode, source: string, line: number, co
   return null;
 }
 
-/** The last segment of a call's callee: `nativeEmitter.addListener` → `addListener`. */
-function calleeName(call: SyntaxNode): string | null {
+/** A call's callee as written: `nativeEmitter.addListener`, `Alert.alert`, `useFormik`. */
+function calleeText(call: SyntaxNode): string | null {
   const callee = call.childForFieldName('function') ?? call.childForFieldName('constructor');
-  if (!callee) return null;
-  const text = collapseText(callee.text);
+  return callee ? cut(collapseText(callee.text), 40) : null;
+}
+
+/** The last segment of a callee: `nativeEmitter.addListener` → `addListener`. */
+function lastSegment(text: string | null): string | null {
+  if (text === null) return null;
   const m = text.match(/([A-Za-z_$][\w$]*)\s*$/);
   return m ? m[1]! : text;
 }

+ 8 - 1
src/ui-server/api/steps.ts

@@ -71,6 +71,8 @@ export interface WireStepSite {
    * summary of all of them.
    */
   when: string;
+  /** What fires THIS site, when it differs from the link's first. */
+  trigger?: WireStepTrigger;
 }
 
 /** What fires a step or a link: the event it is written under, and the function that writes it there. */
@@ -385,9 +387,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     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 stamped: WireStepSite = { ...site, when };
+    const stamped: WireStepSite = { ...site, when, ...(trigger ? { trigger } : {}) };
+    // A `contains` edge is how a nested handler is FOUND, not a place it is
+    // called from: its row stays only while no call site has been seen.
+    const structural = (s: WireStepSite) => s.text.startsWith('defines ');
     const existing = links.get(id);
     if (existing) {
+      if (structural(stamped) && existing.sites.some((s) => !structural(s))) return;
+      if (!structural(stamped) && existing.sites.every(structural)) existing.sites.length = 0;
       if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(stamped);
       if (!existing.trigger && trigger) existing.trigger = trigger;
       if (when !== existing.when) {

+ 23 - 3
ui/src/lib/steps-model.ts

@@ -12,7 +12,7 @@
  * are the links into and out of the selected step.
  */
 
-import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepsPayload } from './wire';
+import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
 import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
 import {
   edgeLabel,
@@ -83,6 +83,21 @@ export function kindWord(kind: WireStep['kind']): string {
   }
 }
 
+/**
+ * What fires something, in a few characters: `onPress · <Button>`,
+ * `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`.
+ */
+export function triggerWords(t: WireStepTrigger): string {
+  switch (t.kind) {
+    case 'prop':
+      return t.of ? `${t.name} · <${t.of}>` : t.name;
+    case 'option':
+      return t.of ? `${t.name} · ${t.of}(…)` : t.name;
+    default:
+      return t.of ? `${t.name}(${t.of})` : t.name;
+  }
+}
+
 /** 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) {
@@ -105,7 +120,8 @@ export function stepSub(step: WireStep): string {
     case 'screen':
       return step.sub;
     case 'trigger':
-      return `handler · ${file}`;
+      // The event before the file: `onPress · <Button> · index.tsx`.
+      return step.trigger ? `${triggerWords(step.trigger)} · ${file}` : `handler · ${file}`;
     case 'bridge':
       return `native · ${file}`;
     case 'event':
@@ -177,12 +193,16 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
       byKind: [{ kind: 'calls', count: group.length }],
       topPairs: [],
     });
+    // A link into a handler says the EVENT — `onPress · <Button>` — not the
+    // conditions; those are one hover away, and the event is what a reader
+    // asking "at what point does this run" came for.
+    const trigger = group.length === 1 && first.kind === 'handler' && first.trigger ? first.trigger : null;
     edges.set(key, {
       id: key,
       from: first.from,
       to: first.to,
       links: group,
-      label: edgeLabel(group),
+      label: trigger ? triggerWords(trigger) : edgeLabel(group),
       synthesized: group.every((l) => l.synthesized),
       kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
     });

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

@@ -710,6 +710,19 @@ export interface WireStepSite {
   args?: string;
   /** The conditions THIS site runs under (the whole chain's); '' when unconditional. */
   when: string;
+  /** What fires THIS site, when it differs from the link's first. */
+  trigger?: WireStepTrigger;
+}
+
+/** What fires a step or a link: the event it is written under, and the function that writes it there. */
+export interface WireStepTrigger {
+  kind: 'prop' | 'option' | 'callback';
+  /** `onPress`, `onSubmit`, `useEffect`, `addListener`. */
+  name: string;
+  /** `Button` for a prop, `useFormik` for an option, the first string argument for a callback; null when unknown. */
+  of: string | null;
+  /** The function the binding is written in. */
+  in: string;
 }
 
 export interface WireStep {
@@ -733,6 +746,8 @@ export interface WireStep {
   event?: string;
   /** Every event that lands on this step. */
   events?: string[];
+  /** For a handler: what fires it. */
+  trigger?: WireStepTrigger;
   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 };
@@ -752,6 +767,8 @@ export interface WireStepLink {
   synthesized: boolean;
   uncertain: boolean;
   sites: WireStepSite[];
+  /** What fires the first site, when something binds it to an event. */
+  trigger?: WireStepTrigger;
 }
 
 export interface WireStepsPayload {

+ 7 - 3
ui/src/views/ScreensView.svelte

@@ -428,7 +428,7 @@
           >
             <button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
             {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
-            {#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
+            {#if link.via.length > 0}<div class="via">via {viaText(link)}</div>{/if}
             {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
             {#each sc.rows as row (row.site.file + row.site.line)}
               <div class="scenario" class:many={sc.rows.length > 1}>
@@ -456,7 +456,7 @@
           >
             <button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
             {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
-            {#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
+            {#if link.via.length > 0}<div class="via">via {viaText(link)}</div>{/if}
             {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
             {#each sc.rows as row (row.site.file + row.site.line)}
               <div class="scenario" class:many={sc.rows.length > 1}>
@@ -644,6 +644,8 @@
     box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
     font-size: 12px;
     pointer-events: none;
+    /* A long via chain or condition wraps inside the box. */
+    overflow-wrap: anywhere;
   }
   .tiprow {
     display: flex;
@@ -736,8 +738,10 @@
   .kw {
     font-weight: 600;
   }
+  /* The chain a transition travels through — the answer to "where on the screen": read, not dim. */
   .via {
-    font: 400 11px var(--mono);
+    color: var(--ink-2);
+    font: 400 11.5px var(--mono);
     margin-top: 2px;
   }
   .ways {

+ 40 - 8
ui/src/views/StepsView.svelte

@@ -38,6 +38,7 @@
     stepNeighbourhood,
     stepPairId,
     stepViaText,
+    triggerWords,
     type StepsModel,
   } from '../lib/steps-model';
 
@@ -234,7 +235,7 @@
     const box = stage.getBoundingClientRect();
     hovered = {
       edge,
-      x: Math.min(event.clientX - box.left + 14, box.width - 360),
+      x: Math.min(event.clientX - box.left + 14, box.width - 420),
       y: event.clientY - box.top + 14,
     };
   }
@@ -262,7 +263,7 @@
     }
     hovered = {
       edge,
-      x: Math.min(event.clientX - box.left + 14, box.width - 360),
+      x: Math.min(event.clientX - box.left + 14, box.width - 420),
       y: event.clientY - box.top + 14,
     };
   }
@@ -399,7 +400,7 @@
             </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>
+              <span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
             </div>
             <div class="lrow">
               <span class="k-box k-cross mono">⇢ fn</span>
@@ -442,9 +443,10 @@
           <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.trigger}<span class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></span>{/if}
+              {#if link.via.length > 0}<span class="via">via {stepViaText(link)}</span>{/if}
               {#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
               <span class="when">{@render words(conditionTokens(link.when))}</span>
-              {#if link.via.length > 0}<span class="mono dim">via {stepViaText(link)}</span>{/if}
               {#if link.label}<span class="dim">{link.label}</span>{/if}
               {#if link.sites[0]}<span class="mono">{siteWords(link.sites[0])}</span>{/if}
             </div>
@@ -462,6 +464,9 @@
           <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.trigger}
+              <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(selectedInfo.step.trigger)} <span class="dim">in {selectedInfo.step.trigger.in}</span></div>
+            {/if}
             {#if selectedInfo.step.screen?.component}
               <a class="sub" href={symbolHref(selectedInfo.step.screen.component.id)}>
                 <KindGlyph kind={selectedInfo.step.screen.component.kind} />
@@ -533,13 +538,17 @@
             onfocusout={() => onRowHover(null)}
           >
             <button class="peer mono" onclick={() => (selected = link.from)}>{nameOf(link.from)}</button>
+            {#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
+            {#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
             {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</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}
             {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
             {#each sc.rows as row (row.site.file + row.site.line)}
               {@const href = siteHref(link, row.site, fallback)}
               <div class="scenario" class:many={sc.rows.length > 1}>
+                {#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
+                  <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
+                {/if}
                 {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
                 {#if href}
                   <a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
@@ -571,13 +580,17 @@
             onfocusout={() => onRowHover(null)}
           >
             <button class="peer mono" onclick={() => (selected = link.to)}>{nameOf(link.to)}</button>
+            {#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
+            {#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
             {#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</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}
             {#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
             {#each sc.rows as row (row.site.file + row.site.line)}
               {@const href = siteHref(link, row.site, fallback)}
               <div class="scenario" class:many={sc.rows.length > 1}>
+                {#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
+                  <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
+                {/if}
                 {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
                 {#if href}
                   <a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
@@ -809,13 +822,21 @@
   .tip {
     position: absolute;
     z-index: 5;
-    width: 340px;
+    width: 400px;
     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;
+    /* A call with its arguments is one long token: it wraps inside the box. */
+    overflow-wrap: anywhere;
+  }
+  .tip .mono,
+  .tip .when,
+  .tip .via,
+  .tip .fires {
+    overflow-wrap: anywhere;
   }
   .tiprow {
     display: flex;
@@ -930,8 +951,19 @@
   .kw {
     font-weight: 600;
   }
+  /* The chain a hop travels through — the answer to "where on the screen": read, not dim. */
   .via {
-    font: 400 11px var(--mono);
+    color: var(--ink-2);
+    font: 400 11.5px var(--mono);
+    margin-top: 2px;
+  }
+  .via.dim {
+    color: var(--ink-3);
+    font-size: 11px;
+  }
+  .fires {
+    color: var(--ink);
+    font: 400 11.5px var(--mono);
     margin-top: 2px;
   }
   .ways {