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.
This commit is contained in:
Colby McHenry
2026-08-28 10:21:46 -05:00
parent 873f133c96
commit e288d7645b
16 changed files with 1028 additions and 127 deletions
+60 -18
View File
@@ -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;