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

Introduce Expo Router integration by adding a new Screens view and API to surface screens and their transitions. Implement a map-based layout with directional ports, extended layering and port pitch to accommodate edge labels, and a pill-based labeling system for transition conditions. Include tests for the new map/screens models, updates to the UI components, and design/docs changes reflecting the Screens design. Merge CodeGraph UI viewer changes to render and interact with Expo Router-based screen graphs. This enables CodeGraph UI to surface screens and navigations from Expo Router apps.
This commit is contained in:
Colby McHenry
2026-08-28 00:19:46 -05:00
parent 70fd5fefc2
commit f0eafe31f9
10 changed files with 1893 additions and 194 deletions
+188 -39
View File
@@ -8,9 +8,16 @@
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.
At rest the picture is boxes and lines. Selecting a screen labels its
transitions — each pill at the FAR end of its line, beside the other screen,
in a lane the model chose so that no two overlap — and lists them in the
panel; the panel and the picture point at each other, so a row under the
pointer lights its line and prints its whole condition on it. On the canvas
the pointer means the line NEAREST it, not the one drawn last under it.
-->
<script lang="ts">
import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import ScreenNode from '../components/screens/ScreenNode.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
@@ -19,14 +26,29 @@
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';
import {
buildScreensModel,
hoverPill,
nearestEdge,
neighbourhood,
pairId,
placeLabels,
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);
/** The panel row under the pointer: its edge on the canvas, and the one transition it names. */
let panelHot = $state<{ edge: string; link: WireScreenLink } | null>(null);
let stage = $state<HTMLDivElement | null>(null);
/** Svelte Flow's pan and zoom, for turning a pointer position into a point on the canvas. */
let viewport = $state<Viewport | undefined>(undefined);
/** How close, in screen pixels, the pointer must be to a line to mean it. */
const HOVER_REACH = 10;
// 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.
@@ -83,6 +105,18 @@
return set;
});
// The labels move with the selection and with nothing else: hovering a
// line or a row must not reflow the pills the reader is looking at.
const pills = $derived(model === null ? null : placeLabels(model, selected));
/** The edge in focus: under the pointer on the canvas, or its row in the panel. */
const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
/** A pill for the focused edge when the selection gave it none. */
const focusPill = $derived.by(() => {
if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
const full = panelHot?.edge === focusId ? fullText(panelHot.link) : undefined;
return hoverPill(model, focusId, selected, full, pills ?? undefined);
});
const nodes = $derived.by<Node[]>(() => {
if (model === null) return [];
return model.layout.nodes.map((node) => ({
@@ -100,6 +134,7 @@
onSelect: (id: string) => {
selected = selected === id ? null : id;
hovered = null;
panelHot = null;
},
},
}));
@@ -107,31 +142,39 @@
const edges = $derived.by<Edge[]>(() => {
if (model === null) return [];
const focus = focusId;
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,
},
}));
.map((edge) => {
const touches = selected !== null && (edge.source === selected || edge.target === selected);
const isFocus = focus === edge.id;
const hot = isFocus || touches;
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: 'screen',
selectable: false,
deletable: false,
// The line in focus, and its pill, over the others; the selected
// screen's over the rest.
zIndex: isFocus ? 3 : hot ? 2 : 1,
data: {
edge,
info: model.edges.get(edge.id)!,
curve: model.curves.get(edge.id)!,
hot,
soft: hot && focus !== null && !isFocus,
focus: isFocus,
dimmed: selected !== null && !touches,
pill: pills?.pills.get(edge.id) ?? (isFocus ? focusPill : null),
full: panelHot?.edge === edge.id ? fullText(panelHot.link) : null,
onHover: onEdgeHover,
},
};
});
});
const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
@@ -140,6 +183,11 @@
);
const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
const edgeById = $derived(
model === null ? new Map<string, MapEdgeLayout>() : new Map(model.layout.edges.map((e) => [e.id, e]))
);
const visibleIds = $derived(new Set(edges.map((e) => e.id)));
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
if (edge === null || event === null || stage === null) {
hovered = null;
@@ -153,6 +201,62 @@
};
}
/**
* The pointer on the canvas means the line nearest it. A pill speaks for
* its own line; over a box, the key or the tooltip there is no line.
*/
function onStageMove(event: MouseEvent): void {
if (model === null || stage === null) return;
const target = event.target as Element | null;
if (target?.closest('.spill')) return;
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
hovered = null;
return;
}
const view = viewport ?? readViewport();
if (!view) return;
const box = stage.getBoundingClientRect();
const point = {
x: (event.clientX - box.left - view.x) / view.zoom,
y: (event.clientY - box.top - view.y) / view.zoom,
};
const hit = nearestEdge(model, point, visibleIds, HOVER_REACH / view.zoom);
const edge = hit === null ? undefined : edgeById.get(hit.id);
if (!edge) {
hovered = null;
return;
}
hovered = {
edge,
x: Math.min(event.clientX - box.left + 14, box.width - 360),
y: event.clientY - box.top + 14,
};
}
/** The transform Svelte Flow applied, for the moment before the binding has a value. */
function readViewport(): Viewport | null {
const el = stage?.querySelector<HTMLElement>('.svelte-flow__viewport');
const m = el?.style.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)\s*scale\(([-\d.]+)\)/);
return m ? { x: Number(m[1]), y: Number(m[2]), zoom: Number(m[3]) } : null;
}
/** The row under the pointer: light its line, and say the whole condition on it. */
function onRowHover(link: WireScreenLink | null): void {
const edge = link === null ? null : pairId(link);
panelHot = link === null || edge === null ? null : { edge, link };
}
/** 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'}`;
}
function rowHot(link: WireScreenLink): boolean {
if (panelHot !== null) return panelHot.link.id === link.id;
return hovered !== null && pairId(link) === hovered.edge.id;
}
function nameOf(id: string): string {
return model?.nodes.get(id)?.label ?? id;
}
@@ -165,7 +269,7 @@
</script>
<div class="screens">
<div class="stage" bind:this={stage}>
<div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
{#if error !== null}
<div class="state">
<h2>The screens could not be read</h2>
@@ -191,8 +295,9 @@
{edgeTypes}
fitView
{...FIT}
bind:viewport
minZoom={0.2}
maxZoom={1.6}
maxZoom={3}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
@@ -201,6 +306,7 @@
onpaneclick={() => {
selected = null;
hovered = null;
panelHot = null;
}}
>
<Controls position="bottom-right" showLock={false} />
@@ -225,11 +331,14 @@
</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>
<span>Goes back up the picture (returning) — leaves the top of its box, arrives at the bottom of the other</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>
<span class="k-label mono">→ …x</span>
<span>
The last condition checked before the transition, beside the screen at the other end of
the selected screen's line; ← when it arrives there. None = always
</span>
</div>
<div class="lrow">
<span class="k-box mono">/path</span>
@@ -241,7 +350,7 @@
</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>
<span>Not a screen: shared chrome, or a trigger no screen reaches — a row above what it opens</span>
</div>
<div class="lrow">
<span class="k-box k-unreached mono">/path</span>
@@ -286,6 +395,12 @@
</div>
<button class="clear" onclick={() => (selected = null)}>clear</button>
</div>
{#if pills !== null && pills.hidden > 0}
<p class="dim note">
{pills.hidden} condition{pills.hidden === 1 ? '' : 's'} not drawn on the picture for want of
room — hover a row below to see {pills.hidden === 1 ? 'it' : 'each'} on its line.
</p>
{/if}
<h4>Opens from <span class="dim">{lists.opensFrom.length}</span></h4>
{#if lists.opensFrom.length === 0}
@@ -294,7 +409,15 @@
</p>
{/if}
{#each lists.opensFrom as link (link.id)}
<div class="row">
<div
class="row"
class:hot={rowHot(link)}
role="presentation"
onmouseenter={() => onRowHover(link)}
onmouseleave={() => onRowHover(null)}
onfocusin={() => onRowHover(link)}
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 link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
@@ -309,7 +432,15 @@
<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">
<div
class="row"
class:hot={rowHot(link)}
role="presentation"
onmouseenter={() => onRowHover(link)}
onmouseleave={() => onRowHover(null)}
onfocusin={() => onRowHover(link)}
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 link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
@@ -330,15 +461,16 @@
</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.
transition away from it. Click a screen and each of its transitions is labelled at the far
end of its line — beside the screen it leads to or comes from — with the last condition
checked before it happens; hover the line, or its row here, for the whole chain and the
calls it travels through.
</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.
(returning), drawn around the boxes rather than through them. 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">
@@ -385,6 +517,11 @@
border: 0;
pointer-events: none;
}
/* The label layer covers the canvas; only the pills in it take the pointer,
never the empty paper between them — the lines underneath do. */
.stage :global(.svelte-flow__edge-labels) {
pointer-events: none;
}
.stage :global(.svelte-flow__controls-button) {
background: var(--paper);
border: 0;
@@ -536,13 +673,25 @@
padding: 1px 7px;
cursor: pointer;
}
.note {
margin: 0 0 6px;
}
h4 {
margin: 16px 0 6px;
font: 600 12.5px var(--sans);
}
/* A row is also a pointer at its line: hovering it lights the line and
prints the whole condition on it, and the line under the pointer on the
canvas tints its row here. Bled to the panel's edges so the tint reads
as a row, not a box inside one. */
.row {
padding: 7px 0;
padding: 7px 8px;
margin: 0 -8px;
border-top: 1px solid var(--rule-soft);
transition: background 90ms linear;
}
.row.hot {
background: var(--press);
}
.peer {
display: block;