| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151 |
- <!--
- 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 };
- /** Two clicks on one box closer than this are a double-click. */
- const DOUBLE_CLICK_MS = 400;
- let lastClick: { id: string; at: number } | null = null;
- /** Start the picture at a step — the panel's *Start here →*. False for a step with no symbol, or the anchor. */
- function startHere(id: string): boolean {
- const step = model?.nodes.get(id)?.step;
- if (!step || !step.node || step.anchor) return false;
- navigate(stepsHref({ anchor: step.node.id }));
- return true;
- }
- 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) => {
- // Two clicks on the same box within a beat are a double-click:
- // the picture starts there. Read here rather than off the DOM's
- // `dblclick`, which the flow canvas does not always pass on.
- const now = performance.now();
- if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
- lastClick = null;
- if (startHere(id)) return;
- }
- lastClick = { id, at: now };
- selected = selected === id ? null : id;
- hovered = null;
- panelHot = null;
- },
- // Double-click: the picture starts here — an endpoint or another
- // screen drawn as a boundary opens as its own chapter. An effect has
- // no symbol to start from.
- ...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
- },
- }));
- });
- 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=<name></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 (or double-click its box) 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 (or double-click its box) 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 picture’s 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 picture’s 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 endpoint’s 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.'
- : selectedInfo.step.cut === 'screen' || selectedInfo.step.cut === 'component'
- ? 'Not entered — a boundary. Start here for its own picture, or continue through from the summary.'
- : '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 picture’s 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>
|