Kaynağa Gözat

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
Colby McHenry 6 gün önce
ebeveyn
işleme
209a07e881

+ 1 - 1
CHANGELOG.md

@@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
-- **The Steps tab reads a handler in the code's own order.** A picture of what an endpoint sets in motion put the lookup, the token signing, the 200 and the 401 side by side, because each is one step from the anchor — true, and not how the code reads. Now a handler, an endpoint or any function opens as a rail instead: the calls top to bottom in the order they are written, a fork where the code forks — an `if`, a `switch`, a `try`, an early exit — with its arms side by side under the condition, an arm that answers the request, returns or throws ending right there, a helper drawn where it is called, and a body that repeats saying so (`for each item of items`). A call written inside another call's arguments comes first, so the token is signed before the reply that carries it. Work registered to run later (`later · then`) and calls started together (`together · Promise.all`) say so rather than pretending to be a sequence. A screen still opens as before — its handlers fire on events and have no order between them — and either reading is one click, or one `&view=order` / `&view=tree` in the link, away. Nothing to re-index: it is read from the source at request time, and where the conditions cannot be read the rail is a plain sequence rather than an invented structure.
+- **The Steps tab draws a handler in the order its code runs.** The picture of what an endpoint sets in motion put the lookup, the token signing, the 200 and the 401 side by side, because each is one step from the anchor — true, and not how the code reads. Now a handler, an endpoint or any function opens as the same picture laid out by *when* things happen: a line means **and then**, so the 200 sits below the token signing it is built from and the 401 branches off the check that chose it. Where the code forks — an `if`, a `switch`, a `try`, an early exit — the line says what has to hold, and an arm that answers the request, returns or throws simply has nothing leaving it. A call written inside another call's arguments happens first, so the token is signed before the reply that carries it. A helper is drawn where it is called (`via generateToken`), a body that repeats says so (`for each item of items`), and work registered to run later (`later · then`) or started at once (`together · Promise.all`) says that rather than pretending to be a sequence. A screen still opens as before — its handlers fire on events and have no order between them — and either reading is one click, or one `&view=order` / `&view=tree` in the link, away. Nothing to re-index: it is read from the source at request time, and where the conditions cannot be read the picture is a plain sequence rather than an invented structure.
 
 - **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading.
 

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
CLAUDE.md


+ 141 - 80
__tests__/ui-program-model.test.ts

@@ -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 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']);
+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 code’s 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('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('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 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('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('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('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('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('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('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('names each kind of bracketed run', () => {
+  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(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');
+    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 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('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('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('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('is empty when there is no body to read', () => {
-    expect(buildRailModel({ ...payload([], []), program: null })).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();
   });
 });

+ 34 - 36
docs/design/codegraph-ui-design-spec.md

@@ -571,29 +571,38 @@ one is fitted to the whole stage. `Picture` (`screens-model.ts`) is the structur
 the shared machinery works over; `steps-model.ts` builds one. Pure model tests: `ui-steps-model.test.ts`; the
 endpoint against a real RN + Expo fixture: `ui-steps-api.test.ts`.
 
-#### 3.13.1 In order — the same walk as the code reads (`&view=order`)
+#### 3.13.1 In order — the same picture, laid out by when things happen (`&view=order`)
 Rows-by-distance is the right picture for a screen, where handlers fire on events and nothing orders them. It is the
 wrong one for a handler: on proshop's `POST /api/users/login` the tree puts `User.findOne`, `jwt.sign`, `200` and `401`
 side by side — each is one step from the anchor — when the code says *look the user up, then IF the password matches
 sign a token and answer 200, ELSE answer 401*, and the signing happens INSIDE the reply that carries it. So the same
-walk has a second reading: the anchor, then its body top to bottom.
+walk has a second reading, on **the same canvas, with the same boxes**: only the graph changes.
 
 ```
-● POST /api/users/login · authUser        FIRES FROM POST /api/users/login
-│ User.findOne({ email })                 database · User · read · authUser
-│ user AND (await user.matchPassword(password))
-│   ┌ WHEN ───────────────────────┐  ┌ WHEN NOT ──────────┐
-│   │ via generateToken · inside res.json(…)              │
-│   │   jwt.sign({ id }, …)  auth │  │ 401  response      │
-│   │ 200  response               │  │ answers here       │
-│   │ answers here                │  └────────────────────┘
+                POST /api/users/login · authUser
+                            │
+                    User.findOne({ email })
+        ┌───────────────────┴────────────────────┐
+  → WHEN user AND (await …)              → WHEN NOT (user && …)
+        │                                         │
+  jwt.sign({ id }, …)  auth                 401  response
+        │
+   200  response
 ```
 
+**A line means "and then", not "leads to"** — that is the whole difference from the other reading, and the key says so.
+A row down is one more thing that has already happened, so the `200` sits below the `jwt.sign` it is built from and the
+`401` branches at the fork. Where the code forks the line carries what has to hold, **drawn at rest** rather than only
+for a selected box (`placeLabels(model, selected, atRest)`) — on this picture the conditions ARE the content. An arm
+that answers, returns or throws simply has nothing leaving it.
+
 **What it is made of.** Every hop the walk makes is recorded where the code writes it — the step it reached (or the
 helper it folded into), the call's position and span, the branch guards, the loops, what fires it — by the SAME pass
 that makes the links, so the two readings can never hold different steps (`WireStepsPayload.program`, built by
 `src/ui-server/api/program.ts` from the records `steps.ts` keeps; `ProgramSite` is one such record). `buildProgram` is
-pure over them: no graph, no source, no control-flow graph.
+pure over them: no graph, no source, no control-flow graph. `ui/src/lib/program-model.ts` then walks that block tree
+carrying a set of *tails* — the steps a next step would follow — and emits one edge per "and then"; the row of a step is
+the longest run of them from the anchor, settled by relaxation because a step reached twice can make the graph cyclic.
 
 **What makes the fold possible** is that a guard names the DECISION it belongs to and not only its own words
 (`BranchGuard.branch` — where the branching construct starts): the `if` and the `else` of one statement carry the same
@@ -601,38 +610,27 @@ branch with `negated` flipped, an early exit carries the branch of the `if` that
 carries the branch of the switch, and two `try`/`catch` blocks in one function stay apart. Two sites are arms of ONE
 fork when they agree on the branch and disagree on the arm — which a joined condition string can never say. A guard
 also carries how the arm it is in leaves (`armExit`) and, for an early exit, how the arm that was not taken leaves
-(`exit`), so an arm ends with the word the code uses.
+(`exit`); that is `WireArm.ends`, and an arm that ends is an arm nothing leaves.
 
-**The items** (`WireItem`): a **step**, where the code writes it — with `inside res.json(…)` when it is written in
+**The block tree** (`WireItem`): a **step**, where the code writes it — with `inside res.json(…)` when it is written in
 another call's arguments, its own body under it when the walk entered it, and `again` when the same function has
-already been read (a function is read once in a rail, however many times it is called); a **fork** — `if` / `switch` /
-`ternary` / `try` / an early exit — carrying its condition once, with an arm per side, each ending `reply` / `return` /
-`throw` / `exit` when it stops there; a bracketed **block** — a helper drawn in place (`via generateToken`), a body that
-runs per item (`for each item of items`, read by `loopsForFile`, which loops and forks nest by which construct BEGINS
-first), work registered to run later (`later · then`), calls started together (`together · Promise.all`); and a **cut**
-where the reading stopped. Source order is execution order for straight-line code and for arguments before their call
-(so the token is signed before the reply); where it is not — a callback, concurrency — the block says so rather than
-pretending.
+already been read (a function is read once per picture, however many times it is called); a **fork** — `if` / `switch` /
+`ternary` / `try` / an early exit — carrying its condition once, with an arm per side; a **run** that is not plain
+sequence, said on the line into it — a helper drawn in place (`via generateToken`), a body that repeats (`for each item
+of items`, read by `loopsForFile`; loops and forks nest by which construct BEGINS first), work registered to run later
+(`later · then`), calls started together (`together · Promise.all`); and a **cut** where the reading stopped. Source
+order is execution order for straight-line code and for arguments before their call; where it is not — a callback,
+concurrency — the line says so rather than pretending. A helper that answers on one path still returns on another, so
+the code after the call follows the call; what comes after is not inside it.
 
 **Honest by construction.** A fork exists only where a guard was READ: a language without rules, or a file that changed
-since the index sync, reads as a plain sequence rather than an invented structure. Every cap is announced
-(`program.truncated`).
-
-**The view.** `ui/src/lib/program-model.ts` decides the words, `StepsRail.svelte` + `RailBlock.svelte` draw them — no
-layout engine, a column of boxes with a hairline down its left and a fork as a row of arm columns. The box is
-`StepBox.svelte`, the canvas's box exactly (the canvas wraps it in handles; the rail lets it size to its words), and so
-are the click, the double-click-to-start-here and the panel. The fork's head says 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. **An early exit is drawn as a guard clause, not as a branch** — a fork with nothing on
-one side (`if (!user) return`) is one line, the condition and where the code leaves (`userWithTeam.length === 0
-· returns here`), with everything below it running because it did not; a rail of guard clauses would otherwise step
-right once per guard and a handler with four of them would read as four nested branches. `StepsKey.svelte` is the key:
-floating over the canvas, last in the document on the rail, which scrolls and cannot have things sitting on it.
+since the index sync, draws a plain sequence rather than an invented structure. Every cap is announced
+(`program.truncated`), and nothing floats — a step the fold could not place follows the anchor unconditionally.
 
 **Which reading opens** travels in the URL (`&view=order` / `&view=tree`) and the summary offers both; without one the
 answer's own `defaultView` decides — the code's order for a handler, an endpoint or any function, the tree for a
-screen. Tests: `ui-steps-program.test.ts` (the fold, over hand-made records), `ui-program-model.test.ts` (the words),
-and one `in order` reading per framework in `ui-steps-api-servers.test.ts`.
+screen. Tests: `ui-steps-program.test.ts` (the fold, over hand-made records), `ui-program-model.test.ts` (the graph and
+its rows), and one `in order` reading per framework in `ui-steps-api-servers.test.ts`.
 
 ## 4. Libraries and versions
 - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,

+ 13 - 0
docs/plans/2026-08-29-steps-in-code-order.md

@@ -16,6 +16,13 @@ spec §3.13.1 is the description of what was built.
   (§4.1.7 only said this for boundaries under `through`).
 - **`WireItem`'s blocks are one kind with a discriminator** (`block: 'inline' | 'loop' | 'later' | 'together'`) rather
   than four item kinds, and they carry facts (`by`, `via`, `loop`) rather than words — the viewer says them.
+- **The reading is drawn on the CANVAS, not as a nested document.** §4.2's rail — a column of boxes with forks as rows
+  of arm columns — was built first and rejected on sight by the maintainer: *"this is very hard to read… go back to the
+  way it looked, but since the 200 and 401 come after the signing of the jwt those should branch out of it."* The right
+  picture is the same Svelte Flow canvas with the same boxes, laid out by WHEN things happen: a line means **and then**,
+  a row down is one more thing already done, and the fork's condition rides on the line (drawn at rest — on this
+  picture the conditions are the content). `ui/src/lib/program-model.ts` turns the block tree into that graph;
+  `StepsRail`/`RailBlock` are gone.
 - **Loops needed their own reading** (`loopsForFile`), and loops and forks nest by which construct BEGINS first, since
   neither reading knows about the other.
 - The open questions of §7 were answered: order for functions/endpoints and the tree for screens (1); read each
@@ -310,6 +317,12 @@ Read against the source, endpoint by endpoint. Every reading below is the one th
 | `fastapi/full-stack-fastapi-template` | `POST /login/access-token` | `via authenticate` → `session.exec`, `not db_user` → return, `not verified` → return, `updated_password_hash` → `session.add`/`commit`/`refresh`; then `not user` → `400`, `elif not user.is_active` → `400`; then `via create_access_token` → `jwt.encode` | right, after the `elif` fix |
 | `amniservices-mobile-app` | `/capture/review` | unchanged: 87 steps, 160 links, the tree, `defaultView: 'tree'` | the regression check |
 
+Each of those was read first as the rejected rail and then as the canvas graph; the readings are the same, the picture
+is not. What the canvas gives that the rail could not: proshop's login fits the sketch the maintainer drew
+(`User.findOne` → the fork → `jwt.sign` → `200` | `401`), and nest-boilerplate's login reads down the page —
+`findByEmail` → `WHEN NOT user → 422` | `WHEN user AND user.provider === …` → `bcrypt.compare` → `WHEN
+isValidPassword` → `sessionRepository.create` → `together · Promise.all` → `jwtService.signAsync`.
+
 **Three defects the pictures caught, all in the walk and both readings** — a hop's span taken from a call that was not
 the one asked for (an inline Express handler's edges carry the route's line, so the registration's span swallowed the
 body); a name-match the call as written disproves (`crypto.createHash('sha256').update(…)` followed into the caller's

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

@@ -1,216 +0,0 @@
-<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'}
-      {@const guard = item.arms.length <= 2 && item.arms[0]?.body.length === 0 && item.arms[0]?.ends !== null}
-      {#if guard}
-        <!--
-          An early exit is a fork with nothing on one side: `if (!user) return`.
-          A reader takes it as a guard, not as a branch — one line saying where
-          the code leaves, and everything below it running when it did not — so
-          it is drawn as one, and the rail does not step right for it.
-        -->
-        <div class="guard">
-          <span class="cond mono">{@render words(item.words)}</span>
-          <span class="ends inline">{item.arms[0]!.ends}</span>
-        </div>
-        {#if item.arms[1] && item.arms[1].body.length > 0}
-          <Self items={item.arms[1].body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
-        {/if}
-        {#if item.arms[1]?.ends}<div class="ends">{item.arms[1].ends}</div>{/if}
-      {:else}
-        <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>
-      {/if}
-    {: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;
-  }
-  /* An early exit: the condition and where it leaves, on one line. */
-  .guard {
-    display: flex;
-    align-items: baseline;
-    gap: 8px;
-    max-width: 100%;
-    min-width: 0;
-  }
-  .ends.inline {
-    border-top: 0;
-    padding-top: 0;
-    align-self: auto;
-    white-space: nowrap;
-  }
-  .kw {
-    font-weight: 600;
-  }
-</style>

+ 9 - 5
ui/src/components/steps/StepsKey.svelte

@@ -87,16 +87,20 @@
       {/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>
+          <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
+          <span>And then — the step at the other end happens after this one; the plumbing between them is folded into the line</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>
+          <span class="k-label mono">WHEN x</span>
+          <span>Where the code forks — an <span class="mono">if</span>, a <span class="mono">switch</span>, a <span class="mono">try</span>, an early exit: what has to hold for the step at the other end. No label = it happens either way</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>
+          <span>Written inside 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">
+          <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">
           <span class="k-label mono">name …</span>

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

@@ -1,94 +0,0 @@
-<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>

+ 262 - 149
ui/src/lib/program-model.ts

@@ -1,184 +1,297 @@
 /**
- * The Steps view's second reading, as the rail draws it.
+ * The Steps picture in the code's ORDER — the same canvas, laid out by when
+ * things happen rather than by how far they are from the anchor.
  *
- * 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 tree's rows are distance: on proshop's login `User.findOne`, `jwt.sign`,
+ * `200` and `401` are each one step out of the handler, so they land in one
+ * row. True, and not the flow — the token is signed while the reply is being
+ * built, so the `200` comes AFTER it, and the `401` is the other side of the
+ * same `if`. This model draws that:
  *
- * 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.
+ * ```
+ *                POST /api/users/login
+ *                        |
+ *                  User.findOne
+ *          +-------------+--------------+
+ *   WHEN user AND (await ...)     WHEN NOT (...)
+ *          |                            |
+ *      jwt.sign                        401
+ *          |
+ *         200
+ * ```
+ *
+ * **A line here means "then", not "calls"** — that is the whole difference from
+ * the other reading, and the key says so. It comes from the block tree the
+ * server folds out of the walk (`api/program.ts`): items in source order, forks
+ * where the code forks, an arm that answers or leaves ending there. Walking that
+ * tree with a set of *tails* — the steps a next step would follow — gives one
+ * edge per "and then", carrying the arm's condition where the code branched.
+ *
+ * Everything else is the canvas's: the same boxes, the same layout engine, the
+ * same pills, hover and panel. Only the graph changes.
  */
 
-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';
+import { conditionTokens, joinTokens, type WordToken } from './conditions';
+import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
+import { samplePolyline, trackedCurves, EDGE_LABEL_MAX, SCREEN_LAYER_GAP, type Point } from './screens-model';
+import { stepLabel, stepSub, type StepEdgeInfo, type StepNodeInfo, type StepsModel } from './steps-model';
+import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire';
+
+/* ----------------------------------------------------------------- words -- */
 
 /** 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;
+/**
+ * What has to hold for an arm to be the one taken, in the words the rest of the
+ * view uses. Said in FULL on the line, because a line on a canvas has no head
+ * above it to refer back to: `WHEN user AND (await …)`, `WHEN NOT (…)`.
+ */
+export function whenTokens(when: string): WordToken[] {
+  return conditionTokens(when);
 }
 
-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[];
+/** The words on a run that is not plain sequence — said on the line into it. */
+export function runWords(item: Extract<WireItem, { kind: 'block' }>): string {
+  switch (item.block) {
+    case 'inline':
+      return item.via ? `via ${item.via.name}` : 'via a helper';
+    case 'loop':
+      if (!item.by) return item.loop === 'while' ? 'again and again' : 'for each item';
+      return item.loop === 'while' ? `again while ${item.by}` : `for each ${item.by}`;
+    case 'later':
+      return item.by ? `later · ${item.by}` : 'later';
+    default:
+      return item.by ? `together · ${item.by}` : 'together';
+  }
 }
 
-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[];
-}
+/* ----------------------------------------------------------------- graph -- */
 
-/** 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[];
+/** One "and then": the step it follows, the step that happens, and what had to hold. */
+export interface OrderEdge {
+  from: string;
+  to: string;
+  /** The conditions on the way — the arms of the forks crossed, joined by ` && `. */
+  when: string;
+  /** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */
+  runs: string[];
 }
 
-export interface RailCut {
-  kind: 'cut';
-  text: string;
+/** Where a next step would follow from, and under what. */
+interface Tail {
+  id: string;
+  when: string[];
+  runs: string[];
 }
 
-export type RailItem = RailStep | RailFork | RailGroup | RailCut;
+export interface OrderGraph {
+  edges: OrderEdge[];
+  /** How many things happen before each step: its row. */
+  depth: Map<string, number>;
+}
 
 /**
- * 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).
+ * The block tree as a graph of what happens next. `anchor` is where the reading
+ * starts, so the first thing in the body follows it.
  */
-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);
-}
+export function orderGraph(program: NonNullable<WireStepsPayload['program']>, anchor: string): OrderGraph {
+  const edges: OrderEdge[] = [];
+  const seen = new Set<string>([anchor]);
+  const at = new Map<string, OrderEdge>();
 
-function block(items: WireBlock, steps: Map<string, WireStep>, project: ProjectKind): RailItem[] {
-  return items.map((item) => one(item, steps, project));
-}
+  const join = (from: string, to: string, tail: Tail): void => {
+    if (from === to) return;
+    const key = `${from} ${to}`;
+    const when = tail.when.filter((w, i) => w && tail.when.indexOf(w) === i).join(' && ');
+    const found = at.get(key);
+    if (found) {
+      // Two ways to the same step: the picture keeps both conditions, the way
+      // a link with several sites does.
+      if (when !== found.when) found.when = !when || !found.when ? '' : `${found.when} || ${when}`;
+      for (const r of tail.runs) if (!found.runs.includes(r)) found.runs.push(r);
+      return;
+    }
+    const edge: OrderEdge = { from, to, when, runs: [...tail.runs] };
+    at.set(key, edge);
+    edges.push(edge);
+  };
 
-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,
-      };
+  const flow = (block: WireBlock, incoming: readonly Tail[], runs: readonly string[]): Tail[] => {
+    let tails: Tail[] = [...incoming];
+    for (const item of block) {
+      if (item.kind === 'step') {
+        seen.add(item.step);
+        for (const t of tails) join(t.id, item.step, { ...t, runs: [...t.runs, ...runs] });
+        // What the step itself sets in motion happens inside it, so the next
+        // thing in the block follows THAT, not the box.
+        let inner: Tail[] = [{ id: item.step, when: [], runs: [] }];
+        if (item.body && item.body.length > 0) inner = flow(item.body, inner, []);
+        tails = inner;
+      } else if (item.kind === 'fork') {
+        const out: Tail[] = [];
+        for (const arm of item.arms) {
+          // The arm's condition as the SOURCE has it: the words are made once,
+          // at the end, or two ways of arriving would each carry their own WHEN.
+          const entry = tails.map((t) => ({ id: t.id, when: [...t.when, arm.when], runs: [...t.runs, ...runs] }));
+          // An arm that answers, returns or throws does not rejoin — nothing
+          // leaves the last box in it, which is what says so on a canvas.
+          const armTails = flow(arm.body, entry, []);
+          if (arm.ends === null) out.push(...armTails);
+        }
+        // An `if` with no `else` runs on either way; a fork with both sides
+        // covered runs on only through the arms that did not end.
+        if (item.arms.length < 2) out.push(...tails.map((t) => ({ ...t, runs: [...t.runs, ...runs] })));
+        tails = out;
+      } else if (item.kind === 'block') {
+        const label = runWords(item);
+        const inner = flow(item.body, tails, [...runs, label]);
+        // A helper that answers on one path still returns on another: the code
+        // after the call follows the call, not nothing. And what comes after
+        // the call is not inside it — a tail that fell through the block drops
+        // the block's own words on the way out.
+        tails = (inner.length > 0 ? inner : tails).map((t) => (t.runs.includes(label) ? { ...t, runs: t.runs.filter((r) => r !== label) } : t));
+      }
     }
-    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',
-      };
+    return tails;
+  };
+
+  flow(program.root, [{ id: anchor, when: [], runs: [] }], []);
+
+  // Nothing the reading holds may float: a step the walk drew but the fold
+  // could not place follows the anchor, unconditionally.
+  for (const id of seen) {
+    if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] });
   }
+
+  return { edges, depth: rows(anchor, seen, edges) };
 }
 
 /**
- * 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.
+ * The row each step sits on: the longest run of "and then" from the anchor to
+ * it, so a step never draws above something that has to happen first. Settled
+ * by relaxation rather than a topological sort, because a step reached twice
+ * (`session.add` before and after a check) can make the graph cyclic.
  */
-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);
+function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): Map<string, number> {
+  const depth = new Map<string, number>();
+  for (const id of nodes) depth.set(id, 0);
+  depth.set(anchor, 0);
+  for (let pass = 0; pass < nodes.size; pass++) {
+    let moved = false;
+    for (const e of edges) {
+      const next = (depth.get(e.from) ?? 0) + 1;
+      if (next > (depth.get(e.to) ?? 0)) {
+        depth.set(e.to, next);
+        moved = true;
+      }
+    }
+    if (!moved) break;
+  }
+  return depth;
 }
 
-/** 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';
+/* ----------------------------------------------------------------- build -- */
+
+/** Points a curve is sampled at for hit-testing (as the other reading's). */
+const HIT_SAMPLES = 24;
+
+/**
+ * The same picture as `buildStepsModel`, laid out in the code's order. Null
+ * when the anchor has no body to read — the view then offers the tree.
+ */
+export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
+  if (!payload.program) return null;
+  const anchorStep = payload.steps.find((s) => s.anchor);
+  if (!anchorStep) return null;
+  const graph = orderGraph(payload.program, anchorStep.id);
+
+  const nodes = new Map<string, StepNodeInfo>();
+  const modules: WireMapModule[] = [];
+  const counts: StepsModel['counts'] = { anchor: 0, screen: 0, trigger: 0, bridge: 0, event: 0, store: 0, effect: 0 };
+  const degree = new Map<string, number>();
+  for (const e of graph.edges) {
+    degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
+    degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
+  }
+  const byId = new Map(payload.steps.map((s) => [s.id, s]));
+  for (const id of [anchorStep.id, ...graph.depth.keys()]) {
+    if (nodes.has(id)) continue;
+    const step: WireStep | undefined = byId.get(id);
+    if (!step) continue;
+    counts[step.kind]++;
+    const info: StepNodeInfo = { id, step, label: stepLabel(step), sub: stepSub(step, payload.project) };
+    nodes.set(id, info);
+    modules.push({
+      id,
+      label: info.label,
+      files: 1,
+      symbols: degree.get(id) ?? 0,
+      languages: [],
+      test: false,
+      generated: 0,
+      generatedFiles: [],
+      facade: false,
+      fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
+    });
   }
-}
 
-/** 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':
-      if (!item.by) return item.loop === 'while' ? 'again and again' : 'for each item';
-      return item.loop === 'while' ? `again while ${item.by}` : `for each ${item.by}`;
-    case 'later':
-      return item.by ? `later · ${item.by}` : 'later';
-    default:
-      return item.by ? `together · ${item.by}` : 'together';
+  const links: WireMapLink[] = [];
+  const edges = new Map<string, StepEdgeInfo>();
+  for (const e of graph.edges) {
+    if (!nodes.has(e.from) || !nodes.has(e.to)) continue;
+    const key = linkId({ source: e.from, target: e.to });
+    if (edges.has(key)) continue;
+    links.push({ source: e.from, target: e.to, count: 1, declared: 1, byKind: [{ kind: 'calls', count: 1 }], topPairs: [] });
+    // The panel and the tooltip still read the walk's own links — the sites,
+    // the `via` chain, what fires it — for the step the line arrives at.
+    const behind = payload.links.filter((l) => l.to === e.to);
+    edges.set(key, {
+      id: key,
+      from: e.from,
+      to: e.to,
+      links: behind,
+      label: lineWords(e),
+      synthesized: behind.length > 0 && behind.every((l) => l.synthesized),
+      kind: behind[0]?.kind ?? 'calls',
+    });
   }
+
+  // Row 0 is the bottom, so the anchor — nothing happens before it — is on top.
+  const deepest = Math.max(0, ...graph.depth.values());
+  const layering = (ids: string[]): Map<string, number> =>
+    new Map(ids.map((id) => [id, deepest - (graph.depth.get(id) ?? deepest)]));
+
+  const layout: MapLayout = buildMapLayout(
+    { modules, links },
+    {
+      includeTests: true,
+      minWeight: 0,
+      sizing: (m) => {
+        const info = nodes.get(m.id);
+        return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
+      },
+      layering,
+      order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
+      layerGap: SCREEN_LAYER_GAP,
+      portPitch: PORT_PITCH,
+      ports: 'directional',
+    }
+  );
+  const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
+  const polylines = new Map<string, Point[]>();
+  for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
+  return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
+}
+
+/**
+ * What a line says: the whole condition the step at its end runs under — this
+ * picture's lines ARE its conditions, so they are not shortened to the last
+ * clause the way the other reading's are — else the run it happens inside
+ * (`via generateToken`), and nothing at all when the code simply goes on.
+ */
+export function lineWords(e: OrderEdge): string {
+  if (!e.when) return e.runs.length > 0 ? e.runs[e.runs.length - 1]! : '';
+  const text = joinTokens(whenTokens(e.when));
+  return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
 }

+ 8 - 4
ui/src/lib/screens-model.ts

@@ -757,18 +757,22 @@ function layPill(
  * pill: the pill for a hovered edge that is not the selected screen's is
  * placed separately by {@link hoverPill}.
  */
-export function placeLabels(model: Picture, selected: string | null): PillLayout {
+export function placeLabels(model: Picture, selected: string | null, atRest = false): PillLayout {
   const pills = new Map<string, PillPlacement>();
-  if (selected === null) return { pills, hidden: 0 };
+  if (selected === null && !atRest) return { pills, hidden: 0 };
   const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
   const lanes = laneCount(model.layerGap);
   const bounds = { width: model.layout.width, height: model.layout.height };
   const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
 
+  // At rest, only the selected screen's lines are labelled — a picture with a
+  // label on every line is unreadable, and the reader has asked about one box.
+  // A picture whose labels ARE its content says so (`atRest`): the Steps view
+  // in the code's order, where the conditions on the lines are the flow.
   const candidates = model.layout.edges
-    .filter((e) => e.source === selected || e.target === selected)
+    .filter((e) => atRest || e.source === selected || e.target === selected)
     .map((edge) => {
-      const end: 'source' | 'target' = edge.source === selected ? 'target' : 'source';
+      const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target';
       const far = nodes.get(end === 'source' ? edge.source : edge.target);
       const anchor = far ? portPoint(far, edge.id, end) : { x: 0, y: 0 };
       return { edge, end, anchor };

+ 39 - 67
ui/src/views/StepsView.svelte

@@ -17,7 +17,6 @@
   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';
@@ -46,7 +45,7 @@
     triggerWords,
     type StepsModel,
   } from '../lib/steps-model';
-  import { buildRailModel } from '../lib/program-model';
+  import { buildOrderModel } from '../lib/program-model';
 
   interface Props {
     anchor: string | null;
@@ -192,37 +191,24 @@
     return () => controller.abort();
   });
 
-  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;
-  }
+
+  /**
+   * The picture. Both readings are the same canvas over the same boxes; what
+   * differs is the graph — in the code's order a line means "and then" and the
+   * rows are how much has already happened, in the tree it means "leads to" and
+   * the rows are distance from the anchor.
+   */
+  const model = $derived<StepsModel | null>(
+    payload === null ? null : (readAs === 'order' ? buildOrderModel(payload) : null) ?? buildStepsModel(payload)
+  );
+  /** The order can be asked for and have nothing to read: the view then says so. */
+  const orderReadable = $derived(payload?.program != null);
 
   const neighbours = $derived.by(() => {
     if (model === null || selected === null) return null;
@@ -234,7 +220,9 @@
     return set;
   });
 
-  const pills = $derived(model === null ? null : placeLabels(model, selected));
+  // In the code's order the conditions ON the lines are the picture: they are
+  // drawn at rest, not only for the step the reader selected.
+  const pills = $derived(model === null ? null : placeLabels(model, selected, readAs === 'order'));
   const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
   const focusPill = $derived.by(() => {
     if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
@@ -349,7 +337,7 @@
   }
 
   function onStageMove(event: MouseEvent): void {
-    if (model === null || stage === null || readAs === 'order') return;
+    if (model === null || stage === null) return;
     const target = event.target as Element | null;
     if (target?.closest('.spill')) return;
     if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
@@ -491,37 +479,15 @@
       </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 && readAs === 'order' && !orderReadable}
+      <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>
     {:else if model !== null && payload !== null}
       <SvelteFlow
         {nodes}
@@ -567,8 +533,14 @@
       {/if}
     {/if}
 
-    {#if payload !== null && model !== null && readAs === 'tree'}
-      <StepsKey project={payload.project} order={false} flow={false} open={legendOpen} onToggle={(next) => (legendOpen = next)} />
+    {#if payload !== null && model !== null && (readAs === 'tree' || orderReadable)}
+      <StepsKey
+        project={payload.project}
+        order={readAs === 'order'}
+        flow={false}
+        open={legendOpen}
+        onToggle={(next) => (legendOpen = next)}
+      />
     {/if}
   </div>
 
@@ -781,11 +753,11 @@
         </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.
+            <span class="mark">●</span> The anchor is at the top, and each row down is what happens next: a line
+            means <b>and then</b>, and where the code forks the line says what has to hold. A call written inside
+            another call's arguments happens first — the token is signed before the reply that carries it — and an
+            arm that answers, returns or throws simply has nothing leaving it. Click a step for its sites and the
+            whole condition; a step is the next anchor.
           </p>
         {:else}
           <p class="dim">

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor