Explorar o código

feat(steps): render fork decisions as points with per-arm edges and captions

Adds full support for decisions at forks in both the code graph and the UI. Key changes introduce a decision model for forks (innermost guard decisions), propagate decision data through the server and wire layer, and render decisions in the UI as distinct points with labeled arms. New components (ForkPoint and DecisionCaption) visualize the decision and its arms, while utilities (armWords, forkLabel) generate arm captions. The order reading (canvas) now shows decisions as points, and arms are drawn as separate edges (yes/no/case), with labels and captions displayed under the deciding box. Tests, typings, and docs updated to reflect the new decision visualization and behavior, including selection reach and resting-label semantics. This lays the groundwork for clearer visualization of conditional navigation and guarded branches on the order canvas.
Colby McHenry hai 4 días
pai
achega
3298db1292

+ 3 - 1
CHANGELOG.md

@@ -16,9 +16,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps.
 
+- **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time.
+
 - **A dialog's buttons fire what they run.** `Alert.prompt('Add Folder', …, [{ onPress: (name) => createBackgroundFolder(name) }])` is two facts: the prompt is a call that leaves the index, and its button fires the handler. The handler's line now arrives from the dialog's own box — with the condition on it — instead of from the screen, so the confirm-then-act chains a mobile app is full of read as chains: the delete alert leads to the delete, which leads to the request it sends. The same holds for anything bound inside the arguments of a call that leaves the index. Nothing needs a re-index: it is read from the source at request time.
 
-- **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.
+- **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 both ways, a small box asks the condition once and each line out of it answers — `yes`, `no`, a case's value — so the two arms of one `if` read as one decision instead of two lines that each carry the whole predicate, one of them negated; selecting a step next to the decision lights the whole fork through it. A lone guard — an early exit, an `if` with one drawn side — keeps its condition on the line, 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.
 

+ 15 - 0
__tests__/expo-router.test.ts

@@ -580,6 +580,21 @@ describe('expo-router: end-to-end', () => {
     expect(toRoot.when).not.toMatch(/!/);
     expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
 
+    // …and each site names the DECISION its condition belongs to, so the two
+    // arms can be drawn as one choice rather than as two lines that happen to
+    // read as each other's negation. Same branch, opposite arms, one `on`.
+    const rootArm = toRoot.sites[0]!.decision!;
+    const welcomeArm = toWelcome.sites[0]!.decision!;
+    expect(rootArm.branch).toBe(welcomeArm.branch);
+    expect(rootArm.branch).not.toBe('');
+    expect(rootArm.form).toBe('ternary');
+    expect(rootArm.on).toBe(welcomeArm.on);
+    expect(rootArm.on).toMatch(/await seen\(\)/);
+    expect(rootArm.on).not.toMatch(/^!/);
+    expect(rootArm.not).toBeUndefined();
+    expect(welcomeArm.not).toBe(true);
+    expect(rootArm.arm).not.toBe(welcomeArm.arm);
+
     cg.close();
   });
 });

+ 145 - 8
__tests__/ui-program-model.test.ts

@@ -10,6 +10,8 @@
 
 import { describe, it, expect } from 'vitest';
 import { buildOrderModel, lineWords, orderGraph, runWords } from '../ui/src/lib/program-model';
+import { selectionReach, stepEdgeVisible } from '../ui/src/lib/steps-model';
+import { placeLabels } from '../ui/src/lib/screens-model';
 import type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
 
 /* ------------------------------------------------------------ material -- */
@@ -65,8 +67,11 @@ describe('the picture in the code’s order', () => {
     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', () => {
+  it('diverges both arms from a point that asks the condition once', () => {
     // proshop's login: look the user up, then sign+answer 200, else answer 401.
+    // The decision is ONE choice, so it draws once — a point the arms leave,
+    // each line saying only which arm it is — not two lines that each carry
+    // the whole predicate, one of them negated.
     const on = 'user && (await user.matchPassword(password))';
     const root: WireBlock = [
       { kind: 'step', step: 'findOne' },
@@ -80,14 +85,20 @@ describe('the picture in the code’s order', () => {
         ],
       },
     ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    expect(g.forks).toEqual([{ id: 'fork:0', on, form: 'if' }]);
     expect(shape(root)).toEqual([
       'anchor → findOne',
-      'findOne → sign · WHEN user AND (await user.matchPassword… [via a helper]',
+      'findOne → fork:0',
+      'fork:0 → sign · yes [via a helper]',
       'sign → 200',
-      'findOne → 401 · WHEN NOT (user && (await user.matchPass…',
+      'fork:0 → 401 · no',
     ]);
-    // 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 });
+    // The arm's own condition still rides the line, for the hover.
+    expect(g.edges.find((e) => e.to === '401')!.when).toBe(`!(${on})`);
+    // The 200 sits a row BELOW the signing, which is the whole point; the
+    // decision takes a row of its own between the lookup and the arms.
+    expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, 'fork:0': 2, sign: 3, '200': 4, '401': 3 });
   });
 
   it('rejoins after an arm that runs on, and stops at one that ends', () => {
@@ -103,12 +114,72 @@ describe('the picture in the code’s order', () => {
     ];
     expect(shape(root)).toEqual([
       'anchor → lookup',
-      'lookup → inside · WHEN ready',
-      'lookup → bail · WHEN NOT ready',
+      'lookup → fork:0',
+      'fork:0 → inside · yes',
+      'fork:0 → bail · no',
       'inside → after',
     ]);
   });
 
+  it('labels a switch’s arms with their own values, and its default with else', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'load' },
+      {
+        kind: 'fork',
+        form: 'switch',
+        on: 'status',
+        arms: [
+          arm("status === 'expired'", [{ kind: 'step', step: 'refresh' }]),
+          arm("status === 'active'", [{ kind: 'step', step: 'serve' }]),
+          arm("!(status === 'expired' || status === 'active')", [{ kind: 'step', step: 'reject' }], { not: true, ends: 'reply' }),
+        ],
+      },
+    ];
+    expect(shape(root)).toEqual([
+      'anchor → load',
+      'load → fork:0',
+      "fork:0 → refresh · 'expired'",
+      "fork:0 → serve · 'active'",
+      'fork:0 → reject · else',
+    ]);
+  });
+
+  it('keeps a lone guard on the line — an early exit is not a point', () => {
+    // `if (!product) throw` — the exit arm is empty; only one arm draws, so
+    // the condition rides the line exactly as before.
+    const root: WireBlock = [
+      { kind: 'step', step: 'lookup' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'product',
+        arms: [arm('product', [], { ends: 'throw' }), arm('!product', [{ kind: 'step', step: 'render' }], { not: true })],
+      },
+    ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    expect(g.forks).toEqual([]);
+    expect(shape(root)).toEqual(['anchor → lookup', 'lookup → render · WHEN NOT product']);
+  });
+
+  it('stops claiming a side when both arms reach the same step', () => {
+    const root: WireBlock = [
+      { kind: 'step', step: 'check' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'a',
+        arms: [
+          arm('a', [{ kind: 'step', step: 'log' }, { kind: 'step', step: 'go' }]),
+          arm('!(a)', [{ kind: 'step', step: 'log', again: true }], { not: true }),
+        ],
+      },
+    ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    const toLog = g.edges.find((e) => e.to === 'log')!;
+    expect(toLog.arm).toBeUndefined();
+    expect(toLog.when).toBe('a || !(a)');
+  });
+
   it('runs on either way past an `if` with no else', () => {
     const root: WireBlock = [
       { kind: 'step', step: 'lookup' },
@@ -163,7 +234,35 @@ describe('the picture in the code’s order', () => {
   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);
+    expect(rowsOf(root)).toEqual({ anchor: 0, db: 1, check: 2 });
+  });
+
+  it('never spreads a cyclic reading over more rows than it has boxes', () => {
+    // A helper the code comes back to from inside a decision makes the graph
+    // cyclic. Relaxing over a cycle never settles — it added a row on every
+    // pass until the bound, so on a real screen sixteen boxes landed on sixty
+    // rows and the picture was a 9,000px ribbon of empty space that no fit
+    // could open on.
+    const root: WireBlock = [
+      { kind: 'step', step: 'logout' },
+      { kind: 'step', step: 'flags' },
+      {
+        kind: 'fork',
+        form: 'if',
+        on: 'options?.showAlert',
+        arms: [
+          arm('options?.showAlert', [{ kind: 'step', step: 'logout', again: true }]),
+          arm('!options?.showAlert', [{ kind: 'step', step: 'quiet' }], { not: true }),
+        ],
+      },
+    ];
+    const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
+    // The cycle is real and still drawn — it is only the ROW that ignores it.
+    expect(g.edges.some((e) => e.to === 'logout' && e.from.startsWith('fork:'))).toBe(true);
+    const depths = [...g.depth.values()];
+    expect(Math.max(...depths)).toBeLessThan(g.depth.size);
+    // Every row between the top and the deepest holds something.
+    expect(new Set(depths).size).toBe(Math.max(...depths) + 1);
   });
 
   it('names each kind of run', () => {
@@ -190,4 +289,42 @@ describe('the picture in the code’s order', () => {
     expect(layer('findOne')).toBeGreaterThan(layer('200'));
     expect(buildOrderModel({ ...payload([], []), program: null })).toBeNull();
   });
+
+  it('draws a decision as a point, and the selection reaches through it', () => {
+    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' })],
+      },
+    ];
+    const model = buildOrderModel(payload([step('lookup'), step('inside'), step('bail')], root))!;
+    expect(model.forks!.get('fork:0')).toEqual({ id: 'fork:0', on: 'ready', form: 'if', label: 'ready?' });
+    // The point sits between the step before the fork and the arms; it is not a step.
+    const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
+    expect(at('fork:0').y).toBeGreaterThan(at('lookup').y);
+    expect(at('fork:0').y).toBeLessThan(at('inside').y);
+    expect(model.nodes.has('fork:0')).toBe(false);
+    expect(model.counts.effect).toBe(3);
+    // The lines out of it say the arm; the line into it says nothing.
+    const label = (to: string) => [...model.edges.values()].find((e) => e.to === to)!.label;
+    expect(label('fork:0')).toBe('');
+    expect(label('inside')).toBe('yes');
+    expect(label('bail')).toBe('no');
+    // At rest the arms are labelled — the conditions are this picture's content.
+    const pills = placeLabels(model, null, true);
+    expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
+    // Selecting the step before the decision reaches through the point: the
+    // arms' lines light, instead of dying at a box the reader cannot click.
+    const reach = selectionReach(model, 'lookup');
+    expect(reach.has('fork:0')).toBe(true);
+    const armEdge = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'inside')!;
+    expect(stepEdgeVisible(model, armEdge, 'lookup')).toBe(true);
+    expect(stepEdgeVisible(model, armEdge, 'lookup', reach)).toBe(true);
+    // …and selecting an arm lights its sibling, through the same point.
+    const sibling = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'bail')!;
+    expect(stepEdgeVisible(model, sibling, 'inside')).toBe(true);
+  });
 });

+ 91 - 2
__tests__/ui-steps-model.test.ts

@@ -4,9 +4,9 @@
  * rule, and the panel's two lists.
  */
 import { describe, it, expect } from 'vitest';
-import { buildStepsModel, countWords, kindWord, kindWords, stepEdgeVisible, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
+import { armWords, buildStepsModel, countWords, kindWord, kindWords, stepEdgeVisible, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
 import { placeLabels } from '../ui/src/lib/screens-model';
-import type { WireNodeRef, WireStep, WireStepLink, WireStepsPayload } from '../ui/src/lib/wire';
+import type { WireNodeRef, WireStep, WireStepLink, WireStepSite, WireStepsPayload } from '../ui/src/lib/wire';
 
 function ref(name: string, file = 'src/a.tsx', language: WireNodeRef['language'] = 'tsx'): WireNodeRef {
   return { id: `function:${name}`, kind: 'function', name, qualifiedName: name, file, line: 1, endLine: 9, language, test: false };
@@ -117,6 +117,95 @@ describe('steps model', () => {
   });
 });
 
+describe('a decision drawn where it is made', () => {
+  // The real shape this exists for: `return (await hasSeenWelcome(id)) ?
+  // '/home/' : '/welcome/'` inside a store action, whose two returned routes
+  // are two `navigates` edges out of ONE box. Each carried the whole
+  // predicate — one of them the other's negation — and at rest the tree drew
+  // both with no label at all, so nothing said it was a choice.
+  const ON = 'await hasSeenWelcome(welcomeUserId())';
+  const BRANCH = '140:9';
+  const site = (when: string, not?: true): WireStepSite => ({
+    file: 'src/org-user.storage.ts',
+    line: 140,
+    text: `push ${when}`,
+    when,
+    decision: { branch: BRANCH, on: ON, arm: when, form: 'ternary', ...(not ? { not: true as const } : {}) },
+  });
+
+  const anchor = step('/terms-of-service', 'screen', 0, { anchor: true });
+  const resolve = step('resolvePostLoginRoute', 'store', 1, { node: ref('resolvePostLoginRoute', 'src/org-user.storage.ts') });
+  const home = step('/home', 'screen', 2, { screen: { path: '/home', component: null } });
+  const welcome = step('/welcome', 'screen', 2, { screen: { path: '/welcome', component: null } });
+  const links = [
+    link(anchor, resolve, { kind: 'store' }),
+    link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] }),
+    link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
+  ];
+  const model = buildStepsModel(payload([anchor, resolve, home, welcome], links));
+  const edgeTo = (id: string) => [...model.edges.values()].find((e) => e.to === id)!;
+
+  it('says the condition once, under the box that decides it', () => {
+    expect(model.decisions).toHaveLength(1);
+    const d = model.decisions[0]!;
+    expect(d.label).toBe('await hasSeenWelcome(welcomeUserId())?');
+    // Under the deciding box and centred on it — not under the arms. The
+    // condition may take more room than the box, since reading it is the
+    // whole point of the caption.
+    const box = model.layout.nodes.find((n) => n.id === resolve.id)!;
+    expect(d.x + d.width / 2).toBeCloseTo(box.x + box.width / 2, 5);
+    expect(d.width).toBeGreaterThanOrEqual(box.width);
+    expect(d.y).toBeGreaterThan(box.y + box.height - 1);
+  });
+
+  it('each line out answers, instead of carrying the whole predicate', () => {
+    expect(edgeTo(home.id).arm).toBe('yes');
+    expect(edgeTo(home.id).label).toBe('yes');
+    expect(edgeTo(welcome.id).arm).toBe('no');
+    expect(edgeTo(welcome.id).label).toBe('no');
+    // The line into the deciding box is not an arm of anything.
+    expect(edgeTo(resolve.id).arm).toBeUndefined();
+  });
+
+  it('labels the arms at rest — and only the arms', () => {
+    const arms = new Set([...model.edges.values()].filter((e) => e.arm !== undefined).map((e) => e.id));
+    const pills = placeLabels(model, null, arms);
+    expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
+    // With nothing asked for, the tree stays unlabelled as it always was.
+    expect(placeLabels(model, null, false).pills.size).toBe(0);
+  });
+
+  it('keeps a lone arm, and a step reached either way, on a plain line', () => {
+    // One drawn arm is a guard clause, not a choice.
+    const only = buildStepsModel(
+      payload([anchor, resolve, home], [link(anchor, resolve, { kind: 'store' }), link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] })])
+    );
+    expect(only.decisions).toEqual([]);
+    expect([...only.edges.values()].every((e) => e.arm === undefined)).toBe(true);
+
+    // A connector with a site that runs under NO condition is not exclusively
+    // an arm — the step happens either way — so it never claims a side.
+    const both = buildStepsModel(
+      payload(
+        [anchor, resolve, home, welcome],
+        [
+          link(anchor, resolve, { kind: 'store' }),
+          link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON), { file: 'x.ts', line: 9, text: 'push', when: '' }] }),
+          link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
+        ]
+      )
+    );
+    expect(both.decisions).toEqual([]);
+  });
+
+  it('words a switch arm by its own value, and the default by else', () => {
+    expect(armWords({ on: 'status', arm: "status === 'expired'", form: 'switch' })).toBe("'expired'");
+    expect(armWords({ on: 'status', arm: 'anything', form: 'switch', not: true })).toBe('else');
+    expect(armWords({ on: 'ready', arm: 'ready', form: 'if' })).toBe('yes');
+    expect(armWords({ on: 'ready', arm: '!ready', form: 'if', not: true })).toBe('no');
+  });
+});
+
 describe('words per project', () => {
   it('names the same box for an app, an API and a web app', () => {
     expect(kindWord('screen', 'app')).toBe('screen');

+ 57 - 10
docs/design/codegraph-ui-design-spec.md

@@ -565,6 +565,28 @@ tracked curves (over a tighter in-region gap), same pills, pointer and panel. Re
 picture ~3,400px (was 28,452), at-rest lines on `/home` 80 of 190 — the region-local structure plus 11 lines between
 regions — with zero boxes that lead somewhere while drawing nothing.
 
+**Decisions — a choice made inside a box, said under it.** A fork the tree can see is written *inside* a box and its
+arms *leave* that box: `resolvePostLoginRoute` ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'`, so two
+`navigates` lines leave one store action. Each carried the whole predicate, one of them the other's negation, truncated
+to the same forty characters — and at rest the tree labels nothing, so the picture never said it was a choice at all.
+Now sibling connectors out of one box that are arms of ONE fork are drawn as the choice they are: the condition once,
+in a caption under the deciding box (`DecisionCaption.svelte`, centred on it and allowed a little more width, since
+reading it is the point), and each line out saying only which way it is — `yes` / `no`, a case's own value, `else` for
+a default (`armWords`, the ONE place either reading words an arm, so the tree and the order reading can never disagree).
+These are **the only lines labelled at rest** in the tree: `placeLabels`' third argument took a boolean and now takes
+a *set* of edges, so the order reading still labels everything and the tree labels exactly the arms.
+
+What makes it possible is the same one idea the order reading's fold rests on, carried one step further out — onto the
+wire. `WireStepSite.decision` (`{ branch, on, arm, form, not? }`) records the decision a site's **innermost** guard
+belongs to: the innermost is the one decided AT the call, while the guards outside it are context both arms share.
+`steps.ts` had `BranchGuard.branch` in hand at all three link paths (arrivals, the known-step re-link, `effectLink`)
+and was dropping it. Two sites agreeing on `branch` and disagreeing on `arm` are the two ways of one fork — which a
+joined condition string can never say, however exactly one reads as the other's negation, and which no amount of
+`X` vs `!(X)` string-matching may be allowed to guess. **Honest by construction, three ways:** a connector is an arm
+only when EVERY site behind it carries the same decision (one site running under no condition means the step happens
+either way, so the line claims nothing); a fork with one drawn arm is a guard clause and keeps its condition on the
+line; and an early exit (`form: 'guard'`) never becomes a decision at all.
+
 **Servers (Express, NestJS, Fastify, Koa, Hono, FastAPI, Flask, Django, Spring, ASP.NET, Vapor, Gin).** The same picture over
 the same machinery; only the facts and the words change (`src/ui-server/api/route-roots.ts`, `effects.ts`,
 `docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md` §4). A route anchor's walk starts at the symbol the route runs —
@@ -653,19 +675,39 @@ walk has a second reading, on **the same canvas, with the same boxes**: only the
                 POST /api/users/login · authUser
                     User.findOne({ email })
-        ┌───────────────────┴────────────────────┐
-  → WHEN user AND (await …)              → WHEN NOT (user && …)
-        │                                         │
-  jwt.sign({ id }, …)  auth                 401  response
-        │
-   200  response
+                            │
+            ┌ user AND (await user.matchPassword(…))? ┐
+                 │                          │
+               → yes                      → no
+                 │                          │
+        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.
+`401` branches at the fork. The conditions are **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.
+
+**A decision both of whose ways are drawn is a POINT, not two labelled lines.** Two edges that each carried the whole
+predicate — one of them negated, their chips truncated to near-identical strings — never said they were the same
+choice. (The tree answers the same problem differently, because there a fork is written INSIDE a box and its arms
+leave it: see **Decisions** in §3.13. Here the fork sits BETWEEN steps, so it gets a box of its own; either way
+`armWords` is the one place an arm is worded.) So a fork two or more of whose arms lead somewhere diverges from a
+small box of its own (`ForkPoint.svelte`,
+`fork:N` in the layout, quieter than a step: one centred line, border `--ink-2`): the box asks the condition once
+(`user AND (await …)?`; a switch asks its subject), and each line out answers with only the arm — `yes` / `no` for an
+`if` or a ternary, the case's own value with the subject stripped (`'expired'`), `else` for a default — as a pill at
+the arm's far end, the arm's full condition still riding the edge for the hover. The point is not a step: it takes no
+click, counts in no summary, and the panel lists nothing for it; selection instead reaches THROUGH it
+(`selectionReach`) — selecting the step before the fork lights both arms, selecting an arm lights its sibling — and
+the neighbour dimming follows the same closure. Client-side entirely: `orderGraph` mints the points from the fork
+items the wire already carries (`WireArm.not` marks the else side), so nothing changes on the server or in the tree
+reading. **A fork with ONE drawn arm keeps the plain line** — an early exit reads as a guard clause (`WHEN NOT
+product` on the line), never as a box with a single exit — and an arm reached from both sides of one decision drops
+the claim (`arm` is deleted on merge) rather than printing `yes` on a line that runs either way.
 
 **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
@@ -673,7 +715,12 @@ that makes the links, so the two readings can never hold different steps (`WireS
 `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. `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.
+the longest run of them from the anchor. A step reached twice (`session.add` before and after a check, a logout helper
+the code comes back to) makes the graph **cyclic**, and relaxing over a cycle never settles — it adds a row on every
+pass until the pass bound. So the lines that close a cycle are dropped before the rows are settled (`withoutBackEdges`,
+one walk from the anchor: a line back to something still open on the way here cannot be what decides its row); the
+cycle is still DRAWN, it just does not stretch the picture. Without this a real screen put sixteen boxes on sixty rows
+— a 9,400px ribbon of empty space that `fitView` opened on a gap, so the canvas came up blank.
 
 **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

+ 50 - 1
src/ui-server/api/steps.ts

@@ -82,6 +82,28 @@ export interface WireStepSite {
   trigger?: WireStepTrigger;
   /** For a response site: the status code it sends, when literal (`res.status(404)`, `throw new NotFoundException`). */
   status?: number;
+  /**
+   * The decision the site's INNERMOST condition belongs to, when one was
+   * read. Two sites that agree on `branch` and disagree on `arm` are the two
+   * ways of one fork — which a joined condition string can never say, however
+   * exactly one reads as the other's negation. It is what lets a picture draw
+   * `resolvePostLoginRoute → /home` and `→ /welcome` as one choice with two
+   * answers instead of two lines that each carry the whole predicate.
+   */
+  decision?: WireStepDecision;
+}
+
+/** One arm of one decision, as the site that runs under it records it. */
+export interface WireStepDecision {
+  /** Where the branching construct starts (`line:column`) — the fork's identity. */
+  branch: string;
+  /** The decision as a reader says it, always positive: `await hasSeenWelcome(…)`. */
+  on: string;
+  /** THIS arm's own condition — an `if` and its `else` differ here and nowhere else. */
+  arm: string;
+  form: 'if' | 'switch' | 'ternary' | 'try';
+  /** The arm taken when the condition does NOT hold — the `else` side. */
+  not?: true;
 }
 
 /** What fires a step or a link: the event it is written under, and the function that writes it there. */
@@ -572,6 +594,27 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     return null;
   };
 
+  /**
+   * The decision a site's innermost condition belongs to. The innermost guard
+   * is the one decided AT the call — the ternary whose two arms return two
+   * different routes — while the ones outside it are the context both arms
+   * share, so it is the innermost that says which way this site went.
+   */
+  const decisionOf = (guards: readonly BranchGuard[]): WireStepDecision | null => {
+    const g = guards[guards.length - 1];
+    if (!g || !g.branch) return null;
+    // A one-sided guard (`if (!product) throw`) is a guard clause, not a
+    // choice with two drawn ways; it keeps its condition on the line.
+    if (g.form === 'guard') return null;
+    return {
+      branch: g.branch,
+      on: guardLabel([{ ...g, negated: false }]),
+      arm: guardLabel([g]),
+      form: g.form === 'case' ? 'switch' : g.form === 'ternary' ? 'ternary' : g.form === 'catch' ? 'try' : 'if',
+      ...(g.negated ? { not: true as const } : {}),
+    };
+  };
+
   const steps = new Map<string, StepRecord>();
   const links = new Map<string, WireStepLink>();
   /**
@@ -818,6 +861,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
     if (args !== null) wireSite.args = args;
     if (status !== null) wireSite.status = status;
+    const effectDecision = decisionOf(guards);
+    if (effectDecision) wireSite.decision = effectDecision;
     // Where the call is written HERE — in this function, at this line. The
     // rail places the step by it; the tree's row order uses the hop out of the
     // step's root, which is the same position when nothing was folded.
@@ -1278,6 +1323,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           const hop = fold.first ?? local;
           if (!to.first) to.first = hop;
           if (!to.region) to.region = regionOf(from, fold.chain);
+          const decision = decisionOf(guards);
+          if (decision) site = { ...site, decision };
           const id = link(from, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
           record(fold.node, local, guards, { step: to.id, link: id }, a.trigger, await loopsAt(fold.node, at));
           if (to.root !== null && !explored.has(to.id)) {
@@ -1317,7 +1364,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
               const guards = await guardsAt(fold.node, at);
               const local = await hopAt(fold.node, at, target.name);
               const hop = fold.first ?? local;
-              const id = link(firedBy ?? step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
+              const knownDecision = decisionOf(guards);
+              const knownSite = await withArgs(a.site, fold.node, at);
+              const id = link(firedBy ?? step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], knownDecision ? { ...knownSite, decision: knownDecision } : knownSite, e, a.trigger, hop.within);
               record(fold.node, local, guards, { step: known.id, link: id }, a.trigger, await loopsAt(fold.node, at));
             }
             continue;

+ 47 - 0
ui/src/components/steps/DecisionCaption.svelte

@@ -0,0 +1,47 @@
+<script lang="ts">
+  /**
+   * A decision made INSIDE a box, said under it — the tree reading's answer to
+   * a fork.
+   *
+   * In the code's order a fork sits BETWEEN steps and draws as a point of its
+   * own ({@link ForkPoint}). In the tree the decision is written inside a box
+   * and its arms leave that box, so the box is the decision: the condition
+   * goes here, once, under it, and each line out says only which way it is.
+   * Text only, taking no pointer, so hovering a line through it still works.
+   */
+  import type { NodeProps } from '@xyflow/svelte';
+  import { joinTokens, whenTokens } from '../../lib/conditions';
+
+  let { data }: NodeProps = $props();
+  const caption = $derived(data as unknown as { label: string; width: number; dimmed: boolean });
+  // The label arrives already worded and asking; the tokens are re-read here
+  // only so the joins we add (NOT, AND, OR) set a little bolder, as they do
+  // everywhere else conditions are shown.
+  const tokens = $derived(whenTokens(caption.label.replace(/\?$/, '')));
+  const plain = $derived(tokens.length === 0 || joinTokens(tokens) !== caption.label.replace(/\?$/, ''));
+</script>
+
+<div class="dcap" class:dimmed={caption.dimmed} style={`width:${caption.width}px`} title={caption.label}>
+  {#if plain}{caption.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b
+      >{:else}{t.text}{/if}{/each}?{/if}
+</div>
+
+<style>
+  .dcap {
+    box-sizing: border-box;
+    font: 400 10.5px/14px var(--mono);
+    color: var(--ink-2);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    text-align: center;
+    pointer-events: none;
+    user-select: none;
+  }
+  .dcap.dimmed {
+    color: var(--ink-4);
+  }
+  .kw {
+    font-weight: 600;
+  }
+</style>

+ 90 - 0
ui/src/components/steps/ForkPoint.svelte

@@ -0,0 +1,90 @@
+<script lang="ts">
+  /**
+   * A decision on the order reading's canvas — the point where a fork's arms
+   * diverge. The condition is said ONCE, here, and each line out answers it
+   * (`yes`, `no`, a case's value): two lines that each carried the whole
+   * predicate, one of them negated, never said they were the same choice.
+   * It is not a step — it takes no click and the panel has nothing to list —
+   * so it draws quieter than a box: one centred line, asking.
+   */
+  import { Handle, Position, type NodeProps } from '@xyflow/svelte';
+  import type { MapNodeLayout } from '../../lib/map-model';
+  import type { StepForkInfo } from '../../lib/steps-model';
+  import { joinTokens, whenTokens } from '../../lib/conditions';
+  import { EDGE_LABEL_MAX } from '../../lib/screens-model';
+
+  let { data }: NodeProps = $props();
+  const node = $derived(data as unknown as { layout: MapNodeLayout; fork: StepForkInfo; dimmed: boolean });
+  const layout = $derived(node.layout);
+  const tokens = $derived(whenTokens(node.fork.on));
+  // A condition past the box's cap is drawn as the capped plain label the box
+  // was sized for, or the ellipsis eats the question mark.
+  const plain = $derived(tokens.length === 0 || joinTokens(tokens).length > EDGE_LABEL_MAX);
+
+  function portStyle(index: number, total: number): string {
+    return `left:${((index + 1) / (total + 1)) * 100}%`;
+  }
+</script>
+
+{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
+  <Handle
+    type={port.type}
+    id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
+    position={Position.Top}
+    style={portStyle(i, layout.ports.top.length)}
+    isConnectable={false}
+  />
+{/each}
+
+<div
+  class="fpoint"
+  class:dimmed={node.dimmed}
+  style={`width:${layout.width}px;height:${layout.height}px`}
+  title={node.fork.on
+    ? `${node.fork.on} — the code forks here; each line out is one arm.`
+    : 'The code forks here; each line out is one arm.'}
+>
+  <span class="q"
+    >{#if plain}{node.fork.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw"
+            >{t.text}</b
+          >{:else}{t.text}{/if}{/each}?{/if}</span
+  >
+</div>
+
+{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
+  <Handle
+    type={port.type}
+    id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
+    position={Position.Bottom}
+    style={portStyle(i, layout.ports.bottom.length)}
+    isConnectable={false}
+  />
+{/each}
+
+<style>
+  .fpoint {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-sizing: border-box;
+    padding: 0 9px;
+    border: 1px solid var(--ink-2);
+    background: var(--paper);
+    color: var(--ink);
+    user-select: none;
+  }
+  .fpoint.dimmed {
+    border-color: var(--ink-4);
+    color: var(--ink-4);
+  }
+  .q {
+    font: 400 12px var(--mono);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 100%;
+  }
+  .kw {
+    font-weight: 600;
+  }
+</style>

+ 17 - 1
ui/src/components/steps/StepsKey.svelte

@@ -115,9 +115,13 @@
           <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-box k-fork mono">x?</span>
+          <span>A decision both of whose ways are drawn: the box asks the condition once and each line out answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case. An arm that answers or leaves ends there</span>
+        </div>
         <div class="lrow">
           <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>
+          <span>A lone guard — an early exit, an <span class="mono">if</span> with one drawn side: 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>
@@ -144,6 +148,14 @@
           <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
           <span>Goes back up the picture — leaves the top of its box, arrives at the bottom of the other</span>
         </div>
+        <div class="lrow">
+          <span class="k-label mono">x? · yes</span>
+          <span
+            >A decision made inside a box, both of whose ways are drawn: the condition is said once under the box and
+            each line out of it answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case. These are
+            the only lines labelled before you select anything</span
+          >
+        </div>
         <div class="lrow">
           <span class="k-label mono">→ …x</span>
           <span>The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always</span>
@@ -237,6 +249,10 @@
   border-style: dashed;
   border-color: var(--ink-3);
 }
+/* The decision's point draws quieter than a step, on the canvas and here. */
+.k-box.k-fork {
+  border-color: var(--ink-2);
+}
 .k-anchor .mark {
   font-size: 8px;
   margin-right: 3px;

+ 164 - 17
ui/src/lib/program-model.ts

@@ -31,10 +31,10 @@
  * same pills, hover and panel. Only the graph changes.
  */
 
-import { conditionTokens, joinTokens, type WordToken } from './conditions';
+import { conditionTokens, joinTokens, whenWords, 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 { armWords, stepLabel, stepSub, type StepEdgeInfo, type StepForkInfo, type StepNodeInfo, type StepsModel } from './steps-model';
 import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire';
 
 /* ----------------------------------------------------------------- words -- */
@@ -76,6 +76,8 @@ export interface OrderEdge {
   when: string;
   /** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */
   runs: string[];
+  /** A line out of a decision's point: which arm this is — `yes`, `no`, a case's value. */
+  arm?: string;
 }
 
 /** Where a next step would follow from, and under what. */
@@ -83,12 +85,26 @@ interface Tail {
   id: string;
   when: string[];
   runs: string[];
+  arm?: string;
+}
+
+/**
+ * A decision drawn as a point of its own: a fork two or more of whose arms
+ * lead somewhere. Its condition is said once, on the point; each line out
+ * says only which arm it is.
+ */
+export interface OrderFork {
+  id: string;
+  on: string;
+  form: ForkForm;
 }
 
 export interface OrderGraph {
   edges: OrderEdge[];
   /** How many things happen before each step: its row. */
   depth: Map<string, number>;
+  /** The decisions drawn as points, in the order the reading met them. */
+  forks: OrderFork[];
 }
 
 /**
@@ -99,6 +115,7 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
   const edges: OrderEdge[] = [];
   const seen = new Set<string>([anchor]);
   const at = new Map<string, OrderEdge>();
+  const forks: OrderFork[] = [];
 
   const join = (from: string, to: string, tail: Tail): void => {
     if (from === to) return;
@@ -110,9 +127,12 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
       // 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);
+      // Reached as two different arms of one decision: it happens either way,
+      // and the line stops claiming a side.
+      if (found.arm !== tail.arm) delete found.arm;
       return;
     }
-    const edge: OrderEdge = { from, to, when, runs: [...tail.runs] };
+    const edge: OrderEdge = { from, to, when, runs: [...tail.runs], ...(tail.arm !== undefined ? { arm: tail.arm } : {}) };
     at.set(key, edge);
     edges.push(edge);
   };
@@ -129,11 +149,28 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
         if (item.body && item.body.length > 0) inner = flow(item.body, inner, []);
         tails = inner;
       } else if (item.kind === 'fork') {
+        // A decision two or more of whose arms lead somewhere diverges from a
+        // POINT: the condition said once, each line out an arm — instead of
+        // two lines that each carry the whole predicate, one of them negated,
+        // with nothing saying they are the same choice. A fork with one drawn
+        // arm keeps the plain line: an early exit reads as a guard clause.
+        const drawn = item.arms.filter((a) => hasStep(a.body)).length;
+        const fork: OrderFork | null =
+          drawn >= 2 && tails.length > 0 ? { id: `fork:${forks.length}`, on: item.on, form: item.form } : null;
+        if (fork !== null) {
+          forks.push(fork);
+          for (const t of tails) join(t.id, fork.id, { ...t, runs: [...t.runs, ...runs] });
+        }
         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] }));
+          const entry: Tail[] =
+            fork !== null && hasStep(arm.body)
+              ? // From the point: the line says the arm; the arm's own
+                // condition rides along for the hover.
+                [{ id: fork.id, when: [arm.when], runs: [...runs], arm: armWord(item, arm) }]
+              : 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, []);
@@ -164,22 +201,50 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
     if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] });
   }
 
-  return { edges, depth: rows(anchor, seen, edges) };
+  const ids = new Set(seen);
+  for (const f of forks) ids.add(f.id);
+  return { edges, depth: rows(anchor, ids, edges), forks };
+}
+
+/** Whether anything in this block draws a box — a fork with one drawn arm is a guard clause, not a point. */
+function hasStep(block: WireBlock): boolean {
+  return block.some((item) =>
+    item.kind === 'step'
+      ? true
+      : item.kind === 'fork'
+        ? item.arms.some((a) => hasStep(a.body))
+        : item.kind === 'block'
+          ? hasStep(item.body)
+          : false
+  );
+}
+
+/** The word on a line out of a decision's point — the same one the tree's arms say. */
+function armWord(fork: Extract<WireItem, { kind: 'fork' }>, arm: WireArm): string {
+  return armWords({ on: fork.on, arm: arm.when, form: fork.form, not: arm.not });
 }
 
 /**
  * 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.
+ * it, so a step never draws above something that has to happen first.
+ *
+ * A step reached twice (`session.add` before and after a check, a logout
+ * helper the code comes back to) makes the graph cyclic, and relaxing over a
+ * cycle never settles — it adds a row on every pass until the pass bound, so
+ * sixteen boxes spread over sixty rows and the picture is a mostly-empty
+ * ribbon no fit can open on. So the lines that close a cycle are dropped
+ * first: a line back to something already on the way here cannot be what
+ * decides its row. The longest path over what remains settles by relaxation,
+ * and no picture is ever taller than it has boxes.
  */
 function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): Map<string, number> {
+  const forward = withoutBackEdges(anchor, nodes, edges);
   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) {
+    for (const e of forward) {
       const next = (depth.get(e.from) ?? 0) + 1;
       if (next > (depth.get(e.to) ?? 0)) {
         depth.set(e.to, next);
@@ -191,6 +256,46 @@ function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderE
   return depth;
 }
 
+/**
+ * The edges minus the ones that close a cycle — those whose end is still open
+ * on the way in, found by one walk from the anchor (then from anything it
+ * does not reach), so the reading's own order decides which way round a cycle
+ * is the forward one.
+ */
+function withoutBackEdges(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): OrderEdge[] {
+  const out = new Map<string, OrderEdge[]>();
+  for (const e of edges) {
+    const list = out.get(e.from);
+    if (list) list.push(e);
+    else out.set(e.from, [e]);
+  }
+  /** 1 = open on the way in, 2 = done with. */
+  const state = new Map<string, 1 | 2>();
+  const back = new Set<OrderEdge>();
+  for (const root of [anchor, ...nodes]) {
+    if (state.has(root)) continue;
+    state.set(root, 1);
+    const stack: Array<{ id: string; next: number }> = [{ id: root, next: 0 }];
+    while (stack.length > 0) {
+      const top = stack[stack.length - 1]!;
+      const list = out.get(top.id) ?? [];
+      if (top.next >= list.length) {
+        state.set(top.id, 2);
+        stack.pop();
+        continue;
+      }
+      const edge = list[top.next++]!;
+      const seen = state.get(edge.to);
+      if (seen === 1) back.add(edge);
+      else if (seen === undefined) {
+        state.set(edge.to, 1);
+        stack.push({ id: edge.to, next: 0 });
+      }
+    }
+  }
+  return edges.filter((e) => !back.has(e));
+}
+
 /* ----------------------------------------------------------------- build -- */
 
 /** Points a curve is sampled at for hit-testing (as the other reading's). */
@@ -207,6 +312,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
   const graph = orderGraph(payload.program, anchorStep.id);
 
   const nodes = new Map<string, StepNodeInfo>();
+  const forks = new Map<string, StepForkInfo>();
   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>();
@@ -235,11 +341,30 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
       fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
     });
   }
+  // Each decision is a point of its own on the canvas: a small box asking the
+  // condition once, where the arms diverge.
+  for (const f of graph.forks) {
+    const label = forkLabel(f.on);
+    forks.set(f.id, { id: f.id, on: f.on, form: f.form, label });
+    modules.push({
+      id: f.id,
+      label,
+      files: 0,
+      symbols: degree.get(f.id) ?? 0,
+      languages: [],
+      test: false,
+      generated: 0,
+      generatedFiles: [],
+      facade: false,
+      fileList: { total: 0, shown: 0, truncated: false, items: [] },
+    });
+  }
+  const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
 
   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;
+    if (!drawn(e.from) || !drawn(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: [] });
@@ -257,6 +382,15 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
     });
   }
 
+  // A point sits where its arms are: the leftmost of them, in the code's order.
+  const forkOrder = new Map<string, number>();
+  for (const e of graph.edges) {
+    if (!forks.has(e.from)) continue;
+    const order = byId.get(e.to)?.order;
+    if (order === undefined) continue;
+    forkOrder.set(e.from, Math.min(forkOrder.get(e.from) ?? Number.MAX_SAFE_INTEGER, order));
+  }
+
   // 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> =>
@@ -268,6 +402,8 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
       includeTests: true,
       minWeight: 0,
       sizing: (m) => {
+        const fork = forks.get(m.id);
+        if (fork) return { label: fork.label, meta: '' };
         const info = nodes.get(m.id);
         // Size for the ` …` a cut step wears and the anchor's ● mark, or the
         // CSS ellipsis eats the name's tail.
@@ -276,7 +412,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
         return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
       },
       layering,
-      order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
+      order: (id) => nodes.get(id)?.step.order ?? forkOrder.get(id) ?? Number.MAX_SAFE_INTEGER,
       layerGap: SCREEN_LAYER_GAP,
       portPitch: PORT_PITCH,
       ports: 'directional',
@@ -285,18 +421,29 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
   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));
-  // The order reading needs no regions: its rows already say when.
-  return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts, regions: null, regionEntries: null };
+  // The order reading needs no regions: its rows already say when. Its
+  // decisions are points between steps, not captions under a box.
+  return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts, regions: null, regionEntries: null, forks, decisions: [] };
 }
 
 /**
- * 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.
+ * What a line says: the arm it is, when it leaves a decision's point — the
+ * point asks, the line answers — else 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.arm) return e.arm;
   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;
 }
+
+/** The point's words: the condition once, as the view says conditions, asking. */
+function forkLabel(on: string): string {
+  const text = whenWords(on);
+  if (!text) return '?';
+  return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…?` : `${text}?`;
+}

+ 12 - 3
ui/src/lib/screens-model.ts

@@ -757,9 +757,18 @@ 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, atRest = false): PillLayout {
+export function placeLabels(
+  model: Picture,
+  selected: string | null,
+  atRest: boolean | ReadonlySet<string> = false
+): PillLayout {
   const pills = new Map<string, PillPlacement>();
-  if (selected === null && !atRest) return { pills, hidden: 0 };
+  // `true` labels every line (the code's order, where the conditions ARE the
+  // picture); a SET labels only those lines (the tree, where the arms of a
+  // decision are the one thing worth saying before anything is selected).
+  const restLabels = (id: string): boolean => (atRest === true ? true : atRest !== false && atRest.has(id));
+  const anyAtRest = atRest === true || (atRest !== false && atRest.size > 0);
+  if (selected === null && !anyAtRest) 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 };
@@ -770,7 +779,7 @@ export function placeLabels(model: Picture, selected: string | null, atRest = fa
   // 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) => atRest || e.source === selected || e.target === selected)
+    .filter((e) => restLabels(e.id) || e.source === selected || e.target === selected)
     .map((edge) => {
       const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target';
       const far = nodes.get(end === 'source' ? edge.source : edge.target);

+ 199 - 13
ui/src/lib/steps-model.ts

@@ -12,7 +12,8 @@
  * are the links into and out of the selected step.
  */
 
-import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
+import { whenWords } from './conditions';
+import type { WireMapLink, WireMapModule, WireStep, WireStepDecision, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
 import {
   buildMapLayout,
   linkId,
@@ -59,6 +60,25 @@ export interface StepEdgeInfo {
   synthesized: boolean;
   /** The kind the links agree on, or `calls` when they differ. */
   kind: WireStepLink['kind'];
+  /**
+   * The one way of a decision this connector is — `yes`, `no`, a case's
+   * value — when it and a sibling out of the same box are arms of one fork.
+   * The condition itself is said once, under the box ({@link StepDecision}).
+   */
+  arm?: string;
+}
+
+/**
+ * A decision drawn where it is made: the condition said ONCE, under the box
+ * that decides it, while each line out of that box says only which way it is.
+ */
+export interface StepDecision {
+  id: string;
+  /** The condition as a reader says it, asking: `await hasSeenWelcome(…)?`. */
+  label: string;
+  x: number;
+  y: number;
+  width: number;
 }
 
 export interface StepsModel extends Picture {
@@ -78,6 +98,34 @@ export interface StepsModel extends Picture {
   regions: StepRegionZone[] | null;
   /** The first box of each region — where the anchor's at-rest line arrives. */
   regionEntries: ReadonlySet<string> | null;
+  /**
+   * The order reading only: its decisions, each drawn as a point of its own
+   * where the arms diverge (`fork:N` in the layout). Null on the tree, whose
+   * decisions are made INSIDE a box and drawn under it ({@link decisions}).
+   */
+  forks: Map<string, StepForkInfo> | null;
+  /**
+   * The tree reading's decisions: a condition said once under the box that
+   * decides it, its arms labelled on the lines out. Empty when the picture
+   * holds none.
+   */
+  decisions: StepDecision[];
+}
+
+/**
+ * A decision on the order reading's canvas — a fork of the code with two or
+ * more arms that lead somewhere. The condition is said ONCE, on the point,
+ * and each line out answers it (`yes`, `no`, a case's value): two lines that
+ * each carried the whole predicate, one of them negated, never said they were
+ * the same choice.
+ */
+export interface StepForkInfo {
+  id: string;
+  /** The condition in positive words — a switch's subject; '' when the arms share none. */
+  on: string;
+  form: 'if' | 'switch' | 'ternary' | 'try';
+  /** The point's words: the condition, asked — `user AND (await …)?`. */
+  label: string;
 }
 
 /** One region of a screen's picture: its caption, and the space its boxes hold. */
@@ -204,6 +252,105 @@ export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
   }
 }
 
+/* ------------------------------------------------------------- decisions -- */
+
+/** A case value longer than this is cut on the line; the whole condition is a hover away. */
+const ARM_WORD_MAX = 24;
+/** Room for one line of a decision's caption under its box. */
+const DECISION_LINE = 15;
+/** Advance of the caption's 10.5px mono, and the room it may take past its box. */
+const DECISION_CHAR = 6.3;
+const DECISION_MAX_WIDTH = 320;
+
+/**
+ * The word a line out of a decision says — the ONE place that decides it, so
+ * the two readings can never word an arm differently. `yes` / `no` for an
+ * `if` or a ternary; a case's own value for a switch, with the subject the
+ * decision already asks stripped off (`status === 'expired'` → `'expired'`),
+ * and `else` for its default; a `try`'s arms keep their own words.
+ */
+export function armWords(d: { on: string; arm: string; form: 'if' | 'switch' | 'ternary' | 'try'; not?: true }): string {
+  if (d.form === 'if' || d.form === 'ternary') return d.not ? 'no' : 'yes';
+  if (d.not) return 'else';
+  let text = d.arm;
+  if (d.on && text.startsWith(d.on)) text = text.slice(d.on.length).trim().replace(/^===?\s*/, '');
+  if (!text) return 'yes';
+  return text.length > ARM_WORD_MAX ? `${text.slice(0, ARM_WORD_MAX - 1)}…` : text;
+}
+
+/**
+ * The one arm of one decision a connector is, when EVERY site behind it
+ * agrees. A connector with a site that runs under no condition is not
+ * exclusively an arm — the step happens either way — and one whose sites
+ * disagree is several stories; both stay plain lines rather than claim a side.
+ */
+function edgeArm(info: StepEdgeInfo): WireStepDecision | null {
+  let found: WireStepDecision | null = null;
+  for (const link of info.links) {
+    for (const site of link.sites) {
+      if (!site.decision) return null;
+      if (found === null) found = site.decision;
+      else if (found.branch !== site.decision.branch || found.arm !== site.decision.arm) return null;
+    }
+  }
+  return found;
+}
+
+/**
+ * Sibling connectors out of one box that are arms of ONE fork, marked as the
+ * choice they are: each line says only which way it is, and the condition is
+ * said once under the box that decides it. Two lines that each carried the
+ * whole predicate — one of them the other's negation, both truncated to the
+ * same forty characters — never said they were the same choice, and at rest
+ * the tree drew them with no label at all.
+ *
+ * A fork with ONE drawn arm is a guard clause, not a choice, and keeps its
+ * condition on the line: the decision has to have at least two ways drawn
+ * before it is worth a caption.
+ */
+function markDecisions(edges: Map<string, StepEdgeInfo>, layout: MapLayout): StepDecision[] {
+  const groups = new Map<string, Array<{ info: StepEdgeInfo; decision: WireStepDecision }>>();
+  for (const info of edges.values()) {
+    const decision = edgeArm(info);
+    if (decision === null) continue;
+    const key = `${info.from}${decision.branch}`;
+    const list = groups.get(key) ?? [];
+    list.push({ info, decision });
+    groups.set(key, list);
+  }
+
+  const boxes = new Map(layout.nodes.map((n) => [n.id, n]));
+  const out: StepDecision[] = [];
+  /** Two decisions made in one box stack under it rather than sitting on each other. */
+  const perBox = new Map<string, number>();
+  for (const [key, group] of groups) {
+    if (new Set(group.map((g) => g.decision.arm)).size < 2) continue;
+    const box = boxes.get(group[0]!.info.from);
+    if (!box) continue;
+    for (const { info, decision } of group) {
+      info.arm = armWords(decision);
+      // The connector's label IS the arm now: the decision says the rest.
+      info.label = info.arm;
+    }
+    const nth = perBox.get(box.id) ?? 0;
+    perBox.set(box.id, nth + 1);
+    const on = group[0]!.decision.on;
+    const label = `${whenWords(on) || on}?`;
+    // The condition is the whole point of the caption, so it may take a
+    // little more room than the box it sits under — centred on it, and capped
+    // so a long predicate cannot reach across its neighbours.
+    const width = Math.max(box.width, Math.min(label.length * DECISION_CHAR + 8, DECISION_MAX_WIDTH));
+    out.push({
+      id: key,
+      label,
+      x: box.x + (box.width - width) / 2,
+      y: box.y + box.height + 4 + nth * DECISION_LINE,
+      width,
+    });
+  }
+  return out;
+}
+
 /* ---------------------------------------------------------------- build -- */
 
 export function buildStepsModel(payload: WireStepsPayload): StepsModel {
@@ -332,6 +479,10 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
     counts,
     regions: zones,
     regionEntries: zones === null ? null : new Set(zones.map((z) => z.entry)),
+    forks: null,
+    // Placed against the finished layout: a decision is drawn under the box
+    // that makes it, so it needs to know where that box ended up.
+    decisions: markDecisions(edges, layout),
   };
 }
 
@@ -651,21 +802,56 @@ function packRegions(
   return { layout, zones };
 }
 
+/**
+ * The selection, extended through decisions: a fork's point is not a step —
+ * it belongs to the steps around it — so selecting the step before a fork, or
+ * one of its arms, reaches the point and, through it, the fork's other lines.
+ * The set holds the selected id and every point connected to it through
+ * points alone; a picture without forks is just the selection.
+ */
+export function selectionReach(model: StepsModel, selected: string): ReadonlySet<string> {
+  const reach = new Set([selected]);
+  if (model.forks === null || model.forks.size === 0) return reach;
+  for (let grew = true; grew; ) {
+    grew = false;
+    for (const e of model.layout.edges) {
+      const from = reach.has(e.source);
+      const to = reach.has(e.target);
+      if (from === to) continue;
+      const other = from ? e.target : e.source;
+      if (model.forks.has(other) && !reach.has(other)) {
+        reach.add(other);
+        grew = true;
+      }
+    }
+  }
+  return reach;
+}
+
 /**
  * Which edges draw, given the selection. Selecting a step says "show me
- * everything about this one" — every line touching it comes out. At rest a
- * regioned picture hides exactly two things: the anchor's own fan — the
- * anchor leads to everything by definition, and a hundred and four ways of
- * saying so were the whole canvas, so one line into each region stands in for
- * it — and, as everywhere, what points back up the layering. Every other
- * lead-to draws, a line between two regions included: the empty state's
- * prompt firing the same handler as the header's is the picture, and hiding
- * it made a box that leads three places read as wired to nothing. A shared
- * step fed from below (the toast every handler calls) stays quiet through the
- * back rule alone. An unregioned picture keeps the Map's rule.
+ * everything about this one" — every line touching it comes out, a decision's
+ * lines through its point ({@link selectionReach}). At rest a regioned
+ * picture hides exactly two things: the anchor's own fan — the anchor leads
+ * to everything by definition, and a hundred and four ways of saying so were
+ * the whole canvas, so one line into each region stands in for it — and, as
+ * everywhere, what points back up the layering. Every other lead-to draws, a
+ * line between two regions included: the empty state's prompt firing the same
+ * handler as the header's is the picture, and hiding it made a box that leads
+ * three places read as wired to nothing. A shared step fed from below (the
+ * toast every handler calls) stays quiet through the back rule alone. An
+ * unregioned picture keeps the Map's rule.
  */
-export function stepEdgeVisible(model: StepsModel, edge: MapEdgeLayout, selected: string | null): boolean {
-  if (selected !== null) return edge.source === selected || edge.target === selected;
+export function stepEdgeVisible(
+  model: StepsModel,
+  edge: MapEdgeLayout,
+  selected: string | null,
+  reach?: ReadonlySet<string>
+): boolean {
+  if (selected !== null) {
+    const r = reach ?? selectionReach(model, selected);
+    return r.has(edge.source) || r.has(edge.target);
+  }
   if (edge.thin || edge.back) return false;
   if (model.regions === null) return true;
   const from = model.nodes.get(edge.source)?.step;

+ 20 - 0
ui/src/lib/wire.ts

@@ -714,6 +714,26 @@ export interface WireStepSite {
   trigger?: WireStepTrigger;
   /** For a response site: the status code it sends, when literal. */
   status?: number;
+  /**
+   * The decision the site's INNERMOST condition belongs to, when one was
+   * read. Two sites that agree on `branch` and disagree on `arm` are the two
+   * ways of ONE fork — which a joined condition string can never say, however
+   * exactly one reads as the other's negation.
+   */
+  decision?: WireStepDecision;
+}
+
+/** One arm of one decision, as the site that runs under it records it. */
+export interface WireStepDecision {
+  /** Where the branching construct starts (`line:column`) — the fork's identity. */
+  branch: string;
+  /** The decision as a reader says it, always positive: `await hasSeenWelcome(…)`. */
+  on: string;
+  /** THIS arm's own condition — an `if` and its `else` differ here and nowhere else. */
+  arm: string;
+  form: 'if' | 'switch' | 'ternary' | 'try';
+  /** The arm taken when the condition does NOT hold — the `else` side. */
+  not?: true;
 }
 
 /** What fires a step or a link: the event it is written under, and the function that writes it there. */

+ 95 - 47
ui/src/views/StepsView.svelte

@@ -17,6 +17,8 @@
   import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
   import '@xyflow/svelte/dist/style.css';
   import StepNode from '../components/steps/StepNode.svelte';
+  import ForkPoint from '../components/steps/ForkPoint.svelte';
+  import DecisionCaption from '../components/steps/DecisionCaption.svelte';
   import RegionCaption from '../components/steps/RegionCaption.svelte';
   import StepsKey from '../components/steps/StepsKey.svelte';
   import ScreenEdge from '../components/screens/ScreenEdge.svelte';
@@ -40,6 +42,7 @@
     buildStepsModel,
     kindWord,
     kindWords,
+    selectionReach,
     stepEdgeVisible,
     stepNeighbourhood,
     stepPairId,
@@ -130,7 +133,7 @@
       ? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
       : { padding: 0.1, maxZoom: 1, minZoom: model !== null && model.regions !== null ? 0.2 : 0.4 }
   );
-  const nodeTypes = { step: StepNode, region: RegionCaption };
+  const nodeTypes = { step: StepNode, region: RegionCaption, fork: ForkPoint, decision: DecisionCaption };
 
   /** Two clicks on one box closer than this are a double-click. */
   const DOUBLE_CLICK_MS = 400;
@@ -215,19 +218,31 @@
   /** The order can be asked for and have nothing to read: the view then says so. */
   const orderReadable = $derived(payload?.program != null);
 
+  /** The selection with the decision points it touches — what the edge filter and the dimming reason over. */
+  const reach = $derived(model === null || selected === null ? null : selectionReach(model, selected));
   const neighbours = $derived.by(() => {
-    if (model === null || selected === null) return null;
-    const set = new Set<string>([selected]);
+    if (model === null || reach === null) return null;
+    const set = new Set<string>(reach);
     for (const edge of model.layout.edges) {
-      if (edge.source === selected) set.add(edge.target);
-      if (edge.target === selected) set.add(edge.source);
+      if (reach.has(edge.source)) set.add(edge.target);
+      if (reach.has(edge.target)) set.add(edge.source);
     }
     return set;
   });
 
-  // 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'));
+  /**
+   * Which lines are labelled before anything is selected. In the code's order
+   * that is all of them — there the conditions ARE the picture. In the tree it
+   * is the arms of a decision and nothing else: a `yes` and a `no` leaving one
+   * box are the one thing a reader cannot work out from the shape, and drawing
+   * every condition at rest is the unreadable picture the tree exists to avoid.
+   */
+  const atRestLabels = $derived.by<boolean | ReadonlySet<string>>(() => {
+    if (readAs === 'order') return true;
+    if (model === null) return false;
+    return new Set([...model.edges.values()].filter((e) => e.arm !== undefined).map((e) => e.id));
+  });
+  const pills = $derived(model === null ? null : placeLabels(model, selected, atRestLabels));
   const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
   const focusPill = $derived.by(() => {
     if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
@@ -248,48 +263,79 @@
       connectable: false,
       data: { label: zone.label, width: zone.width },
     }));
-    return captions.concat(model.layout.nodes.map((node) => ({
-      id: node.id,
-      type: 'step',
-      position: { x: node.x, y: node.y },
-      draggable: false,
-      selectable: false,
-      connectable: false,
-      data: {
-        layout: node,
-        info: model.nodes.get(node.id)!,
-        project: payload?.project ?? 'app',
-        selected: selected === node.id,
-        dimmed: neighbours !== null && !neighbours.has(node.id),
-        onSelect: (id: string) => {
-          // Two clicks on the same box within a beat are a double-click:
-          // the picture starts there. Read here rather than off the DOM's
-          // `dblclick`, which the flow canvas does not always pass on.
-          const now = performance.now();
-          if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
-            lastClick = null;
-            if (startHere(id)) return;
-          }
-          lastClick = { id, at: now };
-          selected = selected === id ? null : id;
-          hovered = null;
-          panelHot = null;
+    // A decision made inside a box, said once under it; each line out of that
+    // box says only which way it is.
+    for (const d of model.decisions) {
+      const owner = d.id.slice(0, d.id.indexOf(' '));
+      captions.push({
+        id: `decision:${d.id}`,
+        type: 'decision',
+        position: { x: d.x, y: d.y },
+        draggable: false,
+        selectable: false,
+        connectable: false,
+        data: { label: d.label, width: d.width, dimmed: neighbours !== null && !neighbours.has(owner) },
+      });
+    }
+    return captions.concat(model.layout.nodes.map((node) => {
+      // A decision's point: not a step — no selection, no panel; the box asks
+      // and the lines out answer.
+      const fork = model.forks?.get(node.id);
+      if (fork) {
+        return {
+          id: node.id,
+          type: 'fork',
+          position: { x: node.x, y: node.y },
+          draggable: false,
+          selectable: false,
+          connectable: false,
+          data: { layout: node, fork, dimmed: neighbours !== null && !neighbours.has(node.id) },
+        };
+      }
+      return {
+        id: node.id,
+        type: 'step',
+        position: { x: node.x, y: node.y },
+        draggable: false,
+        selectable: false,
+        connectable: false,
+        data: {
+          layout: node,
+          info: model.nodes.get(node.id)!,
+          project: payload?.project ?? 'app',
+          selected: selected === node.id,
+          dimmed: neighbours !== null && !neighbours.has(node.id),
+          onSelect: (id: string) => {
+            // Two clicks on the same box within a beat are a double-click:
+            // the picture starts there. Read here rather than off the DOM's
+            // `dblclick`, which the flow canvas does not always pass on.
+            const now = performance.now();
+            if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
+              lastClick = null;
+              if (startHere(id)) return;
+            }
+            lastClick = { id, at: now };
+            selected = selected === id ? null : id;
+            hovered = null;
+            panelHot = null;
+          },
+          // Double-click: the picture starts here — an endpoint or another
+          // screen drawn as a boundary opens as its own chapter. An effect has
+          // no symbol to start from.
+          ...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
         },
-        // Double-click: the picture starts here — an endpoint or another
-        // screen drawn as a boundary opens as its own chapter. An effect has
-        // no symbol to start from.
-        ...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
-      },
-    })));
+      };
+    }));
   });
 
   const edges = $derived.by<Edge[]>(() => {
     if (model === null) return [];
     const focus = focusId;
     return model.layout.edges
-      .filter((edge) => stepEdgeVisible(model, edge, selected))
+      .filter((edge) => stepEdgeVisible(model, edge, selected, reach ?? undefined))
       .map((edge) => {
-        const touches = selected !== null && (edge.source === selected || edge.target === selected);
+        const touches =
+          reach !== null && (reach.has(edge.source) || reach.has(edge.target));
         const isFocus = focus === edge.id;
         const hot = isFocus || touches;
         return {
@@ -403,7 +449,7 @@
   }
 
   function nameOf(id: string): string {
-    return model?.nodes.get(id)?.label ?? id;
+    return model?.nodes.get(id)?.label ?? model?.forks?.get(id)?.label ?? id;
   }
 
   /** A Flow strip between the two symbols of a link, when both are symbols. */
@@ -771,10 +817,12 @@
         {#if readAs === 'order'}
           <p class="dim">
             <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.
+            means <b>and then</b>. Where the code forks both ways, a small box asks the condition once and each
+            line out of it answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case; a lone
+            guard rides its line as <span class="mono">WHEN</span>. 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">