Browse Source

feat(steps): the rail — a handler read top to bottom, forks and all

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Colby McHenry 1 tuần trước cách đây
mục cha
commit
9acab0020f

+ 132 - 0
__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> = {}): 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> = {}): 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([]);
+  });
+});

+ 5 - 4
__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', () => {

+ 33 - 10
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<WireItem, { kind: 'fork' }> = { 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<WireItem, { kind: 'fork' }> = { 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<WireItem, { kind: 'block' }> = {
       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<WireItem, { kind: 'fork' }>, 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 `<subject> === <value>`.
+      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];

+ 1 - 1
ui/src/App.svelte

@@ -177,7 +177,7 @@
   {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
     <ScreensView />
   {:else if route.view === 'steps'}
-    <StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} />
+    <StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} reading={route.reading} />
   {:else if route.view === 'dead'}
     <DeadCodeView exported={route.exported} />
   {:else if route.view === 'unknown'}

+ 184 - 0
ui/src/components/steps/RailBlock.svelte

@@ -0,0 +1,184 @@
+<script lang="ts">
+  /**
+   * A run of the rail: the items of one block, top to bottom, on a hairline.
+   *
+   * Recursive, because the code is: a fork is a row of arm columns, each arm a
+   * block of its own; a helper drawn where it is called, a loop's body, work
+   * that runs later and calls started together are bracketed blocks with a
+   * label in `--ink-3`. No layout engine and no measuring — the browser lays a
+   * column of boxes out, which is all a rail is.
+   */
+  import StepBox from './StepBox.svelte';
+  import Self from './RailBlock.svelte';
+  import type { RailItem } from '../../lib/program-model';
+  import type { ProjectKind } from '../../lib/steps-model';
+  import type { WordToken } from '../../lib/conditions';
+
+  interface Props {
+    items: RailItem[];
+    project: ProjectKind;
+    selected: string | null;
+    /** Steps not on the selected step's line, dimmed; null = nothing is selected. */
+    lit: Set<string> | null;
+    onSelect: (id: string) => void;
+    onStart: (id: string) => void;
+    /** Whether a step may become the next anchor — false for an effect. */
+    canStart: (id: string) => boolean;
+  }
+  let { items, project, selected, lit, onSelect, onStart, canStart }: Props = $props();
+</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="run">
+  {#each items as item, i (i)}
+    {#if item.kind === 'step'}
+      <div class="line">
+        {#if item.within}<span class="note">inside {item.within}(…)</span>{/if}
+        {#if item.info}
+          <StepBox
+            info={item.info}
+            {project}
+            selected={selected === item.id}
+            dimmed={lit !== null && !lit.has(item.id)}
+            note={item.again ? 'It happens here too; what it does is read above.' : ''}
+            onSelect={() => onSelect(item.id)}
+            onStart={canStart(item.id) ? () => onStart(item.id) : undefined}
+          />
+        {:else}
+          <span class="note">a step the picture left out</span>
+        {/if}
+        {#if item.again}<span class="note">as above</span>{/if}
+      </div>
+      {#if item.body.length > 0}
+        <div class="nested">
+          <Self items={item.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
+        </div>
+      {/if}
+    {:else if item.kind === 'fork'}
+      <div class="fork">
+        <div class="cond mono">{@render words(item.words)}</div>
+        <div class="arms">
+          {#each item.arms as arm, a (a)}
+            <div class="arm">
+              <div class="armh mono">{@render words(arm.words)}</div>
+              {#if arm.body.length > 0}
+                <Self items={arm.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
+              {/if}
+              {#if arm.ends}<div class="ends">{arm.ends}</div>{/if}
+            </div>
+          {/each}
+        </div>
+      </div>
+    {:else if item.kind === 'group'}
+      <div class="group" class:again={item.again}>
+        <div class="label">
+          <span>{item.label}</span>{#if item.within}<span class="note">&nbsp;· inside {item.within}(…)</span>{/if}{#if item.again}<span class="note">&nbsp;· read above</span>{/if}
+        </div>
+        {#if item.body.length > 0}
+          <Self items={item.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
+        {/if}
+      </div>
+    {:else}
+      <div class="line"><span class="note">{item.text}</span></div>
+    {/if}
+  {/each}
+</div>
+
+<style>
+  .run {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 8px;
+    min-width: 0;
+  }
+  /* The rail: a hairline down the left of every run but the outermost. */
+  .line {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 2px;
+    max-width: 100%;
+    min-width: 0;
+  }
+  .nested,
+  .group {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 8px;
+    padding-left: 12px;
+    border-left: 1px solid var(--rule-soft);
+    max-width: 100%;
+    min-width: 0;
+  }
+  .group.again {
+    border-left-style: dashed;
+  }
+  .label {
+    font: 400 11px var(--sans);
+    color: var(--ink-3);
+  }
+  .note {
+    font: 400 11px var(--sans);
+    color: var(--ink-3);
+  }
+  .fork {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 6px;
+    max-width: 100%;
+    min-width: 0;
+  }
+  .cond {
+    font-size: 12px;
+    line-height: 16px;
+    padding: 2px 7px;
+    border: 1px solid var(--rule-soft);
+    background: var(--paper-2);
+    color: var(--ink-2);
+    max-width: 640px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .arms {
+    display: flex;
+    align-items: flex-start;
+    gap: 18px;
+    min-width: 0;
+  }
+  .arm {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 8px;
+    padding: 8px 0 0 12px;
+    border-left: 1px solid var(--rule-soft);
+    border-top: 1px solid var(--rule-soft);
+    min-width: 0;
+  }
+  .armh {
+    font-size: 11.5px;
+    line-height: 15px;
+    color: var(--ink-2);
+    max-width: 520px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .ends {
+    font: 400 11px var(--sans);
+    color: var(--ink-3);
+    border-top: 1px solid var(--rule-faint);
+    padding-top: 4px;
+    align-self: stretch;
+  }
+  .kw {
+    font-weight: 600;
+  }
+</style>

+ 186 - 0
ui/src/components/steps/StepBox.svelte

@@ -0,0 +1,186 @@
+<script lang="ts">
+  /**
+   * One step's box — the same box on the canvas and on the rail.
+   *
+   * It is the Screens view's screen box with a kind: a screen is drawn exactly
+   * as there; a handler is a plain box; a native call or a native event carries
+   * an accent rule on its left, where the language changes under the code — and
+   * so does an endpoint the code crosses to (`⇢ POST /api/users`) or a job, an
+   * event, a message arriving; a store action sits on `--paper-2`; a call that
+   * leaves the index is dashed, a place the graph cannot follow into. The
+   * anchor carries the entry mark. A step the walk was cut at ends its name
+   * with an ellipsis, and its tooltip says which cap.
+   *
+   * A click selects the step; a double-click starts the picture there (the
+   * panel's *Start here →*). On the canvas the box is sized by the layout; on
+   * the rail it sizes to its own words.
+   */
+  import type { MapNodeLayout } from '../../lib/map-model';
+  import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
+
+  interface Props {
+    info: StepNodeInfo;
+    project: ProjectKind;
+    selected: boolean;
+    dimmed: boolean;
+    /** The canvas sizes its boxes; the rail lets them size to content. */
+    size?: Pick<MapNodeLayout, 'width' | 'height'> | null;
+    /** Said before the tooltip's own words — the rail says where the call is written. */
+    note?: string;
+    onSelect: (id: string) => void;
+    /** Re-anchor the picture on this step — a double-click; absent for a step with no symbol. */
+    onStart?: (id: string) => void;
+  }
+  let { info, project, selected, dimmed, size = null, note = '', onSelect, onStart }: Props = $props();
+  const step = $derived(info.step);
+
+  const cutNote = $derived.by(() => {
+    switch (step.cut) {
+      case 'depth':
+        return ' More happens past the depth of this picture — start here to see it.';
+      case 'fan-out':
+        return ' It reaches more than the walk follows from one node.';
+      case 'folded':
+        return ' The walk folded as much plumbing as it allows from one step.';
+      case 'steps':
+        return ' The picture reached its size limit here.';
+      case 'screen':
+        return ` Another ${kindWord('screen', project, step)} — a chapter of its own. Start here to see what happens on it.`;
+      case 'component':
+        return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
+      default:
+        return '';
+    }
+  });
+</script>
+
+<button
+  class={`snode k-${step.kind}`}
+  class:sel={selected}
+  class:dimmed
+  class:anchor={step.anchor}
+  class:rail={size === null}
+  style={size === null ? undefined : `width:${size.width}px;height:${size.height}px`}
+  onclick={() => onSelect(info.id)}
+  ondblclickcapture={(e) => {
+    // The flow canvas zooms on a double-click that reaches its pane; a
+    // double-click on a box is a navigation, not a zoom — stop it here,
+    // at the target, before it bubbles. The pane's own double-click keeps zooming.
+    e.stopPropagation();
+    onStart?.(info.id);
+  }}
+  aria-pressed={selected}
+  title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, project, step)}. ${info.sub}.${note ? ` ${note}` : ''}${cutNote}${onStart && !step.anchor ? ' Double-click to start here.' : ''}`}
+>
+  <span class="name"
+    >{#if step.anchor}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}{#if step.cut !== null}<span
+        class="more"
+        aria-hidden="true"> …</span
+      >{/if}</span
+  >
+  <span class="sub">{info.sub}</span>
+</button>
+
+<style>
+  .snode {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    gap: 1px;
+    box-sizing: border-box;
+    padding: 0 9px;
+    border: 1px solid var(--ink);
+    border-radius: 0;
+    background: var(--paper);
+    text-align: left;
+    cursor: pointer;
+    font: inherit;
+    color: var(--ink);
+    transition: background 90ms linear;
+  }
+  /* On the rail a box sizes to its words, and wears its padding itself. */
+  .snode.rail {
+    align-items: flex-start;
+    padding: 5px 9px;
+    max-width: 100%;
+  }
+  .snode:hover,
+  .snode.sel {
+    border-width: 2px;
+    padding: 0 8px;
+  }
+  .snode.rail:hover,
+  .snode.rail.sel {
+    padding: 4px 8px;
+  }
+  .snode:hover,
+  .snode.sel {
+    background: var(--press);
+  }
+  .snode.dimmed {
+    border-color: var(--ink-4);
+    color: var(--ink-4);
+  }
+  .snode.dimmed .sub {
+    color: var(--ink-4);
+  }
+  /* The language changes under the code: a rule where it does. */
+  .snode.k-bridge,
+  .snode.k-event {
+    border-left: 3px solid var(--accent);
+    padding-left: 7px;
+  }
+  .snode.k-bridge:hover,
+  .snode.k-bridge.sel,
+  .snode.k-event:hover,
+  .snode.k-event.sel {
+    border-left-width: 3px;
+    padding-left: 7px;
+  }
+  .snode.k-bridge.dimmed,
+  .snode.k-event.dimmed {
+    border-left-color: var(--accent-line);
+  }
+  .snode.k-store {
+    background: var(--paper-2);
+  }
+  .snode.k-store:hover,
+  .snode.k-store.sel {
+    background: var(--press);
+  }
+  /* Outside the index: a place the graph cannot follow into. */
+  .snode.k-effect {
+    border-style: dashed;
+    border-color: var(--ink-3);
+  }
+  .snode:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 1px;
+  }
+  .name {
+    font: 500 13px var(--mono);
+    line-height: 15px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 100%;
+  }
+  .mark {
+    color: var(--accent);
+    margin-right: 5px;
+    font-size: 9px;
+    vertical-align: 1px;
+  }
+  .more {
+    color: var(--ink-3);
+  }
+  .sub {
+    font: 400 11px var(--sans);
+    line-height: 13px;
+    color: var(--ink-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 100%;
+  }
+</style>

+ 14 - 151
ui/src/components/steps/StepNode.svelte

@@ -1,24 +1,13 @@
 <script lang="ts">
   /**
-   * One step on the Steps view. The box is the Screens view's screen box with
-   * a kind: a screen is drawn exactly as there; a handler is a plain box; a
-   * native call or a native event carries an accent rule on its left, where
-   * the language changes under the code — and so does an endpoint the code
-   * crosses to (`⇢ POST /api/users`) or a job, an event, a message arriving;
-   * a store action sits on `--paper-2`;
-   * a call that leaves the index is dashed, like a trigger no screen reaches
-   * on the Screens view — a place the graph cannot follow into. The anchor
-   * carries the entry mark. A step the walk was cut at ends its name with an
-   * ellipsis, and its tooltip says which cap.
-   *
-   * Hidden handles along the top and bottom, one per port the layout decided
-   * (`directional` ports), exactly as the screen box. A click selects the step;
-   * a double-click starts the picture there (the panel's *Start here →*) — an
-   * endpoint or another screen reached as a boundary opens as its own chapter.
+   * One step on the Steps view's canvas: the shared box ({@link StepBox}) with
+   * hidden handles along its top and bottom, one per port the layout decided
+   * (`directional` ports), exactly as the screen box has.
    */
   import { Handle, Position, type NodeProps } from '@xyflow/svelte';
+  import StepBox from './StepBox.svelte';
   import type { MapNodeLayout } from '../../lib/map-model';
-  import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
+  import type { ProjectKind, StepNodeInfo } from '../../lib/steps-model';
 
   let { data }: NodeProps = $props();
 
@@ -35,27 +24,6 @@
     }
   );
   const layout = $derived(node.layout);
-  const info = $derived(node.info);
-  const step = $derived(info.step);
-
-  const cutNote = $derived.by(() => {
-    switch (step.cut) {
-      case 'depth':
-        return ' More happens past the depth of this picture — start here to see it.';
-      case 'fan-out':
-        return ' It reaches more than the walk follows from one node.';
-      case 'folded':
-        return ' The walk folded as much plumbing as it allows from one step.';
-      case 'steps':
-        return ' The picture reached its size limit here.';
-      case 'screen':
-        return ` Another ${kindWord('screen', node.project, step)} — a chapter of its own. Start here to see what happens on it.`;
-      case 'component':
-        return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
-      default:
-        return '';
-    }
-  });
 
   function portStyle(index: number, total: number): string {
     return `left:${((index + 1) / (total + 1)) * 100}%`;
@@ -72,31 +40,15 @@
   />
 {/each}
 
-<button
-  class={`snode k-${step.kind}`}
-  class:sel={node.selected}
-  class:dimmed={node.dimmed}
-  class:anchor={step.anchor}
-  style={`width:${layout.width}px;height:${layout.height}px`}
-  onclick={() => node.onSelect(info.id)}
-  ondblclickcapture={(e) => {
-    // The flow canvas zooms on a double-click that reaches its pane; a
-    // double-click on a box is a navigation, not a zoom — stop it here,
-    // at the target, before it bubbles. The pane's own double-click keeps zooming.
-    e.stopPropagation();
-    node.onStart?.(info.id);
-  }}
-  aria-pressed={node.selected}
-  title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, node.project, step)}. ${info.sub}.${cutNote}${node.onStart && !step.anchor ? ' Double-click to start here.' : ''}`}
->
-  <span class="name"
-    >{#if step.anchor}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}{#if step.cut !== null}<span
-        class="more"
-        aria-hidden="true"> …</span
-      >{/if}</span
-  >
-  <span class="sub">{info.sub}</span>
-</button>
+<StepBox
+  info={node.info}
+  project={node.project}
+  selected={node.selected}
+  dimmed={node.dimmed}
+  size={layout}
+  onSelect={node.onSelect}
+  onStart={node.onStart}
+/>
 
 {#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
   <Handle
@@ -107,92 +59,3 @@
     isConnectable={false}
   />
 {/each}
-
-<style>
-  .snode {
-    display: flex;
-    flex-direction: column;
-    justify-content: center;
-    gap: 1px;
-    box-sizing: border-box;
-    padding: 0 9px;
-    border: 1px solid var(--ink);
-    border-radius: 0;
-    background: var(--paper);
-    text-align: left;
-    cursor: pointer;
-    font: inherit;
-    color: var(--ink);
-    transition: background 90ms linear;
-  }
-  .snode:hover,
-  .snode.sel {
-    border-width: 2px;
-    padding: 0 8px;
-    background: var(--press);
-  }
-  .snode.dimmed {
-    border-color: var(--ink-4);
-    color: var(--ink-4);
-  }
-  .snode.dimmed .sub {
-    color: var(--ink-4);
-  }
-  /* The language changes under the code: a rule where it does. */
-  .snode.k-bridge,
-  .snode.k-event {
-    border-left: 3px solid var(--accent);
-    padding-left: 7px;
-  }
-  .snode.k-bridge:hover,
-  .snode.k-bridge.sel,
-  .snode.k-event:hover,
-  .snode.k-event.sel {
-    border-left-width: 3px;
-    padding-left: 7px;
-  }
-  .snode.k-bridge.dimmed,
-  .snode.k-event.dimmed {
-    border-left-color: var(--accent-line);
-  }
-  .snode.k-store {
-    background: var(--paper-2);
-  }
-  .snode.k-store:hover,
-  .snode.k-store.sel {
-    background: var(--press);
-  }
-  /* Outside the index: a place the graph cannot follow into. */
-  .snode.k-effect {
-    border-style: dashed;
-    border-color: var(--ink-3);
-  }
-  .snode:focus-visible {
-    outline: 2px solid var(--accent);
-    outline-offset: 1px;
-  }
-  .name {
-    font: 500 13px var(--mono);
-    line-height: 15px;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-  }
-  .mark {
-    color: var(--accent);
-    margin-right: 5px;
-    font-size: 9px;
-    vertical-align: 1px;
-  }
-  .more {
-    color: var(--ink-3);
-  }
-  .sub {
-    font: 400 11px var(--sans);
-    line-height: 13px;
-    color: var(--ink-3);
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-  }
-</style>

+ 216 - 0
ui/src/components/steps/StepsKey.svelte

@@ -0,0 +1,216 @@
+<script lang="ts">
+  /**
+   * The Steps view's key.
+   *
+   * The same rows for both readings, worded for the one on screen: the kinds of
+   * box are the same either way, and only the last rows differ — the canvas
+   * explains its lines and pills, the rail its forks and terminals. On the
+   * canvas it floats over the picture, bottom left; on the rail it is the last
+   * thing in the document, because a rail scrolls and an overlay would sit on
+   * top of the code it is explaining.
+   */
+  import { kindWord, type ProjectKind } from '../../lib/steps-model';
+
+  interface Props {
+    project: ProjectKind;
+    /** The reading on screen: the rail's rows, or the canvas's. */
+    order: boolean;
+    /** In the flow of a scrolling rail rather than floating over a canvas. */
+    flow: boolean;
+    open: boolean;
+    onToggle: (open: boolean) => void;
+  }
+  let { project, order, flow, open, onToggle }: Props = $props();
+</script>
+
+<div class="legend" class:open class:flow>
+  <button class="legend-h" onclick={() => onToggle(!open)} aria-expanded={open}>
+    Key <span class="dim">{open ? '▾' : '▸'}</span>
+  </button>
+  {#if open}
+    <div class="legend-body">
+      <div class="lrow">
+        <span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
+        <span>{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'}</span>
+      </div>
+      {#if 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 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}
+      {#if order}
+        <div class="lrow">
+          <span class="k-label mono">WHEN x</span>
+          <span>A fork — an <span class="mono">if</span>, a <span class="mono">switch</span>, a <span class="mono">try</span> or an early exit — with its arms side by side under the condition</span>
+        </div>
+        <div class="lrow">
+          <span class="k-label">answers here</span>
+          <span>The arm stops there: it answers the request, returns or throws, and nothing below it runs</span>
+        </div>
+        <div class="lrow">
+          <span class="k-label">via x</span>
+          <span>A helper drawn where it is called; <span class="mono">later</span> runs after this returns, <span class="mono">together</span> starts at once, <span class="mono">for each</span> repeats</span>
+        </div>
+        <div class="lrow">
+          <span class="k-label mono">name …</span>
+          <span>Not entered: another {kindWord('screen', project)} (a chapter of its own), or a cap the walk hit — start there to see on</span>
+        </div>
+      {:else}
+        <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>
+      {/if}
+    </div>
+  {/if}
+</div>
+
+<style>
+.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;
+}
+  /* On the rail the key is the document's last block, not an overlay. */
+  .legend.flow {
+    position: static;
+    margin: 28px 0 0;
+    max-width: 520px;
+  }
+</style>

+ 94 - 0
ui/src/components/steps/StepsRail.svelte

@@ -0,0 +1,94 @@
+<script lang="ts">
+  /**
+   * The Steps view read in the code's ORDER: the anchor at the top, then its
+   * body top to bottom — the calls in the order they are written, a fork where
+   * the code forks, its arms side by side, an arm that answers or leaves ending
+   * there. It is the same walk the canvas draws, folded by
+   * `api/program.ts` and worded by `program-model.ts`; a click selects a step
+   * and fills the same panel, a double-click starts the picture there.
+   */
+  import StepBox from './StepBox.svelte';
+  import RailBlock from './RailBlock.svelte';
+  import type { Snippet } from 'svelte';
+  import type { RailItem } from '../../lib/program-model';
+  import { triggerWords, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
+
+  interface Props {
+    anchor: StepNodeInfo;
+    items: RailItem[];
+    project: ProjectKind;
+    selected: string | null;
+    lit: Set<string> | null;
+    /** Items the reading could not place — a recursion or a cap it hit. */
+    truncated: number;
+    onSelect: (id: string) => void;
+    onStart: (id: string) => void;
+    canStart: (id: string) => boolean;
+    /** The key, last in the document — a rail scrolls, so it cannot float over it. */
+    children?: Snippet;
+  }
+  let { anchor, items, project, selected, lit, truncated, onSelect, onStart, canStart, children }: Props = $props();
+</script>
+
+<div class="rail">
+  <div class="head">
+    <StepBox
+      info={anchor}
+      {project}
+      selected={selected === anchor.id}
+      dimmed={false}
+      onSelect={() => onSelect(anchor.id)}
+    />
+    {#if anchor.step.trigger}
+      <div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(anchor.step.trigger)}</div>
+    {/if}
+  </div>
+  {#if items.length === 0}
+    <p class="empty">Nothing in the index happens in this symbol's body — the picture has no order to read.</p>
+  {:else}
+    <div class="body">
+      <RailBlock {items} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
+    </div>
+  {/if}
+  {#if truncated > 0}
+    <p class="empty">
+      {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.
+    </p>
+  {/if}
+  {@render children?.()}
+</div>
+
+<style>
+  .rail {
+    height: 100%;
+    overflow: auto;
+    padding: 20px 24px 64px;
+    box-sizing: border-box;
+    background: var(--paper);
+  }
+  .head {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 3px;
+    padding-bottom: 12px;
+  }
+  .body {
+    padding-left: 12px;
+    border-left: 1px solid var(--rule-soft);
+  }
+  .fires {
+    font: 400 11.5px var(--sans);
+    color: var(--ink-2);
+  }
+  .kw {
+    font: 600 11.5px var(--mono);
+  }
+  .empty {
+    font: 400 12px var(--sans);
+    color: var(--ink-3);
+    max-width: 60ch;
+    margin: 16px 0 0;
+  }
+</style>

+ 7 - 0
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)}`;
   },
 

+ 183 - 0
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<WireItem, { kind: 'fork' }>['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<string, WireStep>, project: ProjectKind): RailItem[] {
+  return items.map((item) => one(item, steps, project));
+}
+
+function one(item: WireItem, steps: Map<string, WireStep>, 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<WireItem, { kind: 'block' }>): 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';
+  }
+}

+ 5 - 1
ui/src/lib/router.svelte.ts

@@ -12,7 +12,7 @@
  *   #/flow                 flow strip       (?from=&to= | ?symbols= | ?t=<trail>)
  *   #/entry                entry points     (where a flow starts)
  *   #/screens              screens          (the app's screens and transitions)
- *   #/steps                steps            (?anchor=<id> | ?symbol=<name>: what happens from there)
+ *   #/steps                steps            (?anchor=<id> | ?symbol=<name>: what happens from there; ?view=order|tree)
  *   #/dead                 dead code        (?exported=1 widens the claim)
  *
  * Node ids are opaque engine strings shaped `<kind>:<hash>` 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

+ 3 - 1
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' };
 

+ 125 - 176
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;
-  }
-  let { anchor, symbol, depth, through }: Props = $props();
+    /**
+     * 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, reading }: Props = $props();
 
   let payload = $state<WireStepsPayload | null>(null);
   let error = $state<string | null>(null);
@@ -185,6 +194,36 @@
 
   const model = $derived<StepsModel | null>(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<string>([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<string>([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 @@
       </div>
     {:else if loading && payload === null}
       <div class="state"><p class="dim">Walking from the anchor…</p></div>
+    {:else if model !== null && payload !== null && readAs === 'order'}
+      {#if railReadable}
+        <StepsRail
+          anchor={model.nodes.get(payload.anchor.id) ?? [...model.nodes.values()][0]!}
+          items={rail}
+          project={payload.project}
+          {selected}
+          lit={litOnRail}
+          truncated={payload.program?.truncated ?? 0}
+          onSelect={selectOnRail}
+          onStart={(id) => startHere(id)}
+          {canStart}
+        >
+          <StepsKey
+            project={payload.project}
+            order={true}
+            flow={true}
+            open={legendOpen}
+            onToggle={(next) => (legendOpen = next)}
+          />
+        </StepsRail>
+      {:else}
+        <div class="state">
+          <h2>This has no body to read in order</h2>
+          <p>
+            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.
+          </p>
+          <p><a class="pick" href={rewrite({ view: 'tree' })}>What it sets in motion →</a></p>
+        </div>
+      {/if}
     {:else if model !== null && payload !== null}
       <SvelteFlow
         {nodes}
@@ -475,91 +547,6 @@
         <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`}>
@@ -579,6 +566,10 @@
         </div>
       {/if}
     {/if}
+
+    {#if payload !== null && model !== null && readAs === 'tree'}
+      <StepsKey project={payload.project} order={false} flow={false} open={legendOpen} onToggle={(next) => (legendOpen = next)} />
+    {/if}
   </div>
 
   {#if payload !== null && model !== null}
@@ -755,6 +746,11 @@
             {/each}
           </p>
         {/if}
+        <p class="reading">
+          Read as:
+          <a class="tab" class:on={readAs === 'order'} href={rewrite({ view: 'order' })}>in order</a>
+          <a class="tab" class:on={readAs === 'tree'} href={rewrite({ view: 'tree' })}>what it sets in motion</a>
+        </p>
         <p>
           <b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
           <select
@@ -783,12 +779,22 @@
             {/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 readAs === 'order'}
+          <p class="dim">
+            <span class="mark">●</span> The anchor is at the top, then its body in the code's own order: the
+            calls as they are written, a fork where the code forks with its arms side by side, a helper drawn
+            where it is called, and an arm that answers, returns or throws ending there. A call written inside
+            another call's arguments comes first — the token is signed before the reply that carries it. Click
+            a step for its sites and conditions; a step is the next anchor.
+          </p>
+        {:else}
+          <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}
         {#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
           <p class="dim">
             Not drawn:
@@ -888,84 +894,6 @@
   .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;
@@ -1048,6 +976,27 @@
     margin: 0;
     accent-color: var(--accent);
   }
+  .reading {
+    display: flex;
+    align-items: baseline;
+    gap: 8px;
+    color: var(--ink-3);
+  }
+  .tab {
+    color: var(--ink-2);
+    text-decoration: none;
+    border-bottom: 1px solid var(--rule-soft);
+    padding-bottom: 1px;
+  }
+  .tab:hover {
+    color: var(--ink);
+    border-bottom-color: var(--ink-3);
+  }
+  .tab.on {
+    color: var(--ink);
+    font-weight: 600;
+    border-bottom-color: var(--accent);
+  }
   .depth {
     font: inherit;
     font-size: 12px;