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

Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
Colby McHenry
2026-08-28 09:46:50 -05:00
parent f0eafe31f9
commit 873f133c96
36 changed files with 3711 additions and 73 deletions
+3
View File
@@ -8,6 +8,7 @@
import FileCodeView from './views/FileCodeView.svelte';
import MapView from './views/MapView.svelte';
import ScreensView from './views/ScreensView.svelte';
import StepsView from './views/StepsView.svelte';
import FlowView from './views/FlowView.svelte';
import EntryView from './views/EntryView.svelte';
import DeadCodeView from './views/DeadCodeView.svelte';
@@ -175,6 +176,8 @@
<EntryView project={project.name} />
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
<ScreensView />
{:else if route.view === 'steps'}
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} />
{:else if route.view === 'dead'}
<DeadCodeView exported={route.exported} />
{:else if route.view === 'unknown'}
+2 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { router, mapHref, flowHref, entryHref, screensHref, deadHref, symbolHref } from '../lib/router.svelte';
import { router, mapHref, flowHref, entryHref, screensHref, stepsHref, deadHref, symbolHref } from '../lib/router.svelte';
import { trail } from '../lib/trail.svelte';
import SearchPalette from './SearchPalette.svelte';
import { live } from '../lib/live.svelte';
@@ -68,6 +68,7 @@
<nav class="views" aria-label="Views">
{#if showScreens}<a href={screensHref()} class:active={view === 'screens' || view === 'home'}>Screens</a>{/if}
<a href={stepsHref()} class:active={view === 'steps'}>Steps</a>
<a href={entryHref()} class:active={view === 'entry'}>Entry points</a>
<a href={mapHref()} class:active={view === 'map'}>Map</a>
<a href={symbolTabHref} class:active={view === 'symbol' || (view === 'home' && !showScreens)}>Symbol</a>
+3 -2
View File
@@ -22,14 +22,15 @@
*/
import { BaseEdge, EdgeLabel, type EdgeProps } from '@xyflow/svelte';
import type { MapEdgeLayout } from '../../lib/map-model';
import { pathOf, type Curve, type PillPlacement, type ScreenEdgeInfo } from '../../lib/screens-model';
import { pathOf, type Curve, type PillPlacement } from '../../lib/screens-model';
let { data }: EdgeProps = $props();
const d = $derived(
data as unknown as {
edge: MapEdgeLayout;
info: ScreenEdgeInfo;
/** The Screens view's edge info, or the Steps view's — only `synthesized` is read. */
info: { synthesized: boolean };
curve: Curve;
/** One of the selected screen's, or under the pointer. */
hot: boolean;
+184
View File
@@ -0,0 +1,184 @@
<script lang="ts">
/**
* One step on the Steps view. The box is the Screens view's screen box with
* a kind: a screen is drawn exactly as there; a handler is a plain box; a
* native call or a native event carries an accent rule on its left, where
* the language changes under the code; a store action sits on `--paper-2`;
* a call that leaves the index is dashed, like a trigger no screen reaches
* on the Screens view — a place the graph cannot follow into. The anchor
* carries the entry mark. A step the walk was cut at ends its name with an
* ellipsis, and its tooltip says which cap.
*
* Hidden handles along the top and bottom, one per port the layout decided
* (`directional` ports), exactly as the screen box.
*/
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import type { MapNodeLayout } from '../../lib/map-model';
import { kindWord, type StepNodeInfo } from '../../lib/steps-model';
let { data }: NodeProps = $props();
const node = $derived(
data as unknown as {
layout: MapNodeLayout;
info: StepNodeInfo;
selected: boolean;
dimmed: boolean;
onSelect: (id: string) => void;
}
);
const layout = $derived(node.layout);
const info = $derived(node.info);
const step = $derived(info.step);
const cutNote = $derived.by(() => {
switch (step.cut) {
case 'depth':
return ' More happens past the depth of this picture — start here to see it.';
case 'fan-out':
return ' It reaches more than the walk follows from one node.';
case 'folded':
return ' The walk folded as much plumbing as it allows from one step.';
case 'steps':
return ' The picture reached its size limit here.';
case 'screen':
return ' Another screen — a chapter of its own. Start here to see what happens on it.';
case 'component':
return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
default:
return '';
}
});
function portStyle(index: number, total: number): string {
return `left:${((index + 1) / (total + 1)) * 100}%`;
}
</script>
{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
<Handle
type={port.type}
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
position={Position.Top}
style={portStyle(i, layout.ports.top.length)}
isConnectable={false}
/>
{/each}
<button
class={`snode k-${step.kind}`}
class:sel={node.selected}
class:dimmed={node.dimmed}
class:anchor={step.anchor}
style={`width:${layout.width}px;height:${layout.height}px`}
onclick={() => node.onSelect(info.id)}
aria-pressed={node.selected}
title={`${info.label}${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind)}. ${info.sub}.${cutNote}`}
>
<span class="name"
>{#if step.anchor}<span class="mark" aria-hidden="true"></span>{/if}{info.label}{#if step.cut !== null}<span
class="more"
aria-hidden="true"></span
>{/if}</span
>
<span class="sub">{info.sub}</span>
</button>
{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
<Handle
type={port.type}
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
position={Position.Bottom}
style={portStyle(i, layout.ports.bottom.length)}
isConnectable={false}
/>
{/each}
<style>
.snode {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
box-sizing: border-box;
padding: 0 9px;
border: 1px solid var(--ink);
border-radius: 0;
background: var(--paper);
text-align: left;
cursor: pointer;
font: inherit;
color: var(--ink);
transition: background 90ms linear;
}
.snode:hover,
.snode.sel {
border-width: 2px;
padding: 0 8px;
background: var(--press);
}
.snode.dimmed {
border-color: var(--ink-4);
color: var(--ink-4);
}
.snode.dimmed .sub {
color: var(--ink-4);
}
/* The language changes under the code: a rule where it does. */
.snode.k-bridge,
.snode.k-event {
border-left: 3px solid var(--accent);
padding-left: 7px;
}
.snode.k-bridge:hover,
.snode.k-bridge.sel,
.snode.k-event:hover,
.snode.k-event.sel {
border-left-width: 3px;
padding-left: 7px;
}
.snode.k-bridge.dimmed,
.snode.k-event.dimmed {
border-left-color: var(--accent-line);
}
.snode.k-store {
background: var(--paper-2);
}
.snode.k-store:hover,
.snode.k-store.sel {
background: var(--press);
}
/* Outside the index: a place the graph cannot follow into. */
.snode.k-effect {
border-style: dashed;
border-color: var(--ink-3);
}
.snode:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.name {
font: 500 13px var(--mono);
line-height: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mark {
color: var(--accent);
margin-right: 5px;
font-size: 9px;
vertical-align: 1px;
}
.more {
color: var(--ink-3);
}
.sub {
font: 400 11px var(--sans);
line-height: 13px;
color: var(--ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+26
View File
@@ -36,6 +36,7 @@ import type {
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireStepsPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -178,6 +179,16 @@ export interface LiveHandlers {
error(): void;
}
/** What happens from an anchor: by id, or by name (the first screen-like match). */
export interface StepsRequest {
anchor?: string;
symbol?: string;
depth?: number;
limit?: number;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through?: boolean;
}
/* -------------------------------------------------------------- adapter -- */
/**
@@ -213,6 +224,11 @@ export interface GraphAdapter {
map(request?: MapRequest, signal?: AbortSignal): Promise<WireMapPayload>;
/** The app's screens and the transitions between them, with their conditions. */
screens(signal?: AbortSignal): Promise<WireScreensPayload>;
/**
* What happens from a screen or a symbol, as typed steps. Optional: a host
* that has not wired it renders the Steps view as absent-and-explained.
*/
steps?(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload>;
/** The URL → handler map. */
routes(request?: RoutesRequest, signal?: AbortSignal): Promise<WireRoutes>;
/** Where a reader starts: routes, files that run something, tests, hubs. */
@@ -400,6 +416,16 @@ export function createHttpAdapter(options: HttpAdapterOptions = {}): GraphAdapte
return getJson<WireScreensPayload>('api/screens', signal);
},
steps(request = {}, signal) {
const params = new URLSearchParams();
if (request.anchor) params.set('anchor', request.anchor);
else if (request.symbol) params.set('symbol', request.symbol);
if (request.depth) params.set('depth', String(request.depth));
if (request.limit) params.set('limit', String(request.limit));
if (request.through) params.set('through', '1');
return getJson<WireStepsPayload>(`api/steps${query(params)}`, signal);
},
entryPoints(request = {}, signal) {
const params = new URLSearchParams();
if (request.limit) params.set('limit', String(request.limit));
+21 -1
View File
@@ -20,6 +20,7 @@ import type {
WireFlowPayload,
WireMapPayload,
WireScreensPayload,
WireStepsPayload,
WireNodeRefs,
WireRoutes,
WireSearch,
@@ -28,7 +29,7 @@ import type {
WireSymbolPayload,
WireTrails,
} from './wire';
import type { SaveTrailRequest } from './adapter';
import type { SaveTrailRequest, StepsRequest } from './adapter';
export * from './wire';
export { ApiFailure } from './adapter';
@@ -44,6 +45,7 @@ export type {
SaveTrailRequest,
SearchRequest,
SourceRequest,
StepsRequest,
} from './adapter';
export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
@@ -146,6 +148,24 @@ export function fetchScreens(signal?: AbortSignal): Promise<WireScreensPayload>
return getGraphAdapter().screens(signal);
}
/**
* What happens from an anchor — a screen, a handler, any symbol — as typed
* steps with the conditions between them. Refused, not thrown at random, by
* an adapter that never offered it (see {@link canDrawSteps}).
*/
export function fetchSteps(request: StepsRequest, signal?: AbortSignal): Promise<WireStepsPayload> {
const adapter = getGraphAdapter();
if (typeof adapter.steps !== 'function') {
return Promise.reject(new ApiFailure(0, 'refused', 'This viewer cannot draw steps.', null));
}
return adapter.steps(request, signal);
}
/** Whether the installed adapter can answer {@link fetchSteps} at all. */
export function canDrawSteps(): boolean {
return typeof getGraphAdapter().steps === 'function';
}
export function fetchMap(
opts: { root?: string | null; depth?: number } = {},
signal?: AbortSignal
+24
View File
@@ -55,6 +55,16 @@ export interface FlowHrefOptions {
trail?: string;
}
export interface StepsHrefOptions {
/** A node id — a screen's route, a handler, any symbol. */
anchor?: string;
/** A name, when no id is at hand; the answering side picks the most screen-like match. */
symbol?: string;
depth?: number;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through?: boolean;
}
/**
* Where the components send the reader.
*
@@ -69,6 +79,7 @@ export interface NavigationDriver {
flowHref(opts?: FlowHrefOptions): string;
entryHref(): string;
screensHref(): string;
stepsHref(opts?: StepsHrefOptions): string;
deadHref(opts?: DeadCodeHrefOptions): string;
/** Go to an href this driver built. */
navigate(href: string, opts?: { replace?: boolean }): void;
@@ -138,6 +149,15 @@ export const hashNavigation: NavigationDriver = {
return '#/screens';
},
stepsHref(opts = {}) {
const params = new URLSearchParams();
if (opts.anchor) params.set('anchor', opts.anchor);
else if (opts.symbol) params.set('symbol', opts.symbol);
if (opts.depth) params.set('depth', String(opts.depth));
if (opts.through) params.set('through', '1');
return `#/steps${query(params)}`;
},
deadHref(opts = {}) {
const params = new URLSearchParams();
if (opts.exported) params.set('exported', '1');
@@ -221,6 +241,10 @@ export function screensHref(): string {
return driver.screensHref();
}
export function stepsHref(opts: StepsHrefOptions = {}): string {
return driver.stepsHref(opts);
}
export function deadHref(opts: DeadCodeHrefOptions = {}): string {
return driver.deadHref(opts);
}
+22
View File
@@ -12,6 +12,7 @@
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
* #/entry entry points (where a flow starts)
* #/screens screens (the app's screens and transitions)
* #/steps steps (?anchor=<id> | ?symbol=<name>: what happens from there)
* #/dead dead code (?exported=1 widens the claim)
*
* Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -43,6 +44,7 @@ export {
navigate,
screensHref,
setNavigationDriver,
stepsHref,
symbolHref,
} from './navigation';
export type {
@@ -51,6 +53,7 @@ export type {
FlowHrefOptions,
MapHrefOptions,
NavigationDriver,
StepsHrefOptions,
SymbolHrefOptions,
} from './navigation';
@@ -77,6 +80,14 @@ export type Route =
}
| { view: 'entry' }
| { view: 'screens' }
| {
view: 'steps';
/** The anchor by id; null with `symbol` set, or on the bare tab. */
anchor: string | null;
symbol: string | null;
depth: number | null;
through: boolean;
}
| {
view: 'dead';
/** Symbols reachable from outside the index are on the list. */
@@ -141,6 +152,17 @@ export function parseHash(hash: string): RouterLocation {
route = { view: 'entry' };
} else if (head === 'screens' && rest.length === 0) {
route = { view: 'screens' };
} else if (head === 'steps' && rest.length === 0) {
// The anchor travels in the URL, so "what happens on the review screen"
// is a link that reopens as the same picture.
const depth = Number.parseInt(params.get('depth') ?? '', 10);
route = {
view: 'steps',
anchor: params.get('anchor'),
symbol: params.get('symbol'),
depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null,
through: params.get('through') === '1',
};
} else if (head === 'dead' && rest.length === 0) {
// The widening travels in the URL like the map's shape does: a link to
// "including exported symbols" has to reopen the same list.
+19 -6
View File
@@ -123,6 +123,19 @@ export interface Point {
y: number;
}
/**
* What the label placement and the pointer need from a picture: the Screens
* view's model, or any other drawn with its machinery (the Steps view draws
* typed steps with the same layout, curves, pills and hit-testing).
*/
export interface Picture {
layout: MapLayout;
layerGap: number;
edges: Map<string, { label: string }>;
curves: Map<string, Curve>;
polylines: Map<string, Point[]>;
}
/* ------------------------------------------------------------- layering -- */
/**
@@ -286,7 +299,7 @@ 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: readonly WireScreenLink[]): string {
export function edgeLabel(links: ReadonlyArray<{ when: string }>): string {
if (links.length === 1) {
const when = links[0]!.when;
if (!when) return '';
@@ -630,7 +643,7 @@ export interface EdgeHit {
* the smaller id, so two visits agree.
*/
export function nearestEdge(
model: ScreensModel,
model: Picture,
point: Point,
among: ReadonlySet<string> | null,
reach: number
@@ -713,7 +726,7 @@ export function laneCount(layerGap: number): number {
* the selected screen — `→` leaving it, `←` arriving — and the edge's label.
* Empty when the edge has nothing to say (a single, unconditional transition).
*/
export function pillText(info: ScreenEdgeInfo, edge: MapEdgeLayout, selected: string | null): string {
export function pillText(info: { label: string }, edge: MapEdgeLayout, selected: string | null): string {
if (!info.label) return '';
const arriving = selected !== null && edge.target === selected && edge.source !== selected;
return `${arriving ? '←' : '→'} ${info.label}`;
@@ -737,7 +750,7 @@ function intersects(a: Rect, b: Rect, gapX: number): boolean {
* lane is free — or when `lanes` is 1 and that lane is taken.
*/
function layPill(
model: ScreensModel,
model: Picture,
edge: MapEdgeLayout,
end: 'source' | 'target',
text: string,
@@ -781,7 +794,7 @@ function layPill(
* pill: the pill for a hovered edge that is not the selected screen's is
* placed separately by {@link hoverPill}.
*/
export function placeLabels(model: ScreensModel, selected: string | null): PillLayout {
export function placeLabels(model: Picture, selected: string | null): PillLayout {
const pills = new Map<string, PillPlacement>();
if (selected === null) return { pills, hidden: 0 };
const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
@@ -827,7 +840,7 @@ export function placeLabels(model: ScreensModel, selected: string | null): PillL
* with the whole condition, not the connector's short label.
*/
export function hoverPill(
model: ScreensModel,
model: Picture,
edgeId: string,
selected: string | null,
text?: string,
+238
View File
@@ -0,0 +1,238 @@
/**
* The Steps view's model — what happens from an anchor, as typed steps laid
* out so that a step sits above the steps it sets in motion.
*
* Everything geometric is the Screens view's (`screens-model.ts`): the Map's
* layout with directional ports, a curve per edge on a track of its own, the
* pills that label a selected step's links at the far end of each line, and
* the nearest-line pointer. What is this file's own is small: the row a step
* sits on is its distance from the anchor, which the server already counted
* (`WireStep.depth`), so the layering is a lookup rather than a search; the
* words in a box come from the step's kind; and the side panel's two lists
* are the links into and out of the selected step.
*/
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepsPayload } from './wire';
import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
import {
edgeLabel,
samplePolyline,
trackedCurves,
SCREEN_LAYER_GAP,
type Curve,
type Picture,
type Point,
} from './screens-model';
export interface StepNodeInfo {
id: string;
step: WireStep;
/** What the box prints on its first line. */
label: string;
/** …and on its second. */
sub: string;
}
export interface StepEdgeInfo {
id: string;
from: string;
to: string;
/** Every link between the pair — one connector, several stories. */
links: WireStepLink[];
/** The connector's short label: the innermost condition, or how many links. */
label: string;
/** Every link behind it was synthesized (a dynamic-dispatch bridge). */
synthesized: boolean;
/** The kind the links agree on, or `calls` when they differ. */
kind: WireStepLink['kind'];
}
export interface StepsModel extends Picture {
layout: MapLayout;
nodes: Map<string, StepNodeInfo>;
edges: Map<string, StepEdgeInfo>;
layerGap: number;
curves: Map<string, Curve>;
polylines: Map<string, Point[]>;
/** Steps per kind, for the panel's summary. */
counts: Record<WireStep['kind'], number>;
}
/** Points a curve is sampled at for hit-testing (as the Screens view's). */
const HIT_SAMPLES = 24;
/* ---------------------------------------------------------------- words -- */
/** A short word for a step's kind, as the panel and the legend say it. */
export function kindWord(kind: WireStep['kind']): string {
switch (kind) {
case 'screen':
return 'screen';
case 'trigger':
return 'handler';
case 'bridge':
return 'native call';
case 'event':
return 'native event';
case 'store':
return 'store action';
case 'effect':
return 'outside the index';
default:
return 'start';
}
}
/** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
export function stepLabel(step: WireStep): string {
switch (step.kind) {
case 'bridge':
return `${step.label}`;
case 'event': {
const events = step.events ?? (step.event ? [step.event] : []);
if (events.length === 0) return `${step.label}`;
return events.length === 1 ? `${events[0]}` : `${events[0]} +${events.length - 1}`;
}
default:
return step.label;
}
}
/** The second line: what the step is, then where it is. */
export function stepSub(step: WireStep): string {
const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
switch (step.kind) {
case 'screen':
return step.sub;
case 'trigger':
return `handler · ${file}`;
case 'bridge':
return `native · ${file}`;
case 'event':
return `${step.label} · ${file}`;
case 'store':
return `store · ${file}`;
case 'effect':
return step.sub;
default:
return step.sub;
}
}
/* ---------------------------------------------------------------- build -- */
export function buildStepsModel(payload: WireStepsPayload): StepsModel {
const nodes = new Map<string, StepNodeInfo>();
const modules: WireMapModule[] = [];
const counts: Record<WireStep['kind'], number> = {
anchor: 0,
screen: 0,
trigger: 0,
bridge: 0,
event: 0,
store: 0,
effect: 0,
};
const degree = new Map<string, number>();
for (const link of payload.links) {
degree.set(link.from, (degree.get(link.from) ?? 0) + 1);
degree.set(link.to, (degree.get(link.to) ?? 0) + 1);
}
for (const step of payload.steps) {
counts[step.kind]++;
const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step) };
nodes.set(step.id, info);
modules.push({
id: step.id,
label: info.label,
files: 1,
symbols: degree.get(step.id) ?? 0,
languages: [],
test: false,
generated: 0,
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
});
}
// One layout link per (from, to); the links behind it stay listed.
const byPair = new Map<string, WireStepLink[]>();
for (const link of payload.links) {
if (!nodes.has(link.from) || !nodes.has(link.to) || link.from === link.to) continue;
const key = linkId({ source: link.from, target: link.to });
const list = byPair.get(key) ?? [];
list.push(link);
byPair.set(key, list);
}
const links: WireMapLink[] = [];
const edges = new Map<string, StepEdgeInfo>();
for (const [key, group] of byPair) {
const first = group[0]!;
links.push({
source: first.from,
target: first.to,
count: group.length,
declared: group.length,
byKind: [{ kind: 'calls', count: group.length }],
topPairs: [],
});
edges.set(key, {
id: key,
from: first.from,
to: first.to,
links: group,
label: edgeLabel(group),
synthesized: group.every((l) => l.synthesized),
kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
});
}
// Layer = distance from the anchor, counted by the server. Layer 0 is the
// bottom, so the deepest row is 0 and the anchor is on top.
const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
const layering = (ids: string[]): Map<string, number> =>
new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
const layout = buildMapLayout(
{ modules, links },
{
includeTests: true,
minWeight: 0,
sizing: (m) => {
const info = nodes.get(m.id);
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
},
layering,
layerGap: SCREEN_LAYER_GAP,
portPitch: PORT_PITCH,
ports: 'directional',
}
);
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
const polylines = new Map<string, Point[]>();
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
}
/** The side panel's two lists for a selected step. */
export function stepNeighbourhood(
payload: WireStepsPayload,
id: string
): { arrivesFrom: WireStepLink[]; leadsTo: WireStepLink[] } {
return {
arrivesFrom: payload.links.filter((l) => l.to === id),
leadsTo: payload.links.filter((l) => l.from === id),
};
}
/** `useReviewHandlers → handleApproveAllImages`, or '' when nothing was folded. */
export function stepViaText(link: WireStepLink): string {
return link.via.map((v) => v.name).join(' → ');
}
/** The layout edge a link draws as, or null when it is a self-loop. */
export function stepPairId(link: WireStepLink): string | null {
return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
}
+70
View File
@@ -694,6 +694,76 @@ export interface WireScreensPayload {
timing: { elapsedMs: number };
}
/* ------------------------------------------------------------------ steps -- */
export type WireStepKind = 'anchor' | 'screen' | 'trigger' | 'bridge' | 'event' | 'store' | 'effect';
export type WireStepLinkKind = 'calls' | 'navigates' | 'handler' | 'bridge' | 'event' | 'store' | 'effect';
export interface WireStepSite {
file: string;
line: number;
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
text: string;
}
export interface WireStep {
/** The node's id, or `effect:<function id>:<api>` for a call leaving the index. */
id: string;
kind: WireStepKind;
/** The step the picture starts from. A screen anchor keeps `kind: 'screen'`. */
anchor: boolean;
/** Null only for an effect, which is a call site rather than a symbol. */
node: WireNodeRef | null;
label: string;
sub: string;
/** Steps from the anchor: the row. */
depth: number;
/**
* Why the walk did not go on from this step: a cap (`depth`, `fan-out`,
* `folded`, `steps`), or `screen` — another screen, drawn as a boundary.
*/
cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
/** The event name a native event step arrived on — the first, when several land here. */
event?: string;
/** Every event that lands on this step. */
events?: string[];
screen?: { path: string; component: WireNodeRef | null };
/** The calls one function makes into one category, and the function. */
effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
}
export interface WireStepLink {
id: string;
from: string;
to: string;
kind: WireStepLinkKind;
/** The symbols folded between the two steps, in order. */
via: WireNodeRef[];
/** Conditions along the whole chain, joined; '' when unconditional. */
when: string;
/** How the last hop was established when it was not a plain call. */
label: string;
synthesized: boolean;
uncertain: boolean;
sites: WireStepSite[];
}
export interface WireStepsPayload {
anchor: WireNodeRef;
/** Other symbols that share the anchor's name, when it was given by name. */
ambiguous: WireNodeRef[];
steps: WireStep[];
links: WireStepLink[];
depth: number;
limit: number;
/** Screens reached from the anchor were entered rather than drawn as boundaries. */
through: boolean;
truncated: { steps: number; hubs: number; chrome: number };
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
/* -------------------------------------------------------------- dead code -- */
/** One symbol nothing in the index reaches. */
+5 -1
View File
@@ -24,7 +24,7 @@
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 { symbolHref, fileHref, stepsHref } from '../lib/navigation';
import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
import {
buildScreensModel,
@@ -392,6 +392,7 @@
{#if selectedInfo.screen}
<a class="sub dim" href={fileHref(selectedInfo.screen.file)}>{selectedInfo.screen.file}</a>
{/if}
<a class="sub act" href={stepsHref({ anchor: selectedInfo.id })}>What happens here →</a>
</div>
<button class="clear" onclick={() => (selected = null)}>clear</button>
</div>
@@ -664,6 +665,9 @@
.sub:hover {
text-decoration: underline;
}
.act {
color: var(--accent);
}
.clear {
border: 1px solid var(--rule);
background: transparent;
+928
View File
@@ -0,0 +1,928 @@
<!--
The Steps view (`#/steps?anchor=…`): what happens from here. One box per
step — a screen, a handler, a call into native code, a native event landing
back in JS, a store action, a call that leaves the index — an arrow for
every way one leads to the next, and on each arrow the condition under
which it happens, with the plumbing between two steps folded into the arrow
and listed in the panel.
Everything drawn comes from `/api/steps`: the anchor's forward walk through
calls, renders, handler bindings and navigations, classified as it goes, and
branch guards read from the source. The canvas is the Screens view's
machinery with a different node universe (see `steps-model.ts`); the side
panel is where the sentences are, and where a step becomes the next anchor
or a Flow strip between two steps.
-->
<script lang="ts">
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import StepNode from '../components/steps/StepNode.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
import {
canDrawSteps,
fetchScreens,
fetchSteps,
type WireScreen,
type WireStepLink,
type WireStepsPayload,
} from '../lib/api';
import { live } from '../lib/live.svelte';
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 {
buildStepsModel,
kindWord,
stepNeighbourhood,
stepPairId,
stepViaText,
type StepsModel,
} from '../lib/steps-model';
interface Props {
anchor: string | null;
symbol: string | null;
depth: number | null;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through: boolean;
}
let { anchor, symbol, depth, through }: Props = $props();
let payload = $state<WireStepsPayload | 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 link it names. */
let panelHot = $state<{ edge: string; link: WireStepLink } | null>(null);
let stage = $state<HTMLDivElement | null>(null);
let viewport = $state<Viewport | undefined>(undefined);
const HOVER_REACH = 10;
/** The chooser's list, when the view opens without an anchor. */
let screens = $state<WireScreen[] | null>(null);
const LEGEND_KEY = 'codegraph-ui:steps-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 = { step: StepNode };
const edgeTypes = { screen: ScreenEdge };
const DEPTHS = [4, 6, 8, 10, 12];
const asked = $derived(anchor !== null || symbol !== null);
const supported = canDrawSteps();
$effect(() => {
void live.indexTick;
const request =
anchor !== null
? { anchor, depth: depth ?? undefined, through }
: symbol !== null
? { symbol, depth: depth ?? undefined, through }
: null;
const controller = new AbortController();
selected = null;
hovered = null;
panelHot = null;
if (request === null) {
payload = null;
loading = false;
error = null;
fetchScreens(controller.signal)
.then((next) => {
screens = next.routed ? next.screens : [];
})
.catch(() => {
screens = [];
});
return () => controller.abort();
}
loading = true;
error = null;
fetchSteps(request, 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<StepsModel | null>(payload === null ? null : buildStepsModel(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 pills = $derived(model === null ? null : placeLabels(model, selected));
const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
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) => ({
id: node.id,
type: 'step',
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;
panelHot = null;
},
},
}));
});
const edges = $derived.by<Edge[]>(() => {
if (model === null) return [];
const focus = focusId;
return model.layout.edges
.filter((edge) => isEdgeVisible(edge, selected))
.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,
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));
const lists = $derived(selected === null || payload === null ? null : stepNeighbourhood(payload, selected));
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)));
/** The same picture with one setting changed: the anchor as the URL asked for it, the rest kept. */
function rewrite(changes: { depth?: number; through?: boolean }): string {
const opts = {
anchor: anchor ?? undefined,
symbol: anchor === null ? (symbol ?? undefined) : undefined,
depth: changes.depth ?? depth ?? undefined,
through: changes.through ?? through,
};
return stepsHref(opts);
}
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 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,
};
}
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;
}
function onRowHover(link: WireStepLink | null): void {
const edge = link === null ? null : stepPairId(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: WireStepLink): string {
const arriving = selected !== null && link.to === selected && link.from !== selected;
return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
}
function rowHot(link: WireStepLink): boolean {
if (panelHot !== null) return panelHot.link.id === link.id;
return hovered !== null && stepPairId(link) === hovered.edge.id;
}
function nameOf(id: string): string {
return model?.nodes.get(id)?.label ?? id;
}
/** A Flow strip between the two symbols of a link, when both are symbols. */
function stripHref(link: WireStepLink): string | null {
const from = payload?.steps.find((s) => s.id === link.from)?.node;
const to = payload?.steps.find((s) => s.id === link.to)?.node;
if (!from || !to) return null;
return flowHref({ from: from.name, to: to.name });
}
/** The symbol a site's line belongs to: the last folded symbol, else the step's own. */
function siteHref(link: WireStepLink, site: { file: string; line: number }, fallback: string | null): string | null {
const last = link.via[link.via.length - 1];
const id = last?.id ?? fallback;
return id === null ? null : symbolHref(id, { line: site.line });
}
function basename(file: string): string {
return file.slice(file.lastIndexOf('/') + 1);
}
</script>
<div class="steps">
<div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
{#if !supported}
<div class="state">
<h2>This viewer cannot draw steps</h2>
<p>The host it runs in has not wired the steps question. The Screens and Flow views still work.</p>
</div>
{:else if !asked}
<div class="state chooser">
<h2>What happens from where?</h2>
<p>
Pick a screen and this view draws everything it sets in motion — its handlers, the calls that
cross into native code, the events that come back, the state it writes, the requests that leave
the app — one box per step, an arrow for every way one leads to the next, and on each arrow the
condition under which it happens. Or search a symbol and choose <i>What happens from here</i>.
</p>
{#if screens === null}
<p class="dim">Reading screens…</p>
{:else if screens.length === 0}
<p class="dim">
No screens in this graph. Open a symbol from the search box and follow <i>What happens from here</i>,
or link here directly with <span class="mono">#/steps?symbol=&lt;name&gt;</span>.
</p>
{:else}
<div class="chooser-list">
{#each [...screens].sort((a, b) => b.outgoing + b.incoming - (a.outgoing + a.incoming) || a.path.localeCompare(b.path)) as screen (screen.id)}
<a class="pick mono" href={stepsHref({ anchor: screen.id })}
>{screen.path} <span class="dim sans">{screen.component?.name ?? basename(screen.file)}</span></a
>
{/each}
</div>
{/if}
</div>
{:else if error !== null}
<div class="state">
<h2>The steps could not be read</h2>
<p>{error}</p>
</div>
{:else if loading && payload === null}
<div class="state"><p class="dim">Walking from the anchor…</p></div>
{:else if model !== null && payload !== null}
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
{edgeTypes}
fitView
{...FIT}
bind:viewport
minZoom={0.2}
maxZoom={3}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
proOptions={{ hideAttribution: true }}
onpaneclick={() => {
selected = null;
hovered = null;
panelHot = null;
}}
>
<Controls position="bottom-right" showLock={false} />
</SvelteFlow>
<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">
<span class="k-box k-anchor mono"><span class="mark"></span>start</span>
<span>Where the picture starts; each row down is one more step away</span>
</div>
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function wired to a tap or a listener</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>Leads to — the plumbing between the two is folded into the line</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>Established by a synthesized hop (an event channel, a callback, 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 — leaves the top of its box, arrives at the bottom of the other</span>
</div>
<div class="lrow">
<span class="k-label mono">→ …x</span>
<span>The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always</span>
</div>
<div class="lrow">
<span class="k-label mono">name …</span>
<span>Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on</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 {stepViaText(link)}</span>{/if}
{#if link.label}<span class="dim">{link.label}</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>
<div class="sub dim">{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
{#if selectedInfo.step.screen?.component}
<a class="sub" href={symbolHref(selectedInfo.step.screen.component.id)}>
<KindGlyph kind={selectedInfo.step.screen.component.kind} />
{selectedInfo.step.screen.component.name}
</a>
{:else if selectedInfo.step.node && selectedInfo.step.kind !== 'screen'}
<a class="sub" href={symbolHref(selectedInfo.step.node.id)}>
<KindGlyph kind={selectedInfo.step.node.kind} />
{selectedInfo.step.node.name}
</a>
{/if}
{#if selectedInfo.step.effect}
<a class="sub" href={symbolHref(selectedInfo.step.effect.by.id, { line: selectedInfo.step.effect.line })}>
<KindGlyph kind={selectedInfo.step.effect.by.kind} />
{selectedInfo.step.effect.by.name} · line {selectedInfo.step.effect.line}
</a>
{/if}
{#if selectedInfo.step.node}
<a class="sub dim" href={fileHref(selectedInfo.step.node.file)}>{selectedInfo.step.node.file}</a>
{/if}
{#if selectedInfo.step.node && !selectedInfo.step.anchor}
<a class="sub act" href={stepsHref({ anchor: selectedInfo.step.node.id })}>Start here →</a>
{/if}
</div>
<button class="clear" onclick={() => (selected = null)}>clear</button>
</div>
{#if selectedInfo.step.cut === 'screen'}
<p class="dim note">Another screen — a chapter of its own. Start here to see what happens on it, or continue through screens from the summary.</p>
{:else if selectedInfo.step.cut === 'component'}
<p class="dim note">The event lands in a component of another screen — a picture of its own. Start here to see it, or continue through screens from the summary.</p>
{:else if selectedInfo.step.cut !== null}
<p class="dim note">
The walk was cut at this step ({selectedInfo.step.cut === 'depth'
? 'the pictures depth'
: selectedInfo.step.cut === 'fan-out'
? 'more calls than the walk follows from one node'
: selectedInfo.step.cut === 'folded'
? 'as much plumbing as it folds from one step'
: 'the pictures size'}). Start here to see on.
</p>
{/if}
{#if selectedInfo.step.effect && selectedInfo.step.effect.apis.length > 1}
<p class="dim note mono">{selectedInfo.step.effect.apis.join(' · ')}</p>
{/if}
{#if selectedInfo.step.events && selectedInfo.step.events.length > 1}
<p class="dim note mono">{selectedInfo.step.events.join(' · ')}</p>
{/if}
{#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>Arrives from <span class="dim">{lists.arrivesFrom.length}</span></h4>
{#if lists.arrivesFrom.length === 0}
<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)}
<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)}>{nameOf(link.from)}</button>
{#if link.when}<div class="when">when {link.when}</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}
{/each}
{#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
</div>
{/each}
<h4>Leads to <span class="dim">{lists.leadsTo.length}</span></h4>
{#if lists.leadsTo.length === 0}
<p class="dim">
{selectedInfo.step.kind === 'effect' ? 'Outside the index: the graph cannot follow it further.' : 'Nothing the walk follows leaves this step.'}
</p>
{/if}
{#each lists.leadsTo as link (link.id)}
<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)}>{nameOf(link.to)}</button>
{#if link.when}<div class="when">when {link.when}</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}
{/each}
{#if stripHref(link)}<a class="site act" href={stripHref(link)}>Open as a flow →</a>{/if}
</div>
{/each}
{:else}
<div class="head">
<div>
<div class="big">What happens from <span class="mono">{payload.anchor.name}</span></div>
<a class="sub" href={symbolHref(payload.anchor.id)}>
<KindGlyph kind={payload.anchor.kind} />
{payload.anchor.qualifiedName}
</a>
<a class="sub dim" href={fileHref(payload.anchor.file)}>{payload.anchor.file}</a>
</div>
</div>
{#if payload.ambiguous.length > 0}
<p class="dim note">
{payload.ambiguous.length} other symbol{payload.ambiguous.length === 1 ? '' : 's'} share this name:
{#each payload.ambiguous as other, i (other.id)}
{#if i > 0},{/if}
<a href={stepsHref({ anchor: other.id })}>{other.kind} in {basename(other.file)}</a>
{/each}
</p>
{/if}
<p>
<b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
<select
class="depth"
value={String(payload.depth)}
onchange={(e) => navigate(rewrite({ depth: Number((e.currentTarget as HTMLSelectElement).value) }))}
>
{#each DEPTHS as d (d)}
<option value={String(d)}>{d}</option>
{/each}
{#if !DEPTHS.includes(payload.depth)}<option value={String(payload.depth)}>{payload.depth}</option>{/if}
</select>
</p>
<p>
<label class="opt">
<input type="checkbox" checked={payload.through} onchange={(e) => navigate(rewrite({ through: (e.currentTarget as HTMLInputElement).checked }))} />
Continue through screens
</label>
<span class="dim">— otherwise another screen is drawn as a boundary, and is a click from being the next anchor.</span>
</p>
<p class="counts">
{#each ['screen', 'trigger', 'bridge', 'event', 'store', 'effect'] as const as kind (kind)}
{#if model.counts[kind] > 0}
<span><b>{model.counts[kind]}</b> {kindWord(kind)}{model.counts[kind] === 1 ? '' : 's'}</span>
{/if}
{/each}
</p>
<p class="dim">
<span class="mark"></span> The anchor is at the top; each row down is one more step away from
it. Click a step and each of its links is labelled at the far end of its line with the last
condition checked before it happens; hover the line, or its row here, for the whole chain and the
plumbing it travels through. A step is the next anchor, and any link opens as a Flow strip.
</p>
{#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
<p class="dim">
Not drawn:
{#if payload.truncated.steps > 0}<b>{payload.truncated.steps}</b> step{payload.truncated.steps === 1 ? '' : 's'} past the pictures size limit;{/if}
{#if payload.truncated.hubs > 0}<b>{payload.truncated.hubs}</b> walk{payload.truncated.hubs === 1 ? '' : 's'} that reached a hub;{/if}
{#if payload.truncated.chrome > 0}<b>{payload.truncated.chrome}</b> into shared chrome.{/if}
</p>
{/if}
<h4>Most connected</h4>
{#each [...payload.steps].sort((a, b) => (model.layout.nodes.find((n) => n.id === b.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === b.id)?.ports.bottom.length ?? 0) - ((model.layout.nodes.find((n) => n.id === a.id)?.ports.top.length ?? 0) + (model.layout.nodes.find((n) => n.id === a.id)?.ports.bottom.length ?? 0))).slice(0, 8) as step (step.id)}
<button class="peer mono" onclick={() => (selected = step.id)}>{model.nodes.get(step.id)?.label ?? step.label} <span class="dim sans">{kindWord(step.kind)}</span></button>
{/each}
{/if}
</aside>
{/if}
</div>
<style>
.steps {
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__edge-labels) {
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;
}
.chooser {
max-width: 720px;
overflow: auto;
height: 100%;
box-sizing: border-box;
}
.chooser-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0;
margin-top: 12px;
border-top: 1px solid var(--rule-soft);
}
.pick {
display: block;
padding: 7px 8px;
border-bottom: 1px solid var(--rule-soft);
color: var(--ink);
text-decoration: none;
font-size: 12.5px;
}
.pick:hover {
background: var(--press);
}
.legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
max-width: 400px;
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-cross {
border-left: 3px solid var(--accent);
}
.k-box.k-store {
background: var(--paper-2);
}
.k-box.k-effect {
border-style: dashed;
border-color: var(--ink-3);
}
.k-anchor .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;
}
a.sub:hover {
text-decoration: underline;
}
.act {
color: var(--accent);
}
.clear {
border: 1px solid var(--rule);
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 11.5px;
padding: 1px 7px;
cursor: pointer;
}
.note {
margin: 0 0 6px;
}
.opt {
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.opt input {
margin: 0;
accent-color: var(--accent);
}
.depth {
font: inherit;
font-size: 12px;
border: 1px solid var(--rule-soft);
background: var(--paper-2);
color: var(--ink);
padding: 0 4px;
}
.counts {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
color: var(--ink-2);
}
h4 {
margin: 16px 0 6px;
font: 600 12.5px var(--sans);
}
.row {
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;
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;
}
a.site:hover {
text-decoration: underline;
}
.mono {
font-family: var(--mono);
}
.sans {
font-family: var(--sans);
}
.dim {
color: var(--ink-3);
}
.mark {
color: var(--accent);
}
</style>