From 5e06204deb279ab4b0ad48583165ab0cd0a7786c Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 28 Aug 2026 10:40:38 -0500 Subject: [PATCH] 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. --- CHANGELOG.md | 2 +- __tests__/branch-guards.test.ts | 80 ++++++++++++++++++++++++- __tests__/ui-steps-api.test.ts | 23 ++++++- __tests__/ui-steps-model.test.ts | 11 +++- docs/design/codegraph-ui-design-spec.md | 12 +++- src/graph/branch-guards.ts | 45 +++++++++----- src/ui-server/api/steps.ts | 9 ++- ui/src/lib/steps-model.ts | 26 +++++++- ui/src/lib/wire.ts | 17 ++++++ ui/src/views/ScreensView.svelte | 10 +++- ui/src/views/StepsView.svelte | 48 ++++++++++++--- 11 files changed, 245 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f53b5c..4e1ce71 100644 --- a/CHANGELOG.md +++ b/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 · {#if sc.common.length > 0}
{@render words(commonTokens(sc.common))}
{/if} - {#if link.via.length > 0}
via {viaText(link)}
{/if} + {#if link.via.length > 0}
via {viaText(link)}
{/if} {#if sc.rows.length > 1}
{sc.rows.length} ways
{/if} {#each sc.rows as row (row.site.file + row.site.line)}
1}> @@ -456,7 +456,7 @@ > {#if sc.common.length > 0}
{@render words(commonTokens(sc.common))}
{/if} - {#if link.via.length > 0}
via {viaText(link)}
{/if} + {#if link.via.length > 0}
via {viaText(link)}
{/if} {#if sc.rows.length > 1}
{sc.rows.length} ways
{/if} {#each sc.rows as row (row.site.file + row.site.line)}
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 { diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte index 196e7b0..3f890f8 100644 --- a/ui/src/views/StepsView.svelte +++ b/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 @@
/path - A screen, or a handler — a function wired to a tap or a listener + A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event
⇢ fn @@ -442,9 +443,10 @@
{nameOf(hoveredInfo.from)} → {nameOf(hoveredInfo.to)}
{#each hoveredInfo.links.slice(0, 5) as link (link.id)}
+ {#if link.trigger}FIRES FROM {triggerWords(link.trigger)} in {link.trigger.in}{/if} + {#if link.via.length > 0}via {stepViaText(link)}{/if} {#if link.sites.length > 1}{link.sites.length} ways{/if} {@render words(conditionTokens(link.when))} - {#if link.via.length > 0}via {stepViaText(link)}{/if} {#if link.label}{link.label}{/if} {#if link.sites[0]}{siteWords(link.sites[0])}{/if}
@@ -462,6 +464,9 @@
{selectedInfo.label}
{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}
+ {#if selectedInfo.step.trigger} +
FIRES FROM {triggerWords(selectedInfo.step.trigger)} in {selectedInfo.step.trigger.in}
+ {/if} {#if selectedInfo.step.screen?.component} @@ -533,13 +538,17 @@ onfocusout={() => onRowHover(null)} > + {#if link.trigger}
FIRES FROM {triggerWords(link.trigger)} in {link.trigger.in}
{/if} + {#if link.via.length > 0}
via {stepViaText(link)}
{/if} {#if sc.common.length > 0}
{@render words(commonTokens(sc.common))}
{/if} - {#if link.via.length > 0}
via {stepViaText(link)}
{/if} {#if link.label}
{link.label}
{/if} {#if sc.rows.length > 1}
{sc.rows.length} ways
{/if} {#each sc.rows as row (row.site.file + row.site.line)} {@const href = siteHref(link, row.site, fallback)}
1}> + {#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')} +
FIRES FROM {triggerWords(row.site.trigger)}
+ {/if} {#if sc.rows.length > 1}
{@render words(restTokens(row.rest, sc.common.length > 0))}
{/if} {#if href}
{siteWords(row.site)} · {basename(row.site.file)}:{row.site.line} @@ -571,13 +580,17 @@ onfocusout={() => onRowHover(null)} > + {#if link.trigger}
FIRES FROM {triggerWords(link.trigger)} in {link.trigger.in}
{/if} + {#if link.via.length > 0}
via {stepViaText(link)}
{/if} {#if sc.common.length > 0}
{@render words(commonTokens(sc.common))}
{/if} - {#if link.via.length > 0}
via {stepViaText(link)}
{/if} {#if link.label}
{link.label}
{/if} {#if sc.rows.length > 1}
{sc.rows.length} ways
{/if} {#each sc.rows as row (row.site.file + row.site.line)} {@const href = siteHref(link, row.site, fallback)}
1}> + {#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')} +
FIRES FROM {triggerWords(row.site.trigger)}
+ {/if} {#if sc.rows.length > 1}
{@render words(restTokens(row.rest, sc.common.length > 0))}
{/if} {#if href} {siteWords(row.site)} · {basename(row.site.file)}:{row.site.line} @@ -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 {