From 9acab0020f6935afd4bfc200691aa93afc5574a6 Mon Sep 17 00:00:00 2001
From: Colby McHenry
Date: Sat, 29 Aug 2026 13:35:17 -0500
Subject: [PATCH] =?UTF-8?q?feat(steps):=20the=20rail=20=E2=80=94=20a=20han?=
=?UTF-8?q?dler=20read=20top=20to=20bottom,=20forks=20and=20all?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The reading the walk records now has a picture. `#/steps?…&view=order` draws
the anchor, then its body: a box per step in the order the code writes them, a
fork where the code forks with its arms side by side under the condition, a
helper drawn where it is called, and an arm that answers, returns or throws
ending there — so proshop's login reads *look the user up · if the password
matches, sign a token inside the reply and answer 200 · otherwise 401*, which
is what the code says and what a row of four boxes could not.
- `program-model.ts` decides the words: the fork carries the decision once and
its arms say only which side they are (WHEN / WHEN NOT), except a `switch`,
whose arms each have a case to say, and a `try`, which says `on error` once.
- `StepBox.svelte` is the box both readings draw — the canvas wraps it in
handles, the rail lets it size to its words. Same look, same click, same
double-click-to-start-here.
- `StepsKey.svelte` is the key, floating over the canvas as before and last in
the document on the rail, which scrolls and cannot have things sitting on it.
- The reading travels in the URL (`view=order` / `view=tree`) and the summary
offers both; without one, the answer's own default decides — the code's order
for a handler or an endpoint, the tree for a screen.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
---
__tests__/ui-program-model.test.ts | 132 ++++++++++
__tests__/ui-steps-program.test.ts | 9 +-
src/ui-server/api/program.ts | 43 +++-
ui/src/App.svelte | 2 +-
ui/src/components/steps/RailBlock.svelte | 184 ++++++++++++++
ui/src/components/steps/StepBox.svelte | 186 ++++++++++++++
ui/src/components/steps/StepNode.svelte | 165 ++-----------
ui/src/components/steps/StepsKey.svelte | 216 ++++++++++++++++
ui/src/components/steps/StepsRail.svelte | 94 +++++++
ui/src/lib/navigation.ts | 7 +
ui/src/lib/program-model.ts | 183 ++++++++++++++
ui/src/lib/router.svelte.ts | 6 +-
ui/src/lib/wire.ts | 4 +-
ui/src/views/StepsView.svelte | 299 ++++++++++-------------
14 files changed, 1187 insertions(+), 343 deletions(-)
create mode 100644 __tests__/ui-program-model.test.ts
create mode 100644 ui/src/components/steps/RailBlock.svelte
create mode 100644 ui/src/components/steps/StepBox.svelte
create mode 100644 ui/src/components/steps/StepsKey.svelte
create mode 100644 ui/src/components/steps/StepsRail.svelte
create mode 100644 ui/src/lib/program-model.ts
diff --git a/__tests__/ui-program-model.test.ts b/__tests__/ui-program-model.test.ts
new file mode 100644
index 0000000..7fb5067
--- /dev/null
+++ b/__tests__/ui-program-model.test.ts
@@ -0,0 +1,132 @@
+/**
+ * What the rail SAYS. The block tree the server sends is turned into rows of
+ * boxes and words here (`ui/src/lib/program-model.ts`); this pins the words —
+ * which is the part a reader actually meets.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { joinTokens } from '../ui/src/lib/conditions';
+import { armWords, buildRailModel, endWords, groupLabel } from '../ui/src/lib/program-model';
+import type { WireArm, WireBlock, WireItem, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
+
+const step = (id: string, over: Partial = {}): WireStep => ({
+ id,
+ kind: 'effect',
+ anchor: false,
+ node: null,
+ label: id,
+ sub: `response · handler`,
+ depth: 1,
+ cut: null,
+ ...over,
+});
+
+function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
+ return {
+ anchor: { id: 'a', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
+ ambiguous: [],
+ project: 'api',
+ steps,
+ links: [],
+ program: { root, truncated: 0 },
+ defaultView: 'order',
+ depth: 8,
+ limit: 120,
+ through: false,
+ truncated: { steps: 0, hubs: 0, chrome: 0 },
+ index: { lastIndexedAt: null, edges: 0, files: 0 },
+ timing: { elapsedMs: 1 },
+ };
+}
+
+const arm = (when: string, over: Partial = {}): WireArm => ({ when, ends: null, body: [], ...over });
+
+describe('the rail’s words', () => {
+ it('says the decision once, and each arm only which side it is', () => {
+ const on = 'user && (await user.matchPassword(password))';
+ const fork: WireItem = {
+ kind: 'fork',
+ form: 'if',
+ on,
+ arms: [arm(on, { ends: 'reply', body: [{ kind: 'step', step: '200' }] }), arm(`!(${on})`, { not: true, ends: 'reply', body: [{ kind: 'step', step: '401' }] })],
+ };
+ const model = buildRailModel(payload([step('200'), step('401')], [fork]));
+ expect(model).toHaveLength(1);
+ const rail = model[0]!;
+ if (rail.kind !== 'fork') throw new Error('expected a fork');
+ expect(joinTokens(rail.words)).toBe('user AND (await user.matchPassword(password))');
+ expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(['WHEN', 'WHEN NOT']);
+ expect(rail.arms.map((a) => a.ends)).toEqual(['answers here', 'answers here']);
+ });
+
+ it('keeps a disjunction whole rather than reading it as two ways of arriving', () => {
+ // `!image || unlimitedCollection` is ONE condition, and the parentheses
+ // `guardLabel` puts round it are what stop the OR from splitting it.
+ const on = '(!image || unlimitedCollection)';
+ const model = buildRailModel(payload([], [{ kind: 'fork', form: 'if', on, arms: [arm(on)] }]));
+ const rail = model[0]!;
+ if (rail.kind !== 'fork') throw new Error('expected a fork');
+ expect(joinTokens(rail.words)).toBe('(!image || unlimitedCollection)');
+ });
+
+ it('gives a switch’s arms their own conditions', () => {
+ const fork: WireItem = {
+ kind: 'fork',
+ form: 'switch',
+ on: 'kind',
+ arms: [arm("kind === 'a'"), arm('kind: default')],
+ };
+ const model = buildRailModel(payload([], [fork]));
+ const rail = model[0]!;
+ if (rail.kind !== 'fork') throw new Error('expected a fork');
+ expect(joinTokens(rail.words)).toBe('kind');
+ expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(["WHEN kind === 'a'", 'WHEN kind: default']);
+ });
+
+ it('lets a try say `on error` once', () => {
+ expect(armWords('try', 'on error', arm('on error'))).toEqual([]);
+ const model = buildRailModel(payload([], [{ kind: 'fork', form: 'try', on: 'on error', arms: [arm('on error')] }]));
+ const rail = model[0]!;
+ if (rail.kind !== 'fork') throw new Error('expected a fork');
+ expect(joinTokens(rail.words)).toBe('on error');
+ });
+
+ it('says how each arm leaves', () => {
+ expect(endWords('reply')).toBe('answers here');
+ expect(endWords('return')).toBe('returns here');
+ expect(endWords('throw')).toBe('throws here');
+ expect(endWords('exit')).toBe('leaves here');
+ });
+
+ it('names each kind of bracketed run', () => {
+ const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
+ expect(groupLabel({ kind: 'block', block: 'inline', via, body: [] })).toBe('via generateToken');
+ expect(groupLabel({ kind: 'block', block: 'inline', body: [] })).toBe('via a helper');
+ expect(groupLabel({ kind: 'block', block: 'later', by: 'then', body: [] })).toBe('later · then');
+ expect(groupLabel({ kind: 'block', block: 'loop', by: 'item of items', body: [] })).toBe('for each item of items');
+ expect(groupLabel({ kind: 'block', block: 'together', by: 'Promise.all', body: [] })).toBe('together · Promise.all');
+ });
+
+ it('carries a box’s two lines and where the call is written', () => {
+ const model = buildRailModel(
+ payload([step('200', { label: '200', sub: 'response · authUser' })], [{ kind: 'step', step: '200', within: 'res.json' }])
+ );
+ const rail = model[0]!;
+ if (rail.kind !== 'step') throw new Error('expected a step');
+ expect(rail.info?.label).toBe('200');
+ expect(rail.info?.sub).toBe('response · authUser');
+ expect(rail.within).toBe('res.json');
+ });
+
+ it('says where the reading stopped', () => {
+ const model = buildRailModel(payload([], [{ kind: 'cut', why: 'folded' }, { kind: 'cut', why: 'depth' }]));
+ expect(model.map((i) => (i.kind === 'cut' ? i.text : ''))).toEqual([
+ 'reads back into itself — the rest is the same code again',
+ 'as deep as this reading goes — start at a step below to read on',
+ ]);
+ });
+
+ it('is empty when there is no body to read', () => {
+ expect(buildRailModel({ ...payload([], []), program: null })).toEqual([]);
+ });
+});
diff --git a/__tests__/ui-steps-program.test.ts b/__tests__/ui-steps-program.test.ts
index 30fd089..b8c35cb 100644
--- a/__tests__/ui-steps-program.test.ts
+++ b/__tests__/ui-steps-program.test.ts
@@ -58,7 +58,7 @@ function shape(block: WireBlock, indent = ''): string[] {
out.push(...shape(arm.body, `${indent} `));
}
} else if (item.kind === 'block') {
- out.push(`${indent}${item.block} ${item.label}${item.again ? ' (again)' : ''}`);
+ out.push(`${indent}${item.block}${item.via ? ` via ${item.via.name}` : item.by ? ` ${item.by}` : ''}${item.again ? ' (again)' : ''}`);
out.push(...shape(item.body, `${indent} `));
} else out.push(`${indent}cut ${item.why}`);
}
@@ -157,8 +157,9 @@ describe('buildProgram', () => {
at('d', [g('kind: default', { form: 'case', branch })]),
],
});
+ // The head says what is being decided on; each arm its own case.
expect(shape(p!.root)).toEqual([
- "switch kind === 'a'",
+ 'switch kind',
" arm kind === 'a'",
' a',
" arm kind === 'b'",
@@ -224,14 +225,14 @@ describe('buildProgram', () => {
const p = program({
root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })],
});
- expect(shape(p!.root)).toEqual(['now', 'later later · then', ' afterwards']);
+ expect(shape(p!.root)).toEqual(['now', 'later then', ' afterwards']);
});
it('puts calls started together in one block', () => {
const p = program({
root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')],
});
- expect(shape(p!.root)).toEqual(['together together', ' a inside Promise.all', ' b inside Promise.all', 'c']);
+ expect(shape(p!.root)).toEqual(['together Promise.all', ' a inside Promise.all', ' b inside Promise.all', 'c']);
});
it('closes a fork when the code leaves it', () => {
diff --git a/src/ui-server/api/program.ts b/src/ui-server/api/program.ts
index a3beeae..d90181f 100644
--- a/src/ui-server/api/program.ts
+++ b/src/ui-server/api/program.ts
@@ -39,6 +39,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
export interface WireArm {
/** This arm's own condition, in the words the rest of the view uses. */
when: string;
+ /** The arm taken when the fork's condition does NOT hold — the `else` side. */
+ not?: true;
/** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
ends: WireArmEnd | null;
body: WireBlock;
@@ -61,7 +63,7 @@ export type WireItem =
* after this function returns (`later`), or calls started together
* (`together`).
*/
- | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
+ | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
| { kind: 'cut'; why: 'folded' | 'depth' };
@@ -175,7 +177,10 @@ function blockFor(input: ProgramInput, fn: string, path: readonly string[], stat
}
for (let i = keep; i < guards.length; i++) {
const g = guards[i]!;
- const fork: Extract = { kind: 'fork', on: g.text, form: formOf(g), arms: [] };
+ // The decision as a reader says it, not as the guard stores it: a
+ // disjunction keeps the parentheses that stop `a || b` from reading as
+ // two ways of arriving (`guardLabel` is the one place that decides).
+ const fork: Extract = { kind: 'fork', on: guardLabel([{ ...g, negated: false }]), form: formOf(g), arms: [] };
// An early exit is a fork whose OTHER arm left before this site could
// run: `if (!product) throw` — the throw is written first, so it is the
// first arm, and it is empty because nothing in the picture happens there.
@@ -206,7 +211,6 @@ function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[]
const block: Extract = {
kind: 'block',
block: 'inline',
- label: via ? `via ${via.name}` : 'via a helper',
...(via ? { via } : {}),
...(site.within ? { within: site.within } : {}),
body: [],
@@ -265,18 +269,18 @@ function place(block: WireBlock, item: WireItem, site: ProgramSite): void {
return;
}
const last = block[block.length - 1];
- if (last && last.kind === 'block' && last.block === run.block && last.label === run.label) {
+ if (last && last.kind === 'block' && last.block === run.block && last.by === run.by) {
last.body.push(item);
return;
}
- block.push({ kind: 'block', block: run.block, label: run.label, body: [item] });
+ block.push({ kind: 'block', block: run.block, ...(run.by ? { by: run.by } : {}), body: [item] });
}
-/** The run a site belongs to — `later · then`, `together` — or null for plain sequence. */
-function runFor(site: ProgramSite): { block: 'later' | 'together'; label: string } | null {
+/** The run a site belongs to — registered to run later, started together — or null for plain sequence. */
+function runFor(site: ProgramSite): { block: 'later' | 'together'; by?: string } | null {
const fires = site.trigger;
- if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', label: `later · ${fires.name}` };
- if (site.within && TOGETHER.test(site.within)) return { block: 'together', label: 'together' };
+ if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', by: fires.name };
+ if (site.within && TOGETHER.test(site.within)) return { block: 'together', by: site.within };
return null;
}
@@ -285,7 +289,7 @@ function armFor(fork: Extract, g: BranchGuard): Wire
const when = guardLabel([g]);
const found = fork.arms.find((a) => a.when === when);
if (found) return found;
- const arm: WireArm = { when, ends: g.armExit ?? null, body: [] };
+ const arm: WireArm = { when, ...(g.negated ? { not: true as const } : {}), ends: g.armExit ?? null, body: [] };
fork.arms.push(arm);
return arm;
}
@@ -317,6 +321,9 @@ function formOf(g: BranchGuard): 'if' | 'switch' | 'ternary' | 'try' {
function seal(input: ProgramInput, block: WireBlock): void {
for (const item of block) {
if (item.kind === 'fork') {
+ // A switch's head is the thing being decided on, which only its arms
+ // together say: every case was written ` === `.
+ if (item.form === 'switch') item.on = subjectOf(item.arms.map((a) => a.when));
for (const arm of item.arms) {
seal(input, arm.body);
if (repliesLast(input, arm.body)) arm.ends = 'reply';
@@ -326,6 +333,22 @@ function seal(input: ProgramInput, block: WireBlock): void {
}
}
+/**
+ * What every arm of a switch is deciding on: the longest start they share, cut
+ * at a word. '' when they share nothing — then the head says nothing and each
+ * arm says its own condition, which is never wrong.
+ */
+function subjectOf(arms: readonly string[]): string {
+ if (arms.length < 2) return '';
+ let common = arms[0]!;
+ for (const arm of arms.slice(1)) {
+ let i = 0;
+ while (i < common.length && i < arm.length && common[i] === arm[i]) i++;
+ common = common.slice(0, i);
+ }
+ return /^[\w$.?[\]'"]+/.exec(common.trim())?.[0] ?? '';
+}
+
/** Whether the last thing a block does is answer the request. */
function repliesLast(input: ProgramInput, block: WireBlock): boolean {
const last = block[block.length - 1];
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 74d17cb..080e3d1 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -177,7 +177,7 @@
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
{:else if route.view === 'steps'}
-
+
{:else if route.view === 'dead'}
{:else if route.view === 'unknown'}
diff --git a/ui/src/components/steps/RailBlock.svelte b/ui/src/components/steps/RailBlock.svelte
new file mode 100644
index 0000000..ae58e34
--- /dev/null
+++ b/ui/src/components/steps/RailBlock.svelte
@@ -0,0 +1,184 @@
+
+
+{#snippet words(tokens: WordToken[])}
+ {#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each}
+{/snippet}
+
+
+ {#each items as item, i (i)}
+ {#if item.kind === 'step'}
+
+ {#if item.within}inside {item.within}(…){/if}
+ {#if item.info}
+ onSelect(item.id)}
+ onStart={canStart(item.id) ? () => onStart(item.id) : undefined}
+ />
+ {:else}
+ a step the picture left out
+ {/if}
+ {#if item.again}as above{/if}
+
+ ●start
+ {order ? 'Where the picture starts; below it, what it does in the code’s own order' : 'Where the picture starts; each row down is one more step away'}
+
+ {#if project === 'api'}
+
+ POST /x
+ 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
+
+
+ ⇢ fn
+ 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 (⇠)
+
+
+ set
+ A data call — a function in a store or state file
+
+
+ db
+ A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network
+
+ {:else if project === 'web'}
+
+ /path
+ A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which
+
+
+ ⇢ fn
+ The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)
+
+
+ set
+ A store action — a function in a store file
+
+
+ api
+ A call that leaves the index: the network, the database, the response, storage, a queue, email
+
+ {:else}
+
+ /path
+ A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event
+
+
+ ⇢ fn
+ The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)
+
+
+ set
+ A store action — a function in a store file
+
+
+ api
+ A call that leaves the index: the network, storage, the device, telemetry
+
+ {/if}
+ {#if order}
+
+ WHEN x
+ A fork — an if, a switch, a try or an early exit — with its arms side by side under the condition
+
+
+ answers here
+ The arm stops there: it answers the request, returns or throws, and nothing below it runs
+
+
+ via x
+ A helper drawn where it is called; later runs after this returns, together starts at once, for each repeats
+
+
+ name …
+ Not entered: another {kindWord('screen', project)} (a chapter of its own), or a cap the walk hit — start there to see on
+
+ {:else}
+
+
+ Leads to — the plumbing between the two is folded into the line
+
+
+
+ Established by a synthesized hop (an event channel, a callback, a helper's return value)
+
+
+
+ Goes back up the picture — leaves the top of its box, arrives at the bottom of the other
+
+
+ → …x
+ 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
+
+
+ name …
+ Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on
+
Nothing in the index happens in this symbol's body — the picture has no order to read.
+ {:else}
+
+
+
+ {/if}
+ {#if truncated > 0}
+
+ {truncated} place{truncated === 1 ? '' : 's'} the reading stopped: code it had already read, or as deep as it goes.
+ Start at a step to read on from there.
+
+ {/if}
+ {@render children?.()}
+
+
+
diff --git a/ui/src/lib/navigation.ts b/ui/src/lib/navigation.ts
index 021f5c5..615a3fd 100644
--- a/ui/src/lib/navigation.ts
+++ b/ui/src/lib/navigation.ts
@@ -63,6 +63,12 @@ export interface StepsHrefOptions {
depth?: number;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through?: boolean;
+ /**
+ * Which reading: the code's `order` — the anchor's body as a rail — or the
+ * `tree` of what it sets in motion. Absent takes the answer's own default:
+ * the order for a handler or an endpoint, the tree for a screen.
+ */
+ view?: 'order' | 'tree';
}
/**
@@ -155,6 +161,7 @@ export const hashNavigation: NavigationDriver = {
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');
+ if (opts.view) params.set('view', opts.view);
return `#/steps${query(params)}`;
},
diff --git a/ui/src/lib/program-model.ts b/ui/src/lib/program-model.ts
new file mode 100644
index 0000000..1f3679c
--- /dev/null
+++ b/ui/src/lib/program-model.ts
@@ -0,0 +1,183 @@
+/**
+ * The Steps view's second reading, as the rail draws it.
+ *
+ * The server answers with the anchor's body folded into blocks and forks
+ * (`api/program.ts`); this turns that into what a reader sees — the boxes of
+ * the picture in the code's order, the conditions as words, and one line for
+ * every place the reading has to be honest about not being plain sequence
+ * (work registered to run later, calls started together, a helper already read
+ * above). Nothing here is geometry: the rail is a column of boxes with a
+ * hairline down its left, and a fork is a row of columns, so the browser lays
+ * it out and this file only decides what each thing SAYS.
+ *
+ * The words are the ones the rest of the view uses: `steps-model.ts` for a
+ * box's two lines and the vocabulary a project is read in, `conditions.ts` for
+ * WHEN / AND / OR / NOT. A fork carries its condition once, on its head; an
+ * arm then says only which side it is — *when* and *when not* — except in a
+ * `switch`, where each arm has a condition of its own to say.
+ */
+
+import { conditionTokens, whenTokens, type WordToken } from './conditions';
+import { stepLabel, stepSub, type ProjectKind, type StepNodeInfo } from './steps-model';
+import type { WireArm, WireArmEnd, WireBlock, WireItem, WireNodeRef, WireStep, WireStepsPayload } from './wire';
+
+/** The construct a fork came from. */
+export type ForkForm = Extract['form'];
+
+/** A step of the picture, where the code writes it. */
+export interface RailStep {
+ kind: 'step';
+ id: string;
+ /** The link it arrived on — the panel's rows for this site. */
+ link: string | null;
+ /** The box's words; null when the step is not in the picture (a cap removed it). */
+ info: StepNodeInfo | null;
+ /** The call this one is written inside the arguments of — `res.json`. */
+ within: string | null;
+ /** What it does, when the walk read on into it. */
+ body: RailItem[];
+ /** It happens here too, and was read above. */
+ again: boolean;
+}
+
+export interface RailArm {
+ /** WHEN / WHEN NOT, or a case's own condition. */
+ words: WordToken[];
+ /** What the arm's last line says when it stops there: `answers`, `returns`, `throws`, `leaves`. */
+ ends: string | null;
+ body: RailItem[];
+}
+
+export interface RailFork {
+ kind: 'fork';
+ /** The decision, in the conditions vocabulary. */
+ words: WordToken[];
+ /** The word for the construct: `if`, `switch`, `try`. */
+ form: string;
+ arms: RailArm[];
+}
+
+/** A run that is not plain sequence, bracketed and labelled. */
+export interface RailGroup {
+ kind: 'group';
+ block: 'inline' | 'loop' | 'later' | 'together';
+ /** `via generateToken`, `for each item of items`, `later · then`, `together`. */
+ label: string;
+ /** The helper drawn here, for its link to the symbol view. */
+ via: WireNodeRef | null;
+ within: string | null;
+ again: boolean;
+ body: RailItem[];
+}
+
+export interface RailCut {
+ kind: 'cut';
+ text: string;
+}
+
+export type RailItem = RailStep | RailFork | RailGroup | RailCut;
+
+/**
+ * The rail for a payload: its anchor's body in the code's order, or an empty
+ * list when the server had nothing to read (a screen, an unreadable file).
+ */
+export function buildRailModel(payload: WireStepsPayload): RailItem[] {
+ if (!payload.program) return [];
+ const steps = new Map(payload.steps.map((s) => [s.id, s]));
+ return block(payload.program.root, steps, payload.project);
+}
+
+function block(items: WireBlock, steps: Map, project: ProjectKind): RailItem[] {
+ return items.map((item) => one(item, steps, project));
+}
+
+function one(item: WireItem, steps: Map, project: ProjectKind): RailItem {
+ switch (item.kind) {
+ case 'step': {
+ const step = steps.get(item.step);
+ return {
+ kind: 'step',
+ id: item.step,
+ link: item.link ?? null,
+ info: step ? { id: step.id, step, label: stepLabel(step), sub: stepSub(step, project) } : null,
+ within: item.within ?? null,
+ body: item.body ? block(item.body, steps, project) : [],
+ again: item.again === true,
+ };
+ }
+ case 'fork':
+ return {
+ kind: 'fork',
+ // The head is the decision itself, said once; the arms say only which
+ // side of it they are, so the head carries no WHEN of its own.
+ words: whenTokens(item.on),
+ form: item.form === 'switch' ? 'switch' : item.form === 'try' ? 'try' : 'if',
+ arms: item.arms.map((arm) => ({
+ words: armWords(item.form, item.on, arm),
+ ends: arm.ends === null ? null : endWords(arm.ends),
+ body: block(arm.body, steps, project),
+ })),
+ };
+ case 'block':
+ return {
+ kind: 'group',
+ block: item.block,
+ label: groupLabel(item),
+ via: item.via ?? null,
+ within: item.within ?? null,
+ again: item.again === true,
+ body: block(item.body, steps, project),
+ };
+ default:
+ return {
+ kind: 'cut',
+ text:
+ item.why === 'folded'
+ ? 'reads back into itself — the rest is the same code again'
+ : 'as deep as this reading goes — start at a step below to read on',
+ };
+ }
+}
+
+/**
+ * What an arm says. The fork already carries the condition, so the two sides of
+ * an `if` say only which side they are; a `switch` arm has a condition of its
+ * own, and so does an arm the reading could not match to the head.
+ */
+export function armWords(form: ForkForm, on: string, arm: WireArm): WordToken[] {
+ // A case has a condition of its own to say; the one arm of a `try` is the
+ // head (`on error`) and says nothing twice.
+ if (form === 'switch') return conditionTokens(arm.when);
+ if (form === 'try') return [];
+ if (arm.not === true) return [{ kw: true, text: 'WHEN' }, { kw: true, text: 'NOT' }];
+ if (arm.when === on) return [{ kw: true, text: 'WHEN' }];
+ return conditionTokens(arm.when);
+}
+
+/** How an arm leaves, as a reader says it. */
+export function endWords(end: WireArmEnd): string {
+ switch (end) {
+ case 'reply':
+ return 'answers here';
+ case 'return':
+ return 'returns here';
+ case 'throw':
+ return 'throws here';
+ default:
+ return 'leaves here';
+ }
+}
+
+/** The words on a bracketed run. */
+export function groupLabel(item: Extract): string {
+ switch (item.block) {
+ case 'inline':
+ return item.via ? `via ${item.via.name}` : 'via a helper';
+ case 'loop':
+ return item.by ? `for each ${item.by}` : 'for each';
+ case 'later':
+ return item.by ? `later · ${item.by}` : 'later';
+ default:
+ return item.by ? `together · ${item.by}` : 'together';
+ }
+}
diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts
index a5e1120..3fb83bf 100644
--- a/ui/src/lib/router.svelte.ts
+++ b/ui/src/lib/router.svelte.ts
@@ -12,7 +12,7 @@
* #/flow flow strip (?from=&to= | ?symbols= | ?t=)
* #/entry entry points (where a flow starts)
* #/screens screens (the app's screens and transitions)
- * #/steps steps (?anchor= | ?symbol=: what happens from there)
+ * #/steps steps (?anchor= | ?symbol=: what happens from there; ?view=order|tree)
* #/dead dead code (?exported=1 widens the claim)
*
* Node ids are opaque engine strings shaped `:` or
@@ -87,6 +87,8 @@ export type Route =
symbol: string | null;
depth: number | null;
through: boolean;
+ /** Which reading the URL asked for; null takes the answer's own default. */
+ reading: 'order' | 'tree' | null;
}
| {
view: 'dead';
@@ -156,12 +158,14 @@ export function parseHash(hash: string): RouterLocation {
// 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);
+ const reading = params.get('view');
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',
+ reading: reading === 'order' || reading === 'tree' ? reading : null,
};
} else if (head === 'dead' && rest.length === 0) {
// The widening travels in the URL like the map's shape does: a link to
diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts
index 3cf3a3d..f6f03ca 100644
--- a/ui/src/lib/wire.ts
+++ b/ui/src/lib/wire.ts
@@ -814,6 +814,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
export interface WireArm {
/** This arm's own condition, in the words the rest of the view uses. */
when: string;
+ /** The arm taken when the fork's condition does NOT hold — the `else` side. */
+ not?: true;
/** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
ends: WireArmEnd | null;
body: WireBlock;
@@ -836,7 +838,7 @@ export type WireItem =
* after this function returns (`later`), or calls started together
* (`together`).
*/
- | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
+ | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true }
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
| { kind: 'cut'; why: 'folded' | 'depth' };
diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte
index ba226bf..e1cb026 100644
--- a/ui/src/views/StepsView.svelte
+++ b/ui/src/views/StepsView.svelte
@@ -17,6 +17,8 @@
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 StepsRail from '../components/steps/StepsRail.svelte';
+ import StepsKey from '../components/steps/StepsKey.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
import {
@@ -44,6 +46,7 @@
triggerWords,
type StepsModel,
} from '../lib/steps-model';
+ import { buildRailModel } from '../lib/program-model';
interface Props {
anchor: string | null;
@@ -51,8 +54,14 @@
depth: number | null;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through: boolean;
+ /**
+ * Which reading the URL asked for — the code's `order` or the `tree` of
+ * what the anchor sets in motion. Null takes the answer's own default: the
+ * order for a handler or an endpoint, the tree for a screen.
+ */
+ reading: 'order' | 'tree' | null;
}
- let { anchor, symbol, depth, through }: Props = $props();
+ let { anchor, symbol, depth, through, reading }: Props = $props();
let payload = $state(null);
let error = $state(null);
@@ -185,6 +194,36 @@
const model = $derived(payload === null ? null : buildStepsModel(payload));
+ /**
+ * Which reading is on screen. The URL wins; otherwise the answer's own
+ * default — the code's order for a handler, an endpoint or any function, the
+ * tree for a screen, where handlers fire on events and have nothing to order.
+ */
+ const readAs = $derived<'order' | 'tree'>(reading ?? payload?.defaultView ?? 'tree');
+ const rail = $derived(payload === null || readAs !== 'order' ? [] : buildRailModel(payload));
+ /** The rail can be asked for and have nothing to show: say so rather than drawing an empty page. */
+ const railReadable = $derived(payload?.program != null);
+ /** The steps on the selected step's own lines — everything else on the rail is dimmed. */
+ const litOnRail = $derived.by(() => {
+ if (payload === null || selected === null) return null;
+ const set = new Set([selected]);
+ for (const l of payload.links) {
+ if (l.from === selected) set.add(l.to);
+ if (l.to === selected) set.add(l.from);
+ }
+ return set;
+ });
+ /** A step with a symbol behind it can become the next anchor. */
+ function canStart(id: string): boolean {
+ const step = payload?.steps.find((s) => s.id === id);
+ return !!step?.node && !step.anchor;
+ }
+ function selectOnRail(id: string): void {
+ selected = selected === id ? null : id;
+ hovered = null;
+ panelHot = null;
+ }
+
const neighbours = $derived.by(() => {
if (model === null || selected === null) return null;
const set = new Set([selected]);
@@ -284,14 +323,16 @@
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 = {
+ function rewrite(changes: { depth?: number; through?: boolean; view?: 'order' | 'tree' }): string {
+ return stepsHref({
anchor: anchor ?? undefined,
symbol: anchor === null ? (symbol ?? undefined) : undefined,
depth: changes.depth ?? depth ?? undefined,
through: changes.through ?? through,
- };
- return stepsHref(opts);
+ // The reading travels in the URL once it has been chosen, so a link to
+ // "the login endpoint in the code's order" reopens as that.
+ view: changes.view ?? reading ?? undefined,
+ });
}
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
@@ -308,7 +349,7 @@
}
function onStageMove(event: MouseEvent): void {
- if (model === null || stage === null) return;
+ if (model === null || stage === null || readAs === 'order') return;
const target = event.target as Element | null;
if (target?.closest('.spill')) return;
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
@@ -450,6 +491,37 @@
{:else if loading && payload === null}
+ Nothing the picture holds is written inside this symbol — a screen renders handlers that fire on
+ events, and they have no order between them. Read it as what it sets in motion instead.
+
+ {/if}
{:else if model !== null && payload !== null}
-
-
- {#if legendOpen}
-
-
- ●start
- Where the picture starts; each row down is one more step away
-
- {#if payload.project === 'api'}
-
- POST /x
- 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
-
-
- ⇢ fn
- 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 (⇠)
-
-
- set
- A data call — a function in a store or state file
-
-
- db
- A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network
-
- {:else if payload.project === 'web'}
-
- /path
- A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which
-
-
- ⇢ fn
- The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)
-
-
- set
- A store action — a function in a store file
-
-
- api
- A call that leaves the index: the network, the database, the response, storage, a queue, email
-
- {:else}
-
- /path
- A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event
-
-
- ⇢ fn
- The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)
-
-
- set
- A store action — a function in a store file
-
-
- api
- A call that leaves the index: the network, storage, the device, telemetry
-
- {/if}
-
-
- Leads to — the plumbing between the two is folded into the line
-
-
-
- Established by a synthesized hop (an event channel, a callback, a helper's return value)
-
-
-
- Goes back up the picture — leaves the top of its box, arrives at the bottom of the other
-
-
- → …x
- 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
-
-
- name …
- Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on
-