Browse Source

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

- Adds Expo Router integration with a new Screens view and a Steps API to surface screens and their transitions.
- Extends codegraph extraction/resolution to handle namespace objects, React hook bindings for handlers, and Swift RN bridge evidence; introduces per-site guard arguments and trigger metadata, enabling richer flow analysis across JS ↔ native boundaries.
- Introduces UI and data-model changes to represent conditions as words (WHEN/AND/OR/NOT), display per-site call arguments, and show what fires a site (triggers). Adds new utilities (ui/conditions.ts) and updates ScreensView and StepsView to render scenarios with multiple sites and “ways” counts.
- Implements site readers for WHEN/ARGS/TRIGGER, and wiring to expose steps via API endpoints (including /api/steps); enhances tests to cover namespace resolution, useCallback-driven handlers, and inline RN event listeners.
- Updates styling and templates to reflect the new wording, scenario rows, and per-site details, including NOT instead of leading negation strings and multi-way links.
- Documents and reflects changes in changelog and design docs to describe Expo Router integration and the Steps surface.
Colby McHenry 1 week ago
parent
commit
e288d7645b

+ 3 - 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. 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*. 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.
 
@@ -30,6 +30,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - **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.
 
+- **Conditions read as words, and each scenario gets its own row.** In the Screens and Steps tabs a condition is now written the way you would say it — `WHEN NOT (isUploadInProgress || elapsed < 5000) AND user?.organization_id`, the joining words set apart from the code — with the code inside each guard left as written, and a transition or step with several call sites (four early returns that each go home) is listed as four rows, the clauses they all share said once above them, instead of one string joined with `||`. A guard that is itself an either/or keeps its parentheses everywhere conditions are shown, including `codegraph_explore`'s Flow section.
+
 - **Every call now says when it happens.** In `codegraph ui`, a symbol's callee and caller rails and the Flow strip's connectors carry the branch conditions the call site sits under — `when !isUploading && isCollected` — and `codegraph_explore`'s Flow section prints the same on each hop (`↓ calls (when isCollected)`). The conditions come from the `if` / `else` / ternary / `switch` / `&&` branches around the call, the early returns before it (`if (busy) return` reads as `!busy`), and Swift's `guard`; an inline callback inherits the conditions of the place it is defined. Read from the source as it is now, never stored: nothing about your index changes. TypeScript, JavaScript and Swift today.
 
 - **Expo Router apps: screens and navigation are in the graph.** Every screen file under `app/` (or `src/app/`) is now a route node named by its path — `/object-detail`, `/item/[id]`, with `(group)` folders stripped — linked to the component it renders. Calls like `router.push('/object-detail?…')`, `router.navigate({ pathname: '/item/[id]', params })`, template-literal hrefs, an href held in a local `const`, and `router.push(await pickRoute())` where the helper returns screen paths (one edge per screen it can return) resolve to the screen they open as a new `navigates` edge that remembers the href, so "where does tapping this go" and "who opens this screen" are one hop in `codegraph_explore`, `callers`, and the viewer's Flow strip instead of a dead end at a string. Re-index after upgrading to pick the new edges up.

+ 70 - 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 { guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards';
+import { callArgumentsInSource, guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards';
 import { buildNode } from '../src/ui-server/api/node';
 import { buildFlow } from '../src/ui-server/api/flow';
 
@@ -48,6 +48,21 @@ export function ItemCard(props) {
     expect(await labelAt(handlePress, 'openObjectDetail(')).toBe('!isUploading && isCollected');
   });
 
+  it('keeps a disjunctive guard in parentheses, so the join stays unambiguous', async () => {
+    const src = `
+function go(object) {
+  if (isUploading) return
+  if (!object?.id || !object?.name) {
+    bail()
+    return
+  }
+  proceed()
+}
+`;
+    expect(await labelAt(src, 'bail(')).toBe('!isUploading && (!object?.id || !object?.name)');
+    expect(await labelAt(src, 'proceed(')).toBe('!isUploading && !(!object?.id || !object?.name)');
+  });
+
   it('turns each earlier early-return into a negated guard, in source order', async () => {
     expect(await labelAt(handlePress, 'handleAddToQueue(')).toBe('!isUploading && !isCollected && queueHasItems');
     expect(await labelAt(handlePress, 'handleStartCapture(')).toBe('!isUploading && !isCollected && !queueHasItems');
@@ -238,3 +253,57 @@ describe('branch guards: on the wire', () => {
     cg.close();
   });
 });
+
+
+// =============================================================================
+// Call arguments — what a site passes
+// =============================================================================
+
+async function argsAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
+  const line = lineOf(src, needle);
+  const column = src.split('\n')[line - 1]!.indexOf(needle);
+  return callArgumentsInSource(src, language, line, column);
+}
+
+describe('call arguments', () => {
+  const login = `
+async function handleLogin(values) {
+  await SecureStore.setItemAsync('userEmail', values.email)
+  const res = await client.post('/auth/login', { email: values.email, password, ...rest })
+  Alert.alert(i18n.t('error_login_failed'), err.message, [{ text: 'OK' }])
+  router.push({ pathname: '/item/[id]', params: { id } })
+  captureView.finalizeCaptureSession()
+  run(() => go(), async (x) => x, new Thing(1))
+  const big = fetch(\`/api/\${id}\`, { method: 'POST', headers, body, mode, cache, credentials })
+}
+`;
+
+  it('keeps literals and names whole, folds objects to their keys, arrays and functions to a shape', async () => {
+    expect(await argsAt(login, 'SecureStore.setItemAsync(')).toBe("'userEmail', values.email");
+    expect(await argsAt(login, 'client.post(')).toBe("'/auth/login', { email, password, ...rest }");
+    expect(await argsAt(login, 'Alert.alert(')).toBe('i18n.t(…), err.message, […]');
+    expect(await argsAt(login, 'router.push(')).toBe('{ pathname, params }');
+    expect(await argsAt(login, 'run(')).toBe('() => …, () => …, new Thing(…)');
+    expect(await argsAt(login, 'fetch(')).toBe('`/api/${id}`, { method, headers, body, mode, … }');
+  });
+
+  it('an empty argument list is an empty string; a position outside a call is null', async () => {
+    expect(await argsAt(login, 'captureView.finalizeCaptureSession(')).toBe('');
+    expect(await argsAt(login, 'async function handleLogin')).toBeNull();
+  });
+
+  it('Swift: labels stay with their values, a trailing closure is a shape', async () => {
+    const src = `
+class CaptureEvents {
+  func emitZipComplete(result: ZipResult) {
+    sendEvent(withName: "onZipComplete", body: ["zipURL": result.url])
+    tracker.setup(side: side, angle: 45)
+    DispatchQueue.main.async { finish() }
+  }
+}
+`;
+    expect(await argsAt(src, 'sendEvent(', 'swift')).toBe('withName: "onZipComplete", body: […]');
+    expect(await argsAt(src, 'tracker.setup(', 'swift')).toBe('side: side, angle: 45');
+    expect(await argsAt(src, 'DispatchQueue.main.async', 'swift')).toBe('{ … }');
+  });
+});

+ 69 - 0
__tests__/ui-conditions.test.ts

@@ -0,0 +1,69 @@
+/**
+ * Conditions as a reader says them: the joins we add (`&&` between guards,
+ * `||` between a link's scenarios, `!(…)` around a negated guard) become
+ * and / or / not, the code inside a guard stays code, and a link with several
+ * call sites is several scenarios with their shared clauses said once.
+ */
+import { describe, it, expect } from 'vitest';
+import { clauseWords, clauses, restWords, scenarios, splitTop, whenWords } from '../ui/src/lib/conditions';
+
+describe('conditions', () => {
+  it('splits at the top level only, respecting brackets and strings', () => {
+    expect(splitTop('a && (b || c) && "x && y" && d', ' && ')).toEqual(['a', '(b || c)', '"x && y"', 'd']);
+    expect(splitTop('a && b || c && d', ' || ')).toEqual(['a && b', 'c && d']);
+    expect(clauses('!busy && isCollected')).toEqual(['!busy', 'isCollected']);
+    // A merged condition has no single innermost clause: it comes back whole.
+    expect(clauses('a && b || c')).toEqual(['a && b || c']);
+  });
+
+  it('says NOT for our negations and leaves the code inside alone', () => {
+    expect(clauseWords('!busy')).toBe('NOT busy');
+    expect(clauseWords('!user?.organization_id')).toBe('NOT user?.organization_id');
+    expect(clauseWords('!(isUploadInProgress || elapsed < 5000)')).toBe('NOT (isUploadInProgress || elapsed < 5000)');
+    // `!(a) || b` is not a negated whole: untouched.
+    expect(clauseWords('!(a) || b')).toBe('!(a) || b');
+    expect(clauseWords('(!object?.id || !object?.name)')).toBe('(!object?.id || !object?.name)');
+    expect(clauseWords('selectedDetectionItems.length === 1')).toBe('selectedDetectionItems.length === 1');
+  });
+
+  it('words a whole condition: AND within a scenario, OR between scenarios', () => {
+    expect(whenWords('!(busy || late) && user?.organization_id && !object?.id')).toBe(
+      'NOT (busy || late) AND user?.organization_id AND NOT object?.id'
+    );
+    expect(whenWords('!x && y || !x && !y')).toBe('NOT x AND y OR NOT x AND NOT y');
+    // The same guard met twice along a chain is said once.
+    expect(whenWords('ctl && !(!ctl || done) && !(!ctl || done) && ready')).toBe('ctl AND NOT (!ctl || done) AND ready');
+    expect(scenarios([{ when: 'a && a && b' }]).common).toEqual(['a', 'b']);
+    expect(whenWords('')).toBe('');
+  });
+
+  it('factors the clauses every scenario shares, and keeps each row’s own tail', () => {
+    const sites = [
+      { line: 248, when: '!(busy || late) && !user?.organization_id' },
+      { line: 257, when: '!(busy || late) && user?.organization_id && (!object?.id || !object?.name)' },
+      { line: 292, when: '!(busy || late) && user?.organization_id && !(!object?.id || !object?.name) && items.length === 1' },
+      { line: 306, when: '!(busy || late) && user?.organization_id && !(!object?.id || !object?.name) && !items.length' },
+    ];
+    const sc = scenarios(sites);
+    expect(sc.common).toEqual(['!(busy || late)']);
+    expect(sc.rows.map((r) => r.rest)).toEqual([
+      ['!user?.organization_id'],
+      ['user?.organization_id', '(!object?.id || !object?.name)'],
+      ['user?.organization_id', '!(!object?.id || !object?.name)', 'items.length === 1'],
+      ['user?.organization_id', '!(!object?.id || !object?.name)', '!items.length'],
+    ]);
+    expect(restWords(sc.rows[0]!.rest, true)).toBe('AND NOT user?.organization_id');
+    expect(restWords(sc.rows[2]!.rest, true)).toBe(
+      'AND user?.organization_id AND NOT (!object?.id || !object?.name) AND items.length === 1'
+    );
+  });
+
+  it('one site is one scenario with nothing left to say; no shared prefix says when', () => {
+    expect(scenarios([{ when: 'a && b' }])).toEqual({ common: ['a', 'b'], rows: [{ site: { when: 'a && b' }, rest: [] }] });
+    const sc = scenarios([{ when: 'a' }, { when: 'b' }, { when: '' }]);
+    expect(sc.common).toEqual([]);
+    expect(restWords(sc.rows[0]!.rest, false)).toBe('WHEN a');
+    expect(restWords(sc.rows[2]!.rest, false)).toBe('always');
+    expect(scenarios([])).toEqual({ common: [], rows: [] });
+  });
+});

+ 1 - 1
__tests__/ui-screens-model.test.ts

@@ -190,7 +190,7 @@ describe('edgeLabel', () => {
     const collect = edgeLabel([link('/home', '/capture/collect', `${chain}guide.dontShowAgain.captureGuide`)]);
     const intro = edgeLabel([link('/home', '/guide', `${chain}!guide.dontShowAgain.captureGuide`)]);
     expect(collect).toBe('…guide.dontShowAgain.captureGuide');
-    expect(intro).toBe('…!guide.dontShowAgain.captureGuide');
+    expect(intro).toBe('…NOT guide.dontShowAgain.captureGuide');
     // The whole point: two arms of a fork no longer read the same.
     expect(collect).not.toBe(intro);
   });

+ 16 - 2
__tests__/ui-steps-api.test.ts

@@ -73,6 +73,7 @@ beforeAll(async () => {
       '  const handleZipComplete = useCallback(async (data: { uri: string }) => {\n' +
       '    setZipUri(data.uri)\n' +
       '    await uploadARCapture(data.uri)\n' +
+      "    Alert.alert('Uploaded', data.uri, [{ text: 'OK' }])\n" +
       "    if (unlimited) router.replace('/')\n" +
       '  }, [unlimited])\n' +
       '  useEffect(() => {\n' +
@@ -192,6 +193,7 @@ 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');
     expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
     const evt = link('finalizeCaptureSession', 'handleZipComplete');
@@ -200,14 +202,26 @@ describe('buildSteps', () => {
     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');
+    const storeLink = link('handleZipComplete', 'setZipUri');
+    expect(storeLink?.kind).toBe('store');
+    // Every call-shaped site says what it passes.
+    expect(storeLink?.sites[0]?.args).toBe('data.uri');
+    expect(link('handleApprove', 'finalizeCaptureSession')?.sites[0]?.args).toBe('');
+    // One call behind an effect box: the box says it. Several: the panel does.
+    const alert = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'device')!;
+    expect(alert.label).toBe("Alert.alert('Uploaded', data.uri, […])");
+    expect(network.label).toBe('client.post +1');
+    expect(req?.sites.map((s) => `${s.text}(${s.args})`)).toEqual(["client.post('/frames', { uri })", "client.get('/frames/status')"]);
     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 /');
+    // Every site carries the whole condition it runs under — one scenario each.
+    expect(nav?.sites[0]?.when).toBe('unlimited');
+    expect(evt?.sites[0]?.when).toBe('result');
+    expect(storeLink?.sites[0]?.when).toBe('');
 
     // Rows: the anchor on 0, then one more step away each. The listener is
     // registered BY the screen (`addListener('onZipComplete', handleZipComplete)`),

+ 1 - 1
__tests__/ui-steps-model.test.ts

@@ -70,7 +70,7 @@ describe('steps model', () => {
     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.label).toBe('NOT busy');
     expect(toBridge.kind).toBe('bridge');
     const toEvent = edges.find((e) => e.to === event.id)!;
     expect(toEvent.synthesized).toBe(true);

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

@@ -460,7 +460,13 @@ whole app; so is a native event that lands in a COMPONENT (the capture overlay t
 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
+listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step) also
+carries **what it passes** — `graph/branch-guards.ts`'s `callArgumentsForFile`, read from the same cached tree as the
+guards: string literals and names whole, an object as its keys (`{ email, password }`), arrays `[…]`, functions
+`() => …`, 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
 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
@@ -530,6 +536,19 @@ the same question about the same graph.
   them. **Prepared, not published**: `"private": true` is the guard and `scripts/pack-npm.sh` only packs it under
   `CODEGRAPH_PACK_UI=1`.
 
+### 3.14 Conditions, as a reader says them (`ui/src/lib/conditions.ts`)
+A `when` arrives from the graph as code joined by OUR operators — guards along a chain joined with ` && `, a negated
+guard wrapped `!(…)`, a link's several call sites joined with ` || ` — and those joins render as words: **WHEN**,
+**AND**, **OR**, **NOT**, set in capitals at weight 600 in the condition's own mono (no tracking — they are words in a
+sentence, not labels), so the joins read at a glance and the code between them reads as code. The code inside one
+guard stays code (`isUploadInProgress || elapsed < 5000` is what the source
+says; a guard that is itself a disjunction keeps its parentheses, `graph/branch-guards.ts` adds them). A link with
+several call sites is several **scenarios**, never one long condition: the panel prints the clauses every site shares
+once (`WHEN NOT (busy || late)`), then one row per site with its own tail (`AND NOT user?.organization_id` · site ·
+file:line), or `always`; the connector's pill counts them (`4 ways · 4 conditional`) instead of quoting them. Both the
+Screens and the Steps view use this; a site's `when` on the wire is the whole condition for that site, the link's
+`when` only their summary.
+
 ## 5. Copy rules
 Sentence case; controls say what happens ("Read as flow", "Clear"); counts always visible next to folds; honesty phrases fixed:
 "No test reaches this within 3 caller hops", "Reached by tests · N files within 3 hops", "Uncertain · N name-only matches, confidence < 0.6",

+ 357 - 1
src/graph/branch-guards.ts

@@ -71,13 +71,34 @@ export function guardLabel(guards: readonly BranchGuard[]): string {
 
 function renderGuard(g: BranchGuard): string {
   if (g.form === 'catch') return g.text;
-  if (!g.negated) return g.text;
+  // `if (!object?.id || !object?.name)` joined to the guard before it with
+  // `&&` would read as two conditions: it keeps its parentheses.
+  if (!g.negated) return hasTopLevelOr(g.text) ? `(${g.text})` : g.text;
   // `!x` negated reads back as `x`; a simple operand takes a bare `!`;
   // anything with operators is parenthesised so the negation is unambiguous.
   if (/^!(?![=])/.test(g.text) && isSimpleOperand(g.text.slice(1))) return g.text.slice(1);
   return isSimpleOperand(g.text) ? `!${g.text}` : `!(${g.text})`;
 }
 
+/** A `||` outside every bracket and string — the condition is a disjunction as written. */
+function hasTopLevelOr(text: string): boolean {
+  let depth = 0;
+  let quote: string | null = null;
+  for (let i = 0; i < text.length; i++) {
+    const ch = text[i]!;
+    if (quote !== null) {
+      if (ch === '\\') i++;
+      else if (ch === quote) quote = null;
+      continue;
+    }
+    if (ch === "'" || ch === '"' || ch === '`') quote = ch;
+    else if (ch === '(' || ch === '[' || ch === '{') depth++;
+    else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
+    else if (depth === 0 && ch === '|' && text[i + 1] === '|') return true;
+  }
+  return false;
+}
+
 function isSimpleOperand(text: string): boolean {
   return /^[\w$.?!]+(?:\([^()]*\))?$/.test(text) && !/[=<>]/.test(text);
 }
@@ -233,6 +254,341 @@ export function guardsForFileSync(
 /** The languages with rules here — what {@link warmBranchGuardGrammars} loads. */
 export const BRANCH_GUARD_LANGUAGES: readonly Language[] = ['typescript', 'tsx', 'javascript', 'jsx', 'swift'];
 
+// =============================================================================
+// Call arguments — what a site passes
+// =============================================================================
+
+/** Longest argument list kept before it is cut with an ellipsis. */
+const MAX_ARGS_TEXT = 96;
+/** Longest single argument (a string literal, a name) kept whole. */
+const MAX_ARG_TEXT = 40;
+/** Object keys listed before `…` stands for the rest. */
+const MAX_OBJECT_KEYS = 4;
+const CALL_TYPES: ReadonlySet<string> = new Set(['call_expression', 'new_expression']);
+const ARGUMENT_CONTAINERS: ReadonlySet<string> = new Set(['arguments', 'value_arguments', 'argument_list']);
+const STRING_TYPES: ReadonlySet<string> = new Set([
+  'string',
+  'template_string',
+  'line_string_literal',
+  'multi_line_string_literal',
+  'raw_string_literal',
+]);
+const OBJECT_TYPES: ReadonlySet<string> = new Set(['object', 'object_expression']);
+const ARRAY_TYPES: ReadonlySet<string> = new Set(['array', 'array_literal', 'dictionary_literal']);
+const FUNCTION_TYPES: ReadonlySet<string> = new Set(['arrow_function', 'function_expression', 'function']);
+
+/**
+ * The arguments a call site passes, as written, abbreviated to what a reader
+ * scans for: a string literal whole (a storage key, a URL, a message), a name
+ * whole, an object as its keys (`{ email, password }`), an array as `[…]`, a
+ * function as `() => …`, a nested call as `f(…)`. The conditions say WHEN a
+ * step runs; this says WITH WHAT — `SecureStore.setItemAsync('userEmail',
+ * values.email)` is a different fact from `SecureStore.setItemAsync`.
+ *
+ * Keyed by {@link siteKey} like the guards, read from the same cached tree.
+ * A site that is not inside a call, or a language without rules, is absent.
+ */
+export async function callArgumentsForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, string>> {
+  const out = new Map<string, string>();
+  if (!supportsBranchGuards(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    const text = callArgumentsInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null);
+    if (text !== null) out.set(key, text);
+  }
+  return out;
+}
+
+/** {@link callArgumentsForFile} over source text — the test surface. */
+export async function callArgumentsInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): Promise<string | null> {
+  if (!supportsBranchGuards(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return callArgumentsInTree(tree.rootNode, source, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+export function callArgumentsInTree(
+  root: SyntaxNode,
+  source: string,
+  line: number,
+  column: number | null
+): string | null {
+  const row = line - 1;
+  const col = column ?? firstNonBlankColumn(source, row);
+  const start = innermostAt(root, row, col);
+  if (!start) return null;
+  // The site's position is on the callee (`setItemAsync` in
+  // `SecureStore.setItemAsync(…)`): climb to the call it belongs to. A few
+  // levels cover a member chain; further up would be another statement.
+  let call: SyntaxNode | null = null;
+  let node: SyntaxNode | null = start;
+  for (let up = 0; node && up < 6; up++, node = node.parent) {
+    if (CALL_TYPES.has(node.type)) {
+      call = node;
+      break;
+    }
+  }
+  if (!call) return null;
+  const container = argumentsOf(call);
+  if (!container) return null;
+  if (container.type === 'lambda_literal') return '{ … }';
+  const parts: string[] = [];
+  for (let i = 0; i < container.namedChildCount; i++) {
+    const c = container.namedChild(i);
+    if (!c || c.type === 'comment') continue;
+    parts.push(abbreviateArgument(c, source));
+  }
+  const text = parts.join(', ');
+  return text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text;
+}
+
+/** The node holding a call's arguments: the `arguments` field, a container child, or Swift's `call_suffix` contents. */
+function argumentsOf(call: SyntaxNode): SyntaxNode | null {
+  const field = call.childForFieldName('arguments');
+  if (field) return field;
+  for (let i = 0; i < call.namedChildCount; i++) {
+    const c = call.namedChild(i);
+    if (!c) continue;
+    if (ARGUMENT_CONTAINERS.has(c.type)) return c;
+    if (c.type === 'call_suffix') {
+      for (let j = 0; j < c.namedChildCount; j++) {
+        const inner = c.namedChild(j);
+        if (inner && (ARGUMENT_CONTAINERS.has(inner.type) || inner.type === 'lambda_literal')) return inner;
+      }
+      return c;
+    }
+  }
+  return null;
+}
+
+function abbreviateArgument(node: SyntaxNode, source: string): string {
+  const type = node.type;
+  if (STRING_TYPES.has(type)) return cut(collapse(node.text), MAX_ARG_TEXT);
+  if (OBJECT_TYPES.has(type)) return objectKeys(node, source);
+  if (ARRAY_TYPES.has(type)) return '[…]';
+  if (FUNCTION_TYPES.has(type)) return '() => …';
+  if (type === 'lambda_literal') return '{ … }';
+  if (type === 'spread_element') return cut(collapse(node.text), MAX_ARG_TEXT);
+  if (type === 'await_expression') {
+    const inner = node.namedChild(0);
+    return inner ? `await ${abbreviateArgument(inner, source)}` : 'await …';
+  }
+  if (CALL_TYPES.has(type)) {
+    const callee = node.childForFieldName('function') ?? node.childForFieldName('constructor') ?? node.namedChild(0);
+    const name = callee ? cut(collapse(callee.text), 28) : '';
+    return `${type === 'new_expression' ? 'new ' : ''}${name}(…)`;
+  }
+  // Swift `label: value` — the label is half the meaning (`withName:`).
+  if (type === 'value_argument') {
+    const named: SyntaxNode[] = [];
+    for (let i = 0; i < node.namedChildCount; i++) {
+      const c = node.namedChild(i);
+      if (c) named.push(c);
+    }
+    if (named.length >= 2 && (named[0]!.type === 'simple_identifier' || named[0]!.type === 'value_argument_label')) {
+      return `${named[0]!.text}: ${abbreviateArgument(named[named.length - 1]!, source)}`;
+    }
+    return named.length > 0 ? abbreviateArgument(named[named.length - 1]!, source) : cut(collapse(node.text), MAX_ARG_TEXT);
+  }
+  if (type === 'lambda_argument' || type === 'trailing_closure') return '{ … }';
+  return cut(collapse(node.text), MAX_ARG_TEXT);
+}
+
+/** `{ email, password, …}` — the keys an object literal passes, not its bulk. */
+function objectKeys(node: SyntaxNode, source: string): string {
+  const keys: string[] = [];
+  let more = 0;
+  for (let i = 0; i < node.namedChildCount; i++) {
+    const c = node.namedChild(i);
+    if (!c || c.type === 'comment') continue;
+    let key: string | null = null;
+    if (c.type === 'pair') key = c.childForFieldName('key')?.text ?? null;
+    else if (c.type === 'shorthand_property_identifier' || c.type === 'shorthand_property_identifier_pattern') key = c.text;
+    else if (c.type === 'spread_element') key = collapse(c.text);
+    else if (c.type === 'method_definition') key = c.childForFieldName('name')?.text ?? null;
+    if (key === null) continue;
+    if (keys.length >= MAX_OBJECT_KEYS) {
+      more++;
+      continue;
+    }
+    keys.push(cut(key, 24));
+  }
+  void source;
+  if (keys.length === 0) return '{…}';
+  return `{ ${keys.join(', ')}${more > 0 ? ', …' : ''} }`;
+}
+
+// =============================================================================
+// Triggers — what fires a site
+// =============================================================================
+
+/**
+ * What binds a call site to an event, when something does — the answer to
+ * "at what point does this run": the JSX attribute the site sits under
+ * (`onPress` of `<Button>`), the `on*` option it is written in (`onSubmit`
+ * of `useFormik({…})`), or the runs-later call it is an argument of
+ * (`useEffect`, `setTimeout`, `addListener('x')`, `.then`).
+ */
+export interface SiteTrigger {
+  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;
+}
+
+/** Callees whose function argument runs LATER — a callback, not a call. Matched on the last segment. */
+const LATER_CALLEES: ReadonlySet<string> = new Set([
+  'useEffect',
+  'useLayoutEffect',
+  'useFocusEffect',
+  'useImperativeHandle',
+  'setTimeout',
+  'setInterval',
+  'requestAnimationFrame',
+  'requestIdleCallback',
+  'runAfterInteractions',
+  'addListener',
+  'addEventListener',
+  'on',
+  'once',
+  'subscribe',
+  'then',
+  'catch',
+  'finally',
+  'runOnJS',
+  'runOnUI',
+  'scheduleOnRN',
+]);
+/** The walk up never leaves the function the site belongs to — unless that function is inline. */
+const TRIGGER_BOUNDARIES: ReadonlySet<string> = new Set(['function_declaration', 'method_definition', 'class_declaration', 'class_body', 'program']);
+const MAX_TRIGGER_CLIMB = 24;
+
+export async function triggersForFile(
+  absPath: string,
+  language: Language,
+  sites: readonly CallSite[]
+): Promise<Map<string, SiteTrigger>> {
+  const out = new Map<string, SiteTrigger>();
+  if (!JS_FAMILY.has(language) || sites.length === 0) return out;
+  const cached = await treeFor(absPath, language);
+  if (!cached) return out;
+  for (const site of sites) {
+    const key = siteKey(site);
+    if (out.has(key)) continue;
+    const t = triggerInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null);
+    if (t !== null) out.set(key, t);
+  }
+  return out;
+}
+
+/** {@link triggersForFile} over source text — the test surface. */
+export async function triggerInSource(
+  source: string,
+  language: Language,
+  line: number,
+  column: number | null
+): Promise<SiteTrigger | null> {
+  if (!JS_FAMILY.has(language)) return null;
+  const tree = await parse(source, language);
+  if (!tree) return null;
+  try {
+    return triggerInTree(tree.rootNode, source, line, column);
+  } finally {
+    tree.delete();
+  }
+}
+
+export function triggerInTree(root: SyntaxNode, source: string, line: number, column: number | null): SiteTrigger | null {
+  const row = line - 1;
+  const col = column ?? firstNonBlankColumn(source, row);
+  let node: SyntaxNode | null = innermostAt(root, row, col);
+  let prev: SyntaxNode | null = null;
+  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) {
+      const p = node.parent;
+      if (p.type === 'variable_declarator') return null;
+      if (p.type === 'arguments' && p.parent) {
+        const callee = calleeName(p.parent);
+        if (callee === 'useCallback' || callee === 'useMemo' || callee === 'useEffectEvent' || callee === 'useEvent') return null;
+      }
+    }
+    if (type === 'jsx_attribute') {
+      const name = node.namedChild(0);
+      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 };
+    }
+    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 };
+      }
+    }
+    if (type === 'arguments' && node.parent && CALL_TYPES.has(node.parent.type) && prev !== null) {
+      const callee = calleeName(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;
+        return { kind: 'callback', name: callee, of };
+      }
+    }
+  }
+  return null;
+}
+
+/** The last segment of a call's callee: `nativeEmitter.addListener` → `addListener`. */
+function calleeName(call: SyntaxNode): string | null {
+  const callee = call.childForFieldName('function') ?? call.childForFieldName('constructor');
+  if (!callee) return null;
+  const text = collapseText(callee.text);
+  const m = text.match(/([A-Za-z_$][\w$]*)\s*$/);
+  return m ? m[1]! : text;
+}
+
+function collapseText(text: string): string {
+  return text.replace(/\s+/g, ' ').trim();
+}
+
+function collapse(text: string): string {
+  return collapseText(text);
+}
+
+function cut(text: string, max: number): string {
+  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
+}
+
+function firstNonBlankColumn(source: string, row: number): number {
+  const line = source.split('\n')[row] ?? '';
+  const m = line.match(/\S/);
+  return m ? (m.index ?? 0) : 0;
+}
+
 /** Load the grammars {@link guardsForFileSync} needs; a no-op once loaded, never throws. */
 export async function warmBranchGuardGrammars(only?: readonly Language[]): Promise<void> {
   const wanted = BRANCH_GUARD_LANGUAGES.filter((l) => !only || only.includes(l));

+ 6 - 1
src/ui-server/api/screens.ts

@@ -71,7 +71,11 @@ export interface WireScreenSite {
   href: string;
   /** `push`, `replace`, `navigate`, or `return` for a helper's return value. */
   method: string;
-  /** Branch conditions at this site alone. */
+  /**
+   * The conditions THIS site runs under — the whole chain's plus its own,
+   * joined; '' when unconditional. A link with several sites is several
+   * scenarios; the link's `when` is only their summary.
+   */
   when: string;
 }
 
@@ -243,6 +247,7 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
         if (w && !whens.includes(w)) whens.push(w);
       }
       if (site.when && !whens.includes(site.when)) whens.push(site.when);
+      site.when = whens.join(' && ');
 
       const viaKey = via.map((v) => v.id).join('>');
       if (fromOrigin && start.path[0]!.node.id !== holder.id) {

+ 111 - 23
src/ui-server/api/steps.ts

@@ -40,7 +40,8 @@
 import type CodeGraph from '../../index';
 import type { Edge, Language, Node, UnresolvedReference } from '../../types';
 import { badRequest, intParam, notFound } from './respond';
-import { createWhenReader } from './when';
+import { createSiteReader } from './when';
+import type { SiteTrigger } from '../../graph/branch-guards';
 import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
 
 // =============================================================================
@@ -56,6 +57,26 @@ export interface WireStepSite {
   line: number;
   /** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
   text: string;
+  /**
+   * What the site passes, as written and abbreviated: `'userEmail',
+   * values.email`, `'/auth/login', { email, password }`. '' for an empty
+   * argument list; absent when the source could not be read.
+   */
+  args?: string;
+  /**
+   * The conditions THIS site runs under — the whole chain's, joined; '' when
+   * unconditional. A link with several sites is several scenarios (four
+   * early returns that each go home), and the viewer lists them as rows with
+   * the clauses they share factored out; the link's own `when` is only the
+   * summary of all of them.
+   */
+  when: string;
+}
+
+/** What fires a step or a link: the event it is written under, and the function that writes it there. */
+export interface WireStepTrigger extends SiteTrigger {
+  /** The function the binding is written in — `LoginButton` for its `onPress`. */
+  in: string;
 }
 
 export interface WireStep {
@@ -82,6 +103,8 @@ export interface WireStep {
   event?: string;
   /** Every event that lands on this step, in the order the walk met them. */
   events?: string[];
+  /** For a handler: what fires it — the first binding the walk met. */
+  trigger?: WireStepTrigger;
   /** For a screen: its path and the component that renders it. */
   screen?: { path: string; component: WireNodeRef | null };
   /**
@@ -105,6 +128,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 {
@@ -145,8 +170,10 @@ const MAX_FOLD_DEPTH = 7;
 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;
+/** Call sites read for conditions and arguments per request. */
+const MAX_WHEN_SITES = 1600;
+/** Longest effect-box label before its argument list is cut. */
+const MAX_EFFECT_LABEL = 56;
 /**
  * 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
@@ -252,8 +279,13 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     }
   }
 
-  const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
-  const whenAt = (caller: Node, site: { line?: number; column?: number }) => readWhen(caller, site);
+  const reader = createSiteReader(cg, projectRoot, MAX_WHEN_SITES);
+  const whenAt = (caller: Node, site: { line?: number; column?: number }) => reader.when(caller, site);
+  const argsAt = (caller: Node, site: { line?: number; column?: number }) => reader.args(caller, site);
+  const withArgs = async (site: WireStepSite, caller: Node, at: { line?: number; column?: number }): Promise<WireStepSite> => {
+    const args = await argsAt(caller, at);
+    return args === null ? site : { ...site, args };
+  };
 
   const steps = new Map<string, StepRecord>();
   const links = new Map<string, WireStepLink>();
@@ -343,7 +375,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     chain: Node[],
     whens: string[],
     site: WireStepSite,
-    edge: Edge | null
+    edge: Edge | null,
+    trigger: WireStepTrigger | null = null
   ): void => {
     const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
     const synthesized = edge?.provenance === 'heuristic';
@@ -352,9 +385,11 @@ 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 existing = links.get(id);
     if (existing) {
-      if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(site);
+      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) {
         if (!when || !existing.when) existing.when = '';
         else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
@@ -371,8 +406,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       label: hopLabel(meta, synthesized),
       synthesized,
       uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
-      sites: [site],
+      sites: [stamped],
+      ...(trigger ? { trigger } : {}),
     });
+    if (trigger && to.kind === 'trigger' && !to.trigger) to.trigger = trigger;
+  };
+
+  /** What fires a site, with the function it is written in. */
+  const triggerAt = async (caller: Node, at: { line?: number; column?: number }): Promise<WireStepTrigger | null> => {
+    const t = await reader.trigger(caller, at);
+    return t ? { ...t, in: caller.name } : null;
   };
 
   // The anchor: a screen keeps its kind and explores from its component.
@@ -454,8 +497,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             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);
+            const at = { line: ref.line, column: ref.column };
+            const when = await whenAt(fold.node, at);
+            const site = await withArgs({ file: posix(fold.node.filePath), line: ref.line, text: ref.referenceName, when: '' }, fold.node, at);
+            link(step, target, 'effect', fold.chain, [...fold.whens, when], site, null, await triggerAt(fold.node, at));
           }
         }
 
@@ -486,6 +531,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           kind: WireStepKind | null;
           linkKind: WireStepLinkKind;
           extra: Partial<WireStep>;
+          trigger: WireStepTrigger | null;
         }
         const arrivals: Arrival[] = [];
         for (const e of edges) {
@@ -496,8 +542,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             file: posix(fold.node.filePath),
             line: e.line ?? fold.node.startLine,
             text: siteText(e, meta, target),
+            when: '',
           };
 
+          // What fires this hop, when the site is written under an event:
+          // the JSX prop, the `on*` option, the runs-later call. Read for
+          // every call-shaped hop, so a store action or an effect fired by
+          // a tap says so on its link too.
+          const isCall = e.kind === 'calls' || e.kind === 'instantiates' || (e.kind === 'references' && meta.fnRef === true);
+          const trigger = isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
+
           // What kind of step, if any, this edge arrives at.
           let kind: WireStepKind | null = null;
           let linkKind: WireStepLinkKind = 'calls';
@@ -521,30 +575,44 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             } 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)
             ) {
+              // A store action fired straight from a tap stays a store
+              // action; the tap is on its link.
               kind = 'store';
               linkKind = 'store';
+            } else if (
+              (target.kind === 'function' || target.kind === 'method') &&
+              !looksLikeComponent(target) &&
+              ((e.kind === 'references' && meta.fnRef === true) || trigger !== null)
+            ) {
+              // A handler: a function passed as a value (`onPress={handleX}`,
+              // `addListener('x', handleX)`), or one called from under an
+              // event binding (`onPress={() => handleLogin(values)}`,
+              // `useFormik({ onSubmit: (v) => handleLogin(v) })`). A
+              // component passed as a value (`memo(CaptureComponent)`) is a
+              // render hop and folds like one.
+              kind = 'trigger';
+              linkKind = 'handler';
+              if (trigger) extra.trigger = trigger;
             }
           }
-          arrivals.push({ e, target, meta, site, kind, linkKind, extra });
+          arrivals.push({ e, target, meta, site, kind, linkKind, extra, trigger });
         }
 
         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);
+          const at = { line: a.e.line, column: a.e.column };
+          const when = await whenAt(fold.node, at);
+          // A call-shaped hop says what it passes; a navigation already says
+          // its href, a handler binding and an event channel pass nothing.
+          const site = a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls' ? await withArgs(a.site, fold.node, at) : a.site;
+          link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger);
           if (to.root !== null && !explored.has(to.id)) {
             explored.add(to.id);
             queue.push(to);
@@ -566,8 +634,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
             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);
+              const at = { line: e.line, column: e.column };
+              const when = await whenAt(fold.node, at);
+              const site = await withArgs({ file: posix(fold.node.filePath), line: e.line ?? fold.node.startLine, text: api, when: '' }, fold.node, at);
+              link(step, to, 'effect', fold.chain, [...fold.whens, when], site, null, a.trigger);
               continue;
             }
           }
@@ -576,8 +646,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           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);
+              const at = { line: e.line, column: e.column };
+              const when = await whenAt(fold.node, at);
+              link(step, known, 'calls', fold.chain, [...fold.whens, when], await withArgs(a.site, fold.node, at), e, a.trigger);
             }
             continue;
           }
@@ -605,6 +676,23 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     }
   }
 
+  // An effect box with ONE call behind it says what that call passes —
+  // `axios.post('/auth/login', { email, password })` is the fact a reader
+  // scans for; several calls list themselves in the panel instead.
+  const sitesByStep = new Map<string, WireStepSite[]>();
+  for (const l of links.values()) {
+    const list = sitesByStep.get(l.to) ?? [];
+    list.push(...l.sites);
+    sitesByStep.set(l.to, list);
+  }
+  for (const step of steps.values()) {
+    if (step.kind !== 'effect' || !step.effect || step.effect.apis.length !== 1) continue;
+    const sites = sitesByStep.get(step.id) ?? [];
+    if (sites.length !== 1 || sites[0]!.args === undefined) continue;
+    const label = `${step.effect.api}(${sites[0]!.args})`;
+    step.label = label.length > MAX_EFFECT_LABEL ? `${label.slice(0, MAX_EFFECT_LABEL - 2)}…)` : label;
+  }
+
   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),

+ 64 - 13
src/ui-server/api/when.ts

@@ -12,7 +12,15 @@
 
 import type CodeGraph from '../../index';
 import type { Language } from '../../types';
-import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
+import {
+  callArgumentsForFile,
+  guardLabel,
+  guardsForFile,
+  siteKey,
+  supportsBranchGuards,
+  triggersForFile,
+  type SiteTrigger,
+} from '../../graph/branch-guards';
 import { resolveProjectFile } from '../security';
 import { findIndexedFile, hasDriftedOnDisk } from './source';
 import type { WireEdge } from './wire';
@@ -81,15 +89,25 @@ export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches:
  * 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> {
+export interface SiteReader {
+  /** The conditions the site runs under, joined; '' when unconditional or unreadable. */
+  when(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string>;
+  /** What the site passes, abbreviated (`'userEmail', values.email`); null when unreadable. '' for an empty list. */
+  args(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string | null>;
+  /** What fires the site — the JSX prop, `on*` option or runs-later call it is written under; null when nothing binds it. */
+  trigger(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<SiteTrigger | null>;
+}
+
+/**
+ * Both readings of one call site — WHEN it runs and WITH WHAT — for the
+ * endpoints that walk chains (Screens, Steps). One file resolution and one
+ * parsed tree serve both; drifted files yield nothing; one site budget bounds
+ * the whole pass.
+ */
+export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites = 600): SiteReader {
   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 resolve = (caller: { filePath: string; language: Language }): { abs: string; language: Language } | null => {
     const posix = caller.filePath.replace(/\\/g, '/');
     let file = files.get(posix);
     if (file === undefined) {
@@ -104,10 +122,43 @@ export function createWhenReader(
       }
       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) : '';
+    return file;
   };
+  return {
+    async when(caller, site) {
+      if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
+      const file = resolve(caller);
+      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) : '';
+    },
+    async args(caller, site) {
+      if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;
+      const file = resolve(caller);
+      if (!file) return null;
+      sites++;
+      const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
+      return (await callArgumentsForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
+    },
+    async trigger(caller, site) {
+      // Not counted against the budget: the tree is already parsed for the
+      // site's guards, and a trigger lookup is a walk up from one node.
+      if (!site.line || !supportsBranchGuards(caller.language)) return null;
+      const file = resolve(caller);
+      if (!file) return null;
+      const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
+      return (await triggersForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
+    },
+  };
+}
+
+/** The `when` half of {@link createSiteReader}, for callers that read nothing else. */
+export function createWhenReader(
+  cg: CodeGraph,
+  projectRoot: string,
+  maxSites = 600
+): (caller: { filePath: string; language: Language }, site: { line?: number; column?: number }) => Promise<string> {
+  return createSiteReader(cg, projectRoot, maxSites).when;
 }

+ 188 - 0
ui/src/lib/conditions.ts

@@ -0,0 +1,188 @@
+/**
+ * Conditions, as a reader says them.
+ *
+ * A `when` arrives from the graph as code joined by our own operators: the
+ * guards along a chain joined with ` && `, a negated one wrapped as `!(…)`,
+ * and — on a link with several call sites — the sites' conditions joined
+ * with ` || `. The code inside one guard stays code (`isUploadInProgress ||
+ * elapsed < 5000` is what the source says); the joins are ours, and ours read
+ * as words — WHEN, AND, OR, NOT — set in capitals and a little bolder.
+ *
+ * A link with several sites is several scenarios, not one long condition:
+ * four early returns that each go home are four rows, and the clauses every
+ * row shares — the same first guard on all four — are said once above them.
+ */
+
+/**
+ * The top-level terms of `text` around `sep`, respecting brackets and
+ * strings. `splitTop('a && (b || c)', ' && ')` → `['a', '(b || c)']`.
+ */
+export function splitTop(text: string, sep: ' && ' | ' || '): string[] {
+  const out: string[] = [];
+  let depth = 0;
+  let quote: string | null = null;
+  let start = 0;
+  for (let i = 0; i < text.length; i++) {
+    const ch = text[i]!;
+    if (quote !== null) {
+      if (ch === '\\') i++;
+      else if (ch === quote) quote = null;
+      continue;
+    }
+    if (ch === "'" || ch === '"' || ch === '`') {
+      quote = ch;
+      continue;
+    }
+    if (ch === '(' || ch === '[' || ch === '{') {
+      depth++;
+      continue;
+    }
+    if (ch === ')' || ch === ']' || ch === '}') {
+      depth = Math.max(0, depth - 1);
+      continue;
+    }
+    if (depth === 0 && text.startsWith(sep, i)) {
+      out.push(text.slice(start, i).trim());
+      start = i + sep.length;
+      i += sep.length - 1;
+    }
+  }
+  out.push(text.slice(start).trim());
+  return out.filter((c) => c.length > 0);
+}
+
+/**
+ * The top-level `&&` terms of a condition, in the order they were tested —
+ * the outermost guard first, the one decided at the call last. A condition
+ * joined by a top-level `||` (several scenarios merged) has no single
+ * innermost term and comes back whole.
+ */
+export function clauses(when: string): string[] {
+  if (splitTop(when, ' || ').length > 1) return [when.trim()];
+  return splitTop(when, ' && ');
+}
+
+/**
+ * One word of a condition: a keyword we add (WHEN, AND, OR, NOT — set in
+ * capitals and a little bolder, so the joins read at a glance and the code
+ * between them reads as code), or a run of the code itself.
+ */
+export type WordToken = { kw: true; text: 'WHEN' | 'AND' | 'OR' | 'NOT' } | { kw: false; text: string };
+
+const KW = (text: 'WHEN' | 'AND' | 'OR' | 'NOT'): WordToken => ({ kw: true, text });
+const CODE = (text: string): WordToken => ({ kw: false, text });
+
+/** `!(a || b)` → NOT `(a || b)`; `!busy` → NOT `busy`; code otherwise untouched. */
+export function clauseTokens(clause: string): WordToken[] {
+  const text = clause.trim();
+  if (text.startsWith('!(') && closesAtEnd(text, 1)) return [KW('NOT'), CODE(text.slice(1))];
+  if (/^![A-Za-z_$][\w$.?]*$/.test(text)) return [KW('NOT'), CODE(text.slice(1))];
+  return [CODE(text)];
+}
+
+/** {@link clauseTokens} as one string — for a pill, which has no markup. */
+export function clauseWords(clause: string): string {
+  return joinTokens(clauseTokens(clause));
+}
+
+/** Tokens joined by a keyword: `a AND b AND c`. */
+function joinWith(groups: readonly WordToken[][], kw: 'AND' | 'OR'): WordToken[] {
+  const out: WordToken[] = [];
+  groups.forEach((g, i) => {
+    if (i > 0) out.push(KW(kw));
+    out.push(...g);
+  });
+  return out;
+}
+
+export function joinTokens(tokens: readonly WordToken[]): string {
+  return tokens.map((t) => t.text).join(' ');
+}
+
+/** Whether the bracket opened at `open` closes on the last character. */
+function closesAtEnd(text: string, open: number): boolean {
+  let depth = 0;
+  let quote: string | null = null;
+  for (let i = open; i < text.length; i++) {
+    const ch = text[i]!;
+    if (quote !== null) {
+      if (ch === '\\') i++;
+      else if (ch === quote) quote = null;
+      continue;
+    }
+    if (ch === "'" || ch === '"' || ch === '`') quote = ch;
+    else if (ch === '(') depth++;
+    else if (ch === ')') {
+      depth--;
+      if (depth === 0) return i === text.length - 1;
+    }
+  }
+  return false;
+}
+
+/** The same clause tested twice along a chain (two early returns with one condition) is said once. */
+function distinct(list: readonly string[]): string[] {
+  return list.filter((c, i) => list.indexOf(c) === i);
+}
+
+/** A whole `when` as tokens: scenarios joined by OR, each its guards joined by AND. Empty when unconditional. */
+export function whenTokens(when: string): WordToken[] {
+  if (!when) return [];
+  return joinWith(
+    splitTop(when, ' || ').map((scenario) => joinWith(distinct(splitTop(scenario, ' && ')).map(clauseTokens), 'AND')),
+    'OR'
+  );
+}
+
+/** {@link whenTokens} led by WHEN, or `always` when there is nothing to say. */
+export function conditionTokens(when: string): WordToken[] {
+  const tokens = whenTokens(when);
+  return tokens.length === 0 ? [CODE('always')] : [KW('WHEN'), ...tokens];
+}
+
+/** A whole `when` as one string — for a pill, a tooltip title, a test. */
+export function whenWords(when: string): string {
+  return joinTokens(whenTokens(when));
+}
+
+/** The clauses every scenario shares, led by WHEN. */
+export function commonTokens(common: readonly string[]): WordToken[] {
+  return common.length === 0 ? [] : [KW('WHEN'), ...joinWith(common.map(clauseTokens), 'AND')];
+}
+
+export interface ScenarioRows<T> {
+  /** The clauses every site shares, in chain order — said once. */
+  common: string[];
+  /** One row per site with what remains after the shared clauses; `rest` empty = always, given the shared ones. */
+  rows: Array<{ site: T; rest: string[] }>;
+}
+
+/**
+ * A link's sites as scenarios. One site: its whole condition is `common` and
+ * the one row has nothing left to say. Several: the longest common prefix of
+ * their clause lists is `common`, and each row keeps its own tail.
+ */
+export function scenarios<T extends { when: string }>(sites: readonly T[]): ScenarioRows<T> {
+  const lists = sites.map((site) => ({ site, all: site.when ? distinct(clauses(site.when)) : [] }));
+  if (lists.length === 0) return { common: [], rows: [] };
+  let common = lists[0]!.all.slice();
+  for (const { all } of lists.slice(1)) {
+    let i = 0;
+    while (i < common.length && i < all.length && common[i] === all[i]) i++;
+    common = common.slice(0, i);
+  }
+  return {
+    common,
+    rows: lists.map(({ site, all }) => ({ site, rest: all.slice(common.length) })),
+  };
+}
+
+/** The words a scenario row prints under a shared prefix: `AND x AND y` (`WHEN x` with no prefix), or `always`. */
+export function restTokens(rest: readonly string[], hasCommon: boolean): WordToken[] {
+  if (rest.length === 0) return [CODE('always')];
+  return [KW(hasCommon ? 'AND' : 'WHEN'), ...joinWith(rest.map(clauseTokens), 'AND')];
+}
+
+export function restWords(rest: readonly string[], hasCommon: boolean): string {
+  return joinTokens(restTokens(rest, hasCommon));
+}

+ 13 - 50
ui/src/lib/screens-model.ts

@@ -29,6 +29,7 @@
  */
 
 import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
+import { clauseWords, clauses } from './conditions';
 import {
   buildMapLayout,
   linkId,
@@ -72,9 +73,10 @@ const BAND_MARGIN = 2;
 /**
  * The longest label a pill prints before an ellipsis; the tooltip and the
  * panel have the rest. Sized so the innermost clause of a typical guard
- * (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole.
+ * (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole even as
+ * `…not guide.dontShowAgain.captureGuide` — the negation is a word now.
  */
-export const EDGE_LABEL_MAX = 36;
+export const EDGE_LABEL_MAX = 40;
 
 /* ---------------------------------------------------------------- model -- */
 
@@ -246,48 +248,7 @@ export function entryLayering(
 
 /* --------------------------------------------------------------- labels -- */
 
-/**
- * The top-level `&&` terms of a condition, in the order they were tested —
- * the outermost guard first, the one decided at the navigation call last.
- * Brackets and strings are respected; a condition joined by a top-level `||`
- * (two transitions between one pair that merged) has no innermost term and
- * comes back whole.
- */
-export function clauses(when: string): string[] {
-  const out: string[] = [];
-  let depth = 0;
-  let quote: string | null = null;
-  let start = 0;
-  for (let i = 0; i < when.length; i++) {
-    const ch = when[i]!;
-    if (quote !== null) {
-      if (ch === '\\') i++;
-      else if (ch === quote) quote = null;
-      continue;
-    }
-    if (ch === "'" || ch === '"' || ch === '`') {
-      quote = ch;
-      continue;
-    }
-    if (ch === '(' || ch === '[' || ch === '{') {
-      depth++;
-      continue;
-    }
-    if (ch === ')' || ch === ']' || ch === '}') {
-      depth = Math.max(0, depth - 1);
-      continue;
-    }
-    if (depth !== 0) continue;
-    if (when.startsWith(' || ', i)) return [when.trim()];
-    if (when.startsWith(' && ', i)) {
-      out.push(when.slice(start, i).trim());
-      start = i + 4;
-      i += 3;
-    }
-  }
-  out.push(when.slice(start).trim());
-  return out.filter((c) => c.length > 0);
-}
+export { clauses } from './conditions';
 
 /**
  * What the connector says. Empty when unconditional and single.
@@ -299,17 +260,19 @@ 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: ReadonlyArray<{ when: string }>): string {
-  if (links.length === 1) {
-    const when = links[0]!.when;
+export function edgeLabel(links: ReadonlyArray<{ when: string; sites?: ReadonlyArray<{ when: string }> }>): string {
+  // A link with several call sites is several scenarios: count them as ways.
+  const ways = links.flatMap((l) => (l.sites && l.sites.length > 1 ? l.sites.map((s) => s.when) : [l.when]));
+  if (ways.length === 1) {
+    const when = ways[0]!;
     if (!when) return '';
     const parts = clauses(when);
-    const last = parts[parts.length - 1] ?? when;
+    const last = clauseWords(parts[parts.length - 1] ?? when);
     const text = parts.length > 1 ? `…${last}` : last;
     return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
   }
-  const conditional = links.filter((l) => l.when).length;
-  return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
+  const conditional = ways.filter((w) => w).length;
+  return conditional > 0 ? `${ways.length} ways · ${conditional} conditional` : `${ways.length} ways`;
 }
 
 /* ---------------------------------------------------------------- build -- */

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

@@ -669,6 +669,7 @@ export interface WireScreenSite {
   line: number;
   href: string;
   method: string;
+  /** The conditions THIS site runs under (the whole chain's plus its own); '' when unconditional. */
   when: string;
 }
 
@@ -705,6 +706,10 @@ export interface WireStepSite {
   line: number;
   /** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
   text: string;
+  /** What the site passes, abbreviated (`'userEmail', values.email`); '' for none; absent when unreadable. */
+  args?: string;
+  /** The conditions THIS site runs under (the whole chain's); '' when unconditional. */
+  when: string;
 }
 
 export interface WireStep {

+ 44 - 14
ui/src/views/ScreensView.svelte

@@ -26,6 +26,7 @@
   import { live } from '../lib/live.svelte';
   import { symbolHref, fileHref, stepsHref } from '../lib/navigation';
   import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
+  import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
   import {
     buildScreensModel,
     hoverPill,
@@ -249,7 +250,7 @@
   /** The words a panel row puts on its line: the arrow, and the whole condition. */
   function fullText(link: WireScreenLink): string {
     const arriving = selected !== null && link.to === selected && link.from !== selected;
-    return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
+    return `${arriving ? '←' : '→'} ${whenWords(link.when) || 'always'}`;
   }
 
   function rowHot(link: WireScreenLink): boolean {
@@ -268,6 +269,10 @@
   }
 </script>
 
+{#snippet words(tokens: WordToken[])}
+  {#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b>{:else}{t.text}{/if}{/each}
+{/snippet}
+
 <div class="screens">
   <div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
     {#if error !== null}
@@ -365,7 +370,8 @@
           <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.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 {viaText(link)}</span>{/if}
             </div>
           {/each}
@@ -410,6 +416,7 @@
           </p>
         {/if}
         {#each lists.opensFrom as link (link.id)}
+            {@const sc = scenarios(link.sites)}
           <div
             class="row"
             class:hot={rowHot(link)}
@@ -420,12 +427,16 @@
             onfocusout={() => onRowHover(null)}
           >
             <button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
-            {#if link.when}<div class="when">when {link.when}</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 {viaText(link)}</div>{/if}
-            {#each link.sites as site (site.file + site.line)}
-              <a class="site dim" href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.id, { line: site.line })}
-                >{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
-              >
+            {#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}>
+                {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
+                <a class="site dim" href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.id, { line: row.site.line })}
+                  >{row.site.method} {row.site.href} · {row.site.file.slice(row.site.file.lastIndexOf('/') + 1)}:{row.site.line}</a
+                >
+              </div>
             {/each}
           </div>
         {/each}
@@ -433,6 +444,7 @@
         <h4>Goes to <span class="dim">{lists.goesTo.length}</span></h4>
         {#if lists.goesTo.length === 0}<p class="dim">No navigation leaves this screen.</p>{/if}
         {#each lists.goesTo as link (link.id)}
+            {@const sc = scenarios(link.sites)}
           <div
             class="row"
             class:hot={rowHot(link)}
@@ -443,14 +455,18 @@
             onfocusout={() => onRowHover(null)}
           >
             <button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
-            {#if link.when}<div class="when">when {link.when}</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 {viaText(link)}</div>{/if}
-            {#each link.sites as site (site.file + site.line)}
-              <a
-                class="site dim"
-                href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.screen?.component?.id ?? selectedInfo.id, { line: site.line })}
-                >{site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line}</a
-              >
+            {#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}>
+                {#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
+                <a
+                  class="site dim"
+                  href={symbolHref(link.via[link.via.length - 1]?.id ?? selectedInfo.screen?.component?.id ?? selectedInfo.id, { line: row.site.line })}
+                  >{row.site.method} {row.site.href} · {row.site.file.slice(row.site.file.lastIndexOf('/') + 1)}:{row.site.line}</a
+                >
+              </div>
             {/each}
           </div>
         {/each}
@@ -716,10 +732,24 @@
     font: 400 11.5px var(--mono);
     margin-top: 2px;
   }
+  /* The joins we add — WHEN, AND, OR, NOT — a little bolder than the code between them. */
+  .kw {
+    font-weight: 600;
+  }
   .via {
     font: 400 11px var(--mono);
     margin-top: 2px;
   }
+  .ways {
+    font: 500 11px var(--sans);
+    margin-top: 6px;
+  }
+  /* One scenario per row under a transition: its own tail of conditions, then its site. */
+  .scenario.many {
+    margin: 4px 0 0 8px;
+    padding-left: 8px;
+    border-left: 1px solid var(--rule-soft);
+  }
   .site {
     display: block;
     font: 400 11px var(--mono);

+ 60 - 18
ui/src/views/StepsView.svelte

@@ -31,6 +31,7 @@
   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 { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
   import {
     buildStepsModel,
     kindWord,
@@ -280,7 +281,7 @@
   /** 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'}`;
+    return `${arriving ? '←' : '→'} ${whenWords(link.when) || 'always'}`;
   }
 
   function rowHot(link: WireStepLink): boolean {
@@ -310,8 +311,17 @@
   function basename(file: string): string {
     return file.slice(file.lastIndexOf('/') + 1);
   }
+
+  /** `SecureStore.setItemAsync('userEmail', values.email)` — the site, with what it passes when that could be read. */
+  function siteWords(site: { text: string; args?: string }): string {
+    return site.args === undefined ? site.text : `${site.text}(${site.args})`;
+  }
 </script>
 
+{#snippet words(tokens: WordToken[])}
+  {#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b>{:else}{t.text}{/if}{/each}
+{/snippet}
+
 <div class="steps">
   <div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
     {#if !supported}
@@ -432,9 +442,11 @@
           <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.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>
           {/each}
           {#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
@@ -509,6 +521,8 @@
           <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)}
+            {@const sc = scenarios(link.sites)}
+            {@const fallback = payload.steps.find((s) => s.id === link.from)?.node?.id ?? null}
           <div
             class="row"
             class:hot={rowHot(link)}
@@ -519,16 +533,20 @@
             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 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}
-            {#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}
+            {#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 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>
+                {:else}
+                  <span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
+                {/if}
+              </div>
             {/each}
             {#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
           </div>
@@ -541,6 +559,8 @@
           </p>
         {/if}
         {#each lists.leadsTo as link (link.id)}
+            {@const sc = scenarios(link.sites)}
+            {@const fallback = selectedInfo.step.screen?.component?.id ?? selectedInfo.step.node?.id ?? null}
           <div
             class="row"
             class:hot={rowHot(link)}
@@ -551,16 +571,20 @@
             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 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}
-            {#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}
+            {#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 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>
+                {:else}
+                  <span class="site">{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></span>
+                {/if}
+              </div>
             {/each}
             {#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
           </div>
@@ -817,6 +841,8 @@
   .big {
     font-size: 15px;
     font-weight: 600;
+    /* An effect's label is a call with its arguments — one long token. */
+    overflow-wrap: anywhere;
   }
   .sub {
     display: flex;
@@ -900,15 +926,31 @@
     font: 400 11.5px var(--mono);
     margin-top: 2px;
   }
+  /* The joins we add — WHEN, AND, OR, NOT — a little bolder than the code between them. */
+  .kw {
+    font-weight: 600;
+  }
   .via {
     font: 400 11px var(--mono);
     margin-top: 2px;
   }
+  .ways {
+    font: 500 11px var(--sans);
+    margin-top: 6px;
+  }
+  /* One scenario per row under a link: its own tail of conditions, then its site. */
+  .scenario.many {
+    margin: 4px 0 0 8px;
+    padding-left: 8px;
+    border-left: 1px solid var(--rule-soft);
+  }
   .site {
     display: block;
     font: 400 11px var(--mono);
     margin-top: 2px;
+    color: var(--ink-2);
     text-decoration: none;
+    overflow-wrap: anywhere;
   }
   a.site:hover {
     text-decoration: underline;