feat(steps): the order reading is the canvas, not a rail

The first cut drew the code's order as a nested document — a column of boxes,
forks as rows of arm columns. Wrong picture: hard to read, and it threw away
the thing that made the tree legible. The ask was the canvas back, with the
timing fixed: the 200 comes after the token is signed, so it should branch out
of it.

So the order reading is now the SAME canvas, the same boxes, the same pills,
hover and panel — only the graph changes. `ui/src/lib/program-model.ts` walks
the server's block tree carrying a set of tails (the steps a next step would
follow) and emits one edge per "and then": proshop's login draws the anchor,
`User.findOne`, then the fork — `jwt.sign` under one arm with the `200` a row
below it, the `401` under the other. A row down is one more thing that has
already happened; an arm that answers, returns or throws has nothing leaving
it; a helper, a loop, `later` and `together` ride on the line into what they
hold. Rows are settled by relaxation, because a step reached twice can make
the graph cyclic.

A line means "and then" here and "leads to" in the tree, so the key says which.
The fork conditions are drawn at rest rather than only for a selected box —
`placeLabels` takes an `atRest` flag — because on this picture they are the
content, and two ways to one step merge as one condition (`WHEN userExists OR
NOT user`), not as two rendered labels stuck together.

`StepsRail.svelte` and `RailBlock.svelte` are gone; `StepBox.svelte` stays as
the box both readings draw.

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 14:16:01 -05:00
co-authored by Claude Opus 5
parent 75686502e3
commit 209a07e881
11 changed files with 529 additions and 674 deletions
+152 -91
View File
@@ -1,13 +1,18 @@
/**
* 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.
* The Steps picture in the code's order: the graph of what happens next.
*
* The server folds the walk into blocks and forks (`api/program.ts`); this
* turns that into the canvas's graph — one edge per "and then", carrying the
* condition where the code branched, and a row per step counted by how much
* has to happen before it. What is pinned here is exactly that: the shape of
* the picture, which is the thing a reader looks at.
*/
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';
import { buildOrderModel, lineWords, orderGraph, runWords } from '../ui/src/lib/program-model';
import type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
/* ------------------------------------------------------------ material -- */
const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
id,
@@ -15,18 +20,20 @@ const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
anchor: false,
node: null,
label: id,
sub: `response · handler`,
sub: 'response · handler',
depth: 1,
cut: null,
...over,
});
const arm = (when: string, body: WireBlock, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body, ...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 },
anchor: { id: 'anchor', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
ambiguous: [],
project: 'api',
steps,
steps: [step('anchor', { kind: 'anchor', anchor: true, label: 'POST /login' }), ...steps],
links: [],
program: { root, truncated: 0 },
defaultView: 'order',
@@ -39,94 +46,148 @@ function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
};
}
const arm = (when: string, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body: [], ...over });
/** The graph as `from → to` lines, each with what has to hold. */
function shape(root: WireBlock): string[] {
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
return g.edges.map((e) => `${e.from}${e.to}${e.when ? ` · ${lineWords(e)}` : ''}${e.runs.length ? ` [${e.runs.join(', ')}]` : ''}`);
}
describe('the rails words', () => {
it('says the decision once, and each arm only which side it is', () => {
function rowsOf(root: WireBlock): Record<string, number> {
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
return Object.fromEntries(g.depth);
}
/* --------------------------------------------------------------- tests -- */
describe('the picture in the codes order', () => {
it('puts one step after the next', () => {
expect(shape([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual(['anchor → a', 'a → b']);
expect(rowsOf([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual({ anchor: 0, a: 1, b: 2 });
});
it('branches both arms off the step before the fork, and says what has to hold', () => {
// proshop's login: look the user up, then sign+answer 200, else answer 401.
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']);
const root: WireBlock = [
{ kind: 'step', step: 'findOne' },
{
kind: 'fork',
form: 'if',
on,
arms: [
arm(on, [{ kind: 'block', block: 'inline', body: [{ kind: 'step', step: 'sign' }] }, { kind: 'step', step: '200' }], { ends: 'reply' }),
arm(`!(${on})`, [{ kind: 'step', step: '401' }], { not: true, ends: 'reply' }),
],
},
];
expect(shape(root)).toEqual([
'anchor → findOne',
'findOne → sign · WHEN user AND (await user.matchPassword… [via a helper]',
'sign → 200',
'findOne → 401 · WHEN NOT (user && (await user.matchPass…',
]);
// The 200 sits a row BELOW the signing, which is the whole point.
expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, sign: 2, '200': 3, '401': 2 });
});
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 switchs 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 boxs 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('rejoins after an arm that runs on, and stops at one that ends', () => {
const root: WireBlock = [
{ kind: 'step', step: 'lookup' },
{
kind: 'fork',
form: 'if',
on: 'ready',
arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
},
{ kind: 'step', step: 'after' },
];
expect(shape(root)).toEqual([
'anchor → lookup',
'lookup → inside · WHEN ready',
'lookup → bail · WHEN NOT ready',
'inside → after',
]);
});
it('is empty when there is no body to read', () => {
expect(buildRailModel({ ...payload([], []), program: null })).toEqual([]);
it('runs on either way past an `if` with no else', () => {
const root: WireBlock = [
{ kind: 'step', step: 'lookup' },
{ kind: 'fork', form: 'if', on: 'verified', arms: [arm('verified', [{ kind: 'step', step: 'mail' }])] },
{ kind: 'step', step: 'reply' },
];
expect(shape(root)).toEqual([
'anchor → lookup',
'lookup → mail · WHEN verified',
'mail → reply',
'lookup → reply',
]);
});
it('reads on into what a step sets in motion before the next step', () => {
const root: WireBlock = [
{ kind: 'step', step: 'save', body: [{ kind: 'step', step: 'write' }] },
{ kind: 'step', step: 'reply' },
];
expect(shape(root)).toEqual(['anchor → save', 'save → write', 'write → reply']);
});
it('says the run a line happens inside', () => {
const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
expect(shape([{ kind: 'block', block: 'inline', via, body: [{ kind: 'step', step: 'sign' }] }])).toEqual([
'anchor → sign [via generateToken]',
]);
expect(shape([{ kind: 'block', block: 'loop', by: 'item of items', loop: 'each', body: [{ kind: 'step', step: 'save' }] }])).toEqual([
'anchor → save [for each item of items]',
]);
});
it('carries on past a helper that answers on every path', () => {
// express-realworld: `login()` throws on each guard and returns on one; the
// handler's own `res.json` still follows the call.
const root: WireBlock = [
{
kind: 'block',
block: 'inline',
body: [{ kind: 'fork', form: 'if', on: 'bad', arms: [arm('bad', [{ kind: 'step', step: '422' }], { ends: 'reply' })] }],
},
{ kind: 'step', step: '200' },
];
expect(shape(root)).toEqual(['anchor → 422 · WHEN bad [via a helper]', 'anchor → 200']);
});
it('lets nothing float: a step the fold could not place follows the anchor', () => {
const g = orderGraph({ root: [{ kind: 'cut', why: 'folded' }], truncated: 1 } as WireProgram, 'anchor');
expect(g.edges).toEqual([]);
});
it('settles the rows of a step reached twice rather than looping', () => {
const root: WireBlock = [{ kind: 'step', step: 'db' }, { kind: 'step', step: 'check' }, { kind: 'step', step: 'db' }];
expect(shape(root)).toEqual(['anchor → db', 'db → check', 'check → db']);
expect(rowsOf(root).db).toBeGreaterThan(0);
});
it('names each kind of run', () => {
const via = { id: 'f', kind: 'function' as const, name: 'gen', qualifiedName: 'gen', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
const block = (over: Partial<Extract<WireItem, { kind: 'block' }>>) => runWords({ kind: 'block', block: 'inline', body: [], ...over } as Extract<WireItem, { kind: 'block' }>);
expect(block({ via })).toBe('via gen');
expect(block({})).toBe('via a helper');
expect(block({ block: 'later', by: 'then' })).toBe('later · then');
expect(block({ block: 'loop', by: 'item of items', loop: 'each' })).toBe('for each item of items');
expect(block({ block: 'loop', by: 'queue.length', loop: 'while' })).toBe('again while queue.length');
expect(block({ block: 'together', by: 'Promise.all' })).toBe('together · Promise.all');
});
it('builds a picture the canvas can draw, and nothing when there is no body', () => {
const model = buildOrderModel(
payload([step('findOne'), step('200')], [{ kind: 'step', step: 'findOne' }, { kind: 'step', step: '200' }])
);
expect(model).not.toBeNull();
expect([...model!.nodes.keys()].sort()).toEqual(['200', 'anchor', 'findOne']);
expect(model!.layout.nodes).toHaveLength(3);
// The anchor is on top: layer 0 is the bottom.
const layer = (id: string) => model!.layout.nodes.find((n) => n.id === id)!.layer;
expect(layer('anchor')).toBeGreaterThan(layer('findOne'));
expect(layer('findOne')).toBeGreaterThan(layer('200'));
expect(buildOrderModel({ ...payload([], []), program: null })).toBeNull();
});
});