+ ●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
-