Files
codegraph/ui/src/views/StepsView.svelte
T
Colby McHenryandClaude Fable 5 950686def4 feat(ui): Steps for servers — route roots, server effects, request/decorator triggers, guards for Python/Java/Kotlin/C#/Go/C
- api/route-roots.ts: the symbol a route runs (references-edge handler, exported page component, or the route itself for an inline handler), shared by steps and screens; the bare Steps tab lists an API's endpoints by router file
- api/effects.ts: database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry, matched on the call as written per language family, with model + read/write and the literal status on a response site
- graph/branch-guards.ts: callSitesForFile (the whole member chain), memberTypesInTree, decoratorsForFile, request/decorator triggers with the middleware/guard chain; guard + argument rules for Python, Java, Kotlin, C#, Go and C
- steps.ts: classify on the chain before trusting a name match, retarget this.x.y() by declared type, skip test doubles after the effect pre-check, project kind on the wire
- viewer: kindWord/kindWords per project kind, endpoint chooser, response boxes labelled by status codes
- python.ts: FastAPI detected from a monorepo sub-directory; is-test-file: samples/examples package paths are not tests
- tests: ui-steps-api-servers, ui-effects, branch-guards-languages; spec §3.13 Servers paragraph, CHANGELOG, plan doc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
2026-08-28 13:45:38 -05:00

1124 lines
43 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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,
fetchRoutes,
fetchScreens,
fetchSteps,
type WireRoute,
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 { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
import {
buildStepsModel,
kindWord,
kindWords,
stepNeighbourhood,
stepPairId,
stepViaText,
triggerWords,
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 lists, when the view opens without an anchor: the screens of an app, else the endpoints of an API. */
let screens = $state<WireScreen[] | null>(null);
let routes = $state<WireRoute[] | null>(null);
/** What the chooser offers: null while reading. */
const chooser = $derived.by<'screens' | 'routes' | 'none' | null>(() => {
if (screens === null) return null;
if (screens.length > 0) return 'screens';
if (routes === null) return null;
return routes.length > 0 ? 'routes' : 'none';
});
/** Endpoints by the file they are registered in — the router file is how a reader groups them — biggest first, in registration order within. */
function routeGroups(list: WireRoute[]): Array<{ file: string; entries: WireRoute[] }> {
const byFile = new Map<string, WireRoute[]>();
for (const r of list) {
const group = byFile.get(r.routeFile) ?? [];
group.push(r);
byFile.set(r.routeFile, group);
}
return [...byFile]
.map(([file, entries]) => ({ file, entries: [...entries].sort((a, b) => a.routeLine - b.routeLine) }))
.sort((a, b) => b.entries.length - a.entries.length || a.file.localeCompare(b.file));
}
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.
}
});
/**
* The fit. A picture of a few boxes is centred — and the key, bottom left,
* would sit on its second row; it is fitted to the right of the key instead.
* A picture of many boxes is fitted to the whole stage, as the Screens view's.
*/
const fitOptions = $derived(
model !== null && model.layout.nodes.length <= 24 && legendOpen
? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
: { 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(async (next) => {
screens = next.routed ? next.screens : [];
// No screens: an API's endpoints are its places to start from.
if (next.routed) {
routes = [];
return;
}
const found = await fetchRoutes({ limit: 300 }, controller.signal);
routes = found.routed ? found.entries : [];
})
.catch(() => {
screens = screens ?? [];
routes = routes ?? [];
});
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)!,
project: payload?.project ?? 'app',
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 - 420),
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 - 420),
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 ? '←' : '→'} ${whenWords(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);
}
/** `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}
<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>
{#if chooser === 'routes'}
<p>
Pick an endpoint and this view draws everything it sets in motion — its handler and what runs
before it, the calls into the database, a queue, another service, and every response it can
send — 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>
{:else}
<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}
{#if chooser === null}
<p class="dim">Reading {screens === null ? 'screens' : 'endpoints'}</p>
{:else if chooser === 'none'}
<p class="dim">
No screens or endpoints 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 if chooser === 'screens' && screens !== null}
<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>
{:else if routes !== null}
{#each routeGroups(routes) as group (group.file)}
<div class="group-h"><span class="mono">{group.file}</span><span class="dim">{group.entries.length}</span></div>
<div class="chooser-list">
{#each group.entries as route (route.routeId)}
<a class="pick mono" href={stepsHref({ anchor: route.routeId })}
>{route.url} <span class="dim sans">{route.handler}</span></a
>
{/each}
</div>
{/each}
{/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
fitViewOptions={fitOptions}
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>
{#if payload.project === 'api'}
<div class="lrow">
<span class="k-box mono">POST /x</span>
<span>An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A data call — a function in a store or state file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">db</span>
<span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
</div>
{:else if payload.project === 'web'}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</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, the database, the response, storage, a queue, email</span>
</div>
{:else}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</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>
{/if}
<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.trigger}<span class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></span>{/if}
{#if link.via.length > 0}<span class="via">via {stepViaText(link)}</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.label}<span class="dim">{link.label}</span>{/if}
{#if link.sites[0]}<span class="mono">{#if link.sites[0].status}<b class="status">{link.sites[0].status}</b> · {/if}{siteWords(link.sites[0])}</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, payload.project, selectedInfo.step)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
{#if selectedInfo.step.trigger}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(selectedInfo.step.trigger)} <span class="dim">in {selectedInfo.step.trigger.in}</span></div>
{/if}
{#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 {kindWord('screen', payload.project, selectedInfo.step)} — a chapter of its own. Start here to see what happens on it, or continue through {kindWords('screen', payload.project)[1]} 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.effect?.category === 'response'}
<p class="dim note">The endpoints contract as the code has it: each row below is one way it answers, with the condition it answers under.</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)}
{@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)}
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.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
{#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.label}<div class="via dim">{link.label}</div>{/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 row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
{/if}
{#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}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{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>
{/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)}
{@const sc = scenarios(link.sites)}
{@const fallback = selectedInfo.step.screen?.component?.id ?? selectedInfo.step.node?.id ?? null}
<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.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
{#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.label}<div class="via dim">{link.label}</div>{/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 row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
{/if}
{#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}>{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
{:else}
<span class="site">{#if row.site.status}<b class="status">{row.site.status}</b> · {/if}{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>
{/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 {kindWords('screen', payload.project)[1]}
</label>
<span class="dim">— otherwise another {kindWord('screen', payload.project)} 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}
{@const words = kindWords(kind, payload.project)}
<span><b>{model.counts[kind]}</b> {model.counts[kind] === 1 ? words[0] : words[1]}</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, payload.project, step)}</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);
}
/* A router file heading over its endpoints; the list under it keeps its own top rule. */
.group-h {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-top: 18px;
font-size: 11.5px;
color: var(--ink-2);
}
.group-h + .chooser-list {
margin-top: 6px;
}
.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: 400px;
padding: 8px 10px;
border: 1px solid var(--ink);
background: var(--paper);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
font-size: 12px;
pointer-events: none;
/* A call with its arguments is one long token: it wraps inside the box. */
overflow-wrap: anywhere;
}
.tip .mono,
.tip .when,
.tip .via,
.tip .fires {
overflow-wrap: anywhere;
}
.tiprow {
display: flex;
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;
/* An effect's label is a call with its arguments — one long token. */
overflow-wrap: anywhere;
}
.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;
}
/* The joins we add — WHEN, AND, OR, NOT — a little bolder than the code between them. */
.kw {
font-weight: 600;
}
/* The chain a hop travels through — the answer to "where on the screen": read, not dim. */
.via {
color: var(--ink-2);
font: 400 11.5px var(--mono);
margin-top: 2px;
}
.via.dim {
color: var(--ink-3);
font-size: 11px;
}
.fires {
color: var(--ink);
font: 400 11.5px var(--mono);
margin-top: 2px;
}
.ways {
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 response's status code leads its row: the number is the fact. */
.status {
color: var(--ink);
font-weight: 600;
}
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>