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
This commit is contained in:
Colby McHenry
2026-08-29 13:35:17 -05:00
co-authored by Claude Opus 5
parent b02e192ffa
commit 9acab0020f
14 changed files with 1187 additions and 343 deletions
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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' };