feat(expo-router): add Expo Router support for screens and navigates

Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
This commit is contained in:
Colby McHenry
2026-08-27 22:52:18 -05:00
parent ac9580544b
commit 70fd5fefc2
42 changed files with 4257 additions and 33 deletions
+588
View File
@@ -0,0 +1,588 @@
<!--
The Screens view (`#/screens`): the app as its user meets it — one box per
screen, an arrow for every way of getting from one to another, and on each
arrow the condition under which it happens.
Everything drawn comes from `/api/screens`: routes the framework resolver
found, `navigates` edges the extractor bound, attribution back through the
render/call chain to the screen a transition starts on, and branch guards
read from the source. The canvas is the Map's machinery with different
words (see `screens-model.ts`); the side panel is where the sentences are.
-->
<script lang="ts">
import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import ScreenNode from '../components/screens/ScreenNode.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
import { fetchScreens, type WireScreensPayload, type WireScreenLink } from '../lib/api';
import { live } from '../lib/live.svelte';
import { symbolHref, fileHref } from '../lib/navigation';
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
import { buildScreensModel, neighbourhood, viaText, type ScreensModel } from '../lib/screens-model';
let payload = $state<WireScreensPayload | null>(null);
let error = $state<string | null>(null);
let loading = $state(true);
let selected = $state<string | null>(null);
let hovered = $state<{ edge: MapEdgeLayout; x: number; y: number } | null>(null);
let stage = $state<HTMLDivElement | null>(null);
// The key stays open until the reader closes it; the choice survives a
// reload but is per browser — a preference, not a fact about the project.
const LEGEND_KEY = 'codegraph-ui:screens-legend';
let legendOpen = $state(readLegendOpen());
function readLegendOpen(): boolean {
try {
return localStorage.getItem(LEGEND_KEY) !== 'closed';
} catch {
return true;
}
}
$effect(() => {
try {
localStorage.setItem(LEGEND_KEY, legendOpen ? 'open' : 'closed');
} catch {
// Storage refused (private mode): the key simply reopens next time.
}
});
const FIT = { fitViewOptions: { padding: 0.1, maxZoom: 1, minZoom: 0.4 } };
const nodeTypes = { screen: ScreenNode };
const edgeTypes = { screen: ScreenEdge };
$effect(() => {
void live.indexTick;
const controller = new AbortController();
loading = true;
error = null;
fetchScreens(controller.signal)
.then((next) => {
payload = next;
loading = false;
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
error = err instanceof Error ? err.message : String(err);
loading = false;
});
return () => controller.abort();
});
const model = $derived<ScreensModel | null>(
payload === null || !payload.routed ? null : buildScreensModel(payload)
);
const neighbours = $derived.by(() => {
if (model === null || selected === null) return null;
const set = new Set<string>([selected]);
for (const edge of model.layout.edges) {
if (edge.source === selected) set.add(edge.target);
if (edge.target === selected) set.add(edge.source);
}
return set;
});
const nodes = $derived.by<Node[]>(() => {
if (model === null) return [];
return model.layout.nodes.map((node) => ({
id: node.id,
type: 'screen',
position: { x: node.x, y: node.y },
draggable: false,
selectable: false,
connectable: false,
data: {
layout: node,
info: model.nodes.get(node.id)!,
selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => {
selected = selected === id ? null : id;
hovered = null;
},
},
}));
});
const edges = $derived.by<Edge[]>(() => {
if (model === null) return [];
return model.layout.edges
.filter((edge) => isEdgeVisible(edge, selected))
.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'screen',
selectable: false,
deletable: false,
data: {
edge,
info: model.edges.get(edge.id)!,
hot: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
dimmed: selected !== null && edge.source !== selected && edge.target !== selected,
// A label only where it can be read as belonging to one line: the
// selected screen's own edges (near that screen) and the hovered
// edge (near its source). Unselected, the picture is lines and
// boxes; the conditions are one click away.
labelled: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
labelAt: selected !== null && edge.target === selected && edge.source !== selected ? 0.72 : 0.28,
onHover: onEdgeHover,
},
}));
});
const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
const lists = $derived(
selected === null || payload === null ? null : neighbourhood(payload, selected)
);
const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
if (edge === null || event === null || stage === null) {
hovered = null;
return;
}
const box = stage.getBoundingClientRect();
hovered = {
edge,
x: Math.min(event.clientX - box.left + 14, box.width - 360),
y: event.clientY - box.top + 14,
};
}
function nameOf(id: string): string {
return model?.nodes.get(id)?.label ?? id;
}
/** The row the panel prints for one transition, seen from `side`. */
function sentence(link: WireScreenLink, side: 'from' | 'to'): string {
const other = side === 'from' ? nameOf(link.from) : nameOf(link.to);
return other;
}
</script>
<div class="screens">
<div class="stage" bind:this={stage}>
{#if error !== null}
<div class="state">
<h2>The screens could not be read</h2>
<p>{error}</p>
</div>
{:else if loading && payload === null}
<div class="state"><p class="dim">Reading screens and transitions…</p></div>
{:else if payload !== null && !payload.routed}
<div class="state">
<h2>No screen navigation in this graph</h2>
<p>
This view draws the routes a UI framework binds to components and the navigation calls
that reach them. The index has {payload.screens.length === 0 ? 'no routes' : 'routes'} but no
navigation between them — it is not an app with screens, or its router is one CodeGraph
does not read yet.
</p>
</div>
{:else if model !== null}
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
{edgeTypes}
fitView
{...FIT}
minZoom={0.2}
maxZoom={1.6}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
proOptions={{ hideAttribution: true }}
onpaneclick={() => {
selected = null;
hovered = null;
}}
>
<Controls position="bottom-right" showLock={false} />
</SvelteFlow>
<!-- The key, on the picture it explains. Each row draws the actual
stroke or box, not a word for it — a reader matches shapes, not
descriptions. Collapsible, remembered per browser. -->
<div class="legend" class:open={legendOpen}>
<button class="legend-h" onclick={() => (legendOpen = !legendOpen)} aria-expanded={legendOpen}>
Key <span class="dim">{legendOpen ? '▾' : '▸'}</span>
</button>
{#if legendOpen}
<div class="legend-body">
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>Transition — the destination is written at the call</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-synth" /></svg>
<span>Destination inferred from a helper's return value</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
<span>Goes back up the picture (returning)</span>
</div>
<div class="lrow">
<span class="k-label mono">when x</span>
<span>Runs only under that condition (shown on the selected screen's lines); none = always</span>
</div>
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen — its path and the component that renders it</span>
</div>
<div class="lrow">
<span class="k-box k-entry mono"><span class="mark">●</span>/</span>
<span>The entry screen; each row down is one more transition away</span>
</div>
<div class="lrow">
<span class="k-box k-origin mono">fn()</span>
<span>Not a screen: shared chrome, or a trigger no screen reaches</span>
</div>
<div class="lrow">
<span class="k-box k-unreached mono">/path</span>
<span>Nothing reaches it from the entry (bottom band)</span>
</div>
</div>
{/if}
</div>
{#if hovered !== null && hoveredInfo !== null}
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
<div class="mono"><b>{nameOf(hoveredInfo.from)}</b> → {nameOf(hoveredInfo.to)}</div>
{#each hoveredInfo.links.slice(0, 5) as link (link.id)}
<div class="tiprow">
{#if link.when}<span class="when">when {link.when}</span>{:else}<span class="dim">always</span>{/if}
{#if link.via.length > 0}<span class="mono dim">via {viaText(link)}</span>{/if}
</div>
{/each}
{#if hoveredInfo.links.length > 5}<div class="dim">+{hoveredInfo.links.length - 5} more</div>{/if}
</div>
{/if}
{/if}
</div>
{#if payload !== null && model !== null}
<aside class="side">
{#if selectedInfo !== null && lists !== null}
<div class="head">
<div>
<div class="mono big">{selectedInfo.label}</div>
{#if selectedInfo.screen?.component}
<a class="sub" href={symbolHref(selectedInfo.screen.component.id)}>
<KindGlyph kind={selectedInfo.screen.component.kind} />
{selectedInfo.screen.component.name}
</a>
{:else if selectedInfo.origin}
<span class="sub dim">navigates from outside any screen</span>
{/if}
{#if selectedInfo.screen}
<a class="sub dim" href={fileHref(selectedInfo.screen.file)}>{selectedInfo.screen.file}</a>
{/if}
</div>
<button class="clear" onclick={() => (selected = null)}>clear</button>
</div>
<h4>Opens from <span class="dim">{lists.opensFrom.length}</span></h4>
{#if lists.opensFrom.length === 0}
<p class="dim">
{selectedInfo.entry ? 'The entry screen — the app starts here.' : 'Nothing in the graph navigates here.'}
</p>
{/if}
{#each lists.opensFrom as link (link.id)}
<div class="row">
<button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
{#if link.when}<div class="when">when {link.when}</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
>
{/each}
</div>
{/each}
<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)}
<div class="row">
<button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
{#if link.when}<div class="when">when {link.when}</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
>
{/each}
</div>
{/each}
{:else}
<div class="head"><div class="big">Screens</div></div>
<p>
<b>{payload.screens.length}</b> screens · <b>{payload.links.length}</b> transitions{#if payload.origins.length > 0}
· <b>{payload.origins.length}</b> triggered outside a screen{/if}.
</p>
<p class="dim">
<span class="mark">●</span> The entry screen is at the top; each row down is one more
transition away from it. An arrow's label is the condition under which that transition
happens, read from the code around the navigation call; hover it for the chain the call
travels through, click a screen for everything in and out of it.
</p>
<p class="dim">
Solid: the destination is written at the call. Dashed grey: it comes back from a
helper's return value (inferred). Dashed accent: a transition back up the picture
(returning). Dashed box: a trigger that is not a screen — shared chrome, or code no
screen's render chain reaches.
</p>
{#if model.unreached > 0}
<p class="dim">
<b>{model.unreached}</b> screen{model.unreached === 1 ? '' : 's'} in the band at the bottom: no
transition in the graph reaches {model.unreached === 1 ? 'it' : 'them'} from the entry — opened
by something the graph cannot see (a layout's initial route, a deep link), or unused.
</p>
{/if}
{#if payload.dropped > 0}
<p class="dim">{payload.dropped} navigation{payload.dropped === 1 ? '' : 's'} could not be attributed: the walk back to a screen hit a hub.</p>
{/if}
<h4>Most connected</h4>
{#each [...payload.screens].sort((a, b) => b.incoming + b.outgoing - (a.incoming + a.outgoing)).slice(0, 8) as screen (screen.id)}
<button class="peer mono" onclick={() => (selected = screen.id)}
>{screen.path} <span class="dim">←{screen.incoming} →{screen.outgoing}</span></button
>
{/each}
{/if}
</aside>
{/if}
</div>
<style>
.screens {
display: grid;
grid-template-columns: minmax(600px, 1fr) 340px;
height: 100%;
min-height: 0;
}
.stage {
position: relative;
overflow: hidden;
background: var(--paper);
}
.stage :global(.svelte-flow) {
background: var(--paper);
}
.stage :global(.svelte-flow__handle) {
opacity: 0;
width: 1px;
height: 1px;
min-width: 0;
min-height: 0;
border: 0;
pointer-events: none;
}
.stage :global(.svelte-flow__controls-button) {
background: var(--paper);
border: 0;
border-bottom: 1px solid var(--rule-soft);
border-radius: 0;
color: var(--ink-2);
}
.stage :global(.svelte-flow__controls-button svg) {
fill: var(--ink-2);
}
.state {
padding: 48px 40px;
max-width: 560px;
}
.state h2 {
font: 600 20px var(--sans);
margin: 0 0 8px;
}
.legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
max-width: 380px;
border: 1px solid var(--rule);
background: var(--paper);
font-size: 11.5px;
color: var(--ink-2);
}
.legend-h {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 5px 10px;
text-align: left;
color: var(--ink);
font: 600 12px var(--sans);
cursor: pointer;
}
.legend-body {
padding: 2px 10px 8px;
border-top: 1px solid var(--rule-soft);
}
.lrow {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
.lrow > :first-child {
flex: 0 0 44px;
display: inline-flex;
justify-content: center;
}
.k-line {
stroke: var(--ink);
stroke-opacity: 0.6;
stroke-width: 1.5;
fill: none;
}
.k-line.k-synth {
stroke-dasharray: 5 3;
}
.k-line.k-back {
stroke: var(--accent);
stroke-opacity: 0.8;
stroke-dasharray: 4 3;
}
.k-label {
font-size: 10.5px;
color: var(--ink-3);
}
.k-box {
box-sizing: border-box;
padding: 1px 5px;
border: 1px solid var(--ink);
font-size: 10.5px;
color: var(--ink);
line-height: 14px;
}
.k-box.k-origin {
border-style: dashed;
border-color: var(--ink-3);
}
.k-box.k-unreached {
border-color: var(--ink-4);
color: var(--ink-2);
}
.k-entry .mark {
font-size: 8px;
margin-right: 3px;
vertical-align: 1px;
}
.tip {
position: absolute;
z-index: 5;
width: 340px;
padding: 8px 10px;
border: 1px solid var(--ink);
background: var(--paper);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
font-size: 12px;
pointer-events: none;
}
.tiprow {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--rule-soft);
}
.side {
border-left: 1px solid var(--rule);
padding: 14px 16px;
overflow: auto;
font-size: 12.5px;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
}
.big {
font-size: 15px;
font-weight: 600;
}
.sub {
display: flex;
align-items: center;
gap: 5px;
margin-top: 3px;
color: var(--ink-2);
text-decoration: none;
}
.sub:hover {
text-decoration: underline;
}
.clear {
border: 1px solid var(--rule);
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 11.5px;
padding: 1px 7px;
cursor: pointer;
}
h4 {
margin: 16px 0 6px;
font: 600 12.5px var(--sans);
}
.row {
padding: 7px 0;
border-top: 1px solid var(--rule-soft);
}
.peer {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 2px 0;
text-align: left;
color: var(--ink);
font: 500 12.5px var(--mono);
cursor: pointer;
}
.peer:hover {
text-decoration: underline;
}
.when {
color: var(--ink);
font: 400 11.5px var(--mono);
margin-top: 2px;
}
.via {
font: 400 11px var(--mono);
margin-top: 2px;
}
.site {
display: block;
font: 400 11px var(--mono);
margin-top: 2px;
text-decoration: none;
}
.site:hover {
text-decoration: underline;
}
.mono {
font-family: var(--mono);
}
.dim {
color: var(--ink-3);
}
.mark {
color: var(--accent);
}
</style>