Przeglądaj źródła

feat(ui): the Steps tab lays a screen out in clusters and says a far link in words; a <Card/> is the Card its file imports (#1817)

* fix(ui): a screen's steps read as clusters, and a link too far to follow is said in words

The mobile app's /capture came back as a web: 100 boxes in a 1,227x5,588
ribbon, 113 lines drawn at rest crossing each other 652 times, each one
running over about five other boxes' names. Measured, not guessed — three
separate causes, very unequal.

The region grouping had nothing to divide there (98 of 100 boxes take their
region from one memoized component), so the picture fell back to a single
719px column. But the region dimension was not the lever. The lever was that
`packRegions` packed every step of one distance onto shared rows and wrapped
those rows at a fixed 720px, so a box and the thing it fires landed seven
lines apart: 70 of the 113 lines joined boxes ONE step apart. That is what
the crossings were made of.

So a region is now packed as CLUSTERS — a step, then the steps it sets in
motion on the line under it, stepped in — while the starting points that fire
nothing still share a line, because a screen's handlers are siblings and
giving each its own line turned a flat region into a column. A region's line
width is earned rather than fixed (sqrt(total * pitch), clamped 720..2600),
so a big screen comes out about as wide as it is tall.

Clustering makes most links local but not all: a step reached from two places
is drawn under whichever reached it first, so the other way in still crosses
the picture. Those are now said in WORDS at both ends — `-> resumeInference`
under the box that leads there, `<- CaptureView` under the box it arrives at,
capped at three with `+N more` — rather than drawn. This is not a hiding: the
link is stated, which says more than a line vanishing off the edge of the
screen does, and selecting the box draws every one of its real lines exactly
as before. It is the one at-rest cut that does not produce the "box that leads
somewhere and draws nothing" every earlier cut produced.

Also fixed while here, and predicted by the earlier region work: the in-region
row relaxation had no cycle guard, so a region holding one loop pushed 65 of
its boxes to rows 294-301 while the rest sat at 0-2. `forwardLinks` sets
cycle-closing links aside first, as the order reading's `withoutBackEdges`
already did.

/capture: 1,227x5,588 -> 2,279x4,356, at-rest crossings 652 -> 1,
lines-over-boxes 553 -> 26, with half the links still drawn as real lines and
every quiet box still one the screen itself fires directly. The order reading
is untouched (it keeps every line).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): a stub names its box without the mark the box wears for its kind

`← ⇠ onCaptureProgress +2` reads as two arrows arguing: the stub already
leads with a direction, and the box's own kind mark was competing with it.
Verified in the live canvas. The box keeps its mark, where nothing competes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): the screen's line into each of its parts stops sweeping the picture

Audited all 51 screens of the mobile app on this branch. 96 lines — the
screen's own stand-in line into each region — were 17% of everything drawn
and caused 79% of every crossing left. A screen with ten regions tiles them
into bands, so the line into a region two bands down travelled the height of
the whole picture.

Two causes, both fixed. The entry the line lands on was the walk's first
member of the region; clustering moves a step that fires something BELOW the
ones that fire nothing, so that box could sit lines down inside the region
and the line had to reach past everything above it. It now lands on the box
nearest the region's top-left that the screen actually leads to. And the
stand-in line is no longer exempt from the stub rule — when the region is
still too far to follow, the link is said in words like any other. The rest
of the anchor's fan stays quiet as before: it is already stood in for.

Across the 51 screens: crossings 47 -> 10, no screen above 10 (worst was 19,
now 2); lines-over-boxes 236 -> 182; boxes with neither a line nor a word
150 -> 135.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): a screen's parts fill the canvas instead of squaring off into rows

Audited the app's 28 regioned screens: the median canvas was 55% region and
45% nothing, and /home was 44% — 4,860px tall to hold about 2,160px of
picture. The cause is that regions were tiled a row at a time with each row
as tall as its tallest member, so one short region beside a tall one left the
rest of that row blank, and a reader scrolls through the blank.

Each region now goes as high as it can and then as far left as it can, over a
skyline of what is already placed. Reading order is untouched: regions are
still walked in the screen's own source order, so an earlier one is never
pushed below a later one — a short one just tucks under another short one
rather than waiting for the tall one beside it. Layering now comes from the
finished geometry rather than a band counter, since once regions drop
independently what a reader sees as one row IS one row.

/home 4,860px -> 3,584px, aspect 0.59 -> 0.94. Tallest screen in the app
4,860 -> 4,356. Crossings 10 -> 13 across all 51 screens, still none above 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): the width a picture wraps at is tried, not estimated

A region's line width came from sqrt(total * pitch) — the width at which
total/width lines come out square. That estimate is wrong for how these
pictures are drawn: a cluster spends lines on its own structure (a hub gets a
line to itself, and what it fires starts another), so it undercounts a
region's lines badly and wrapped /capture's 98 boxes into a 4,356px column.

Laying a picture out is cheap and exact, so the widths are tried instead:
layoutAt runs the whole pack at each of eight widths and the best finished
canvas wins (~2ms for the model, all eight included). It has to be scored on
the CANVAS, not per region — squaring each region off individually leaves
fewer of them side by side, which took /home from 3,584px to 5,624px while
every region looked better on its own.

Also measured and rejected while here: dropping a region's CLUSTERS side by
side the way the regions drop onto the canvas. Total height 42,084 -> 39,756px
(-6%), but lines-over-boxes 120 -> 134 and crossings 5 -> 8, because two
clusters side by side put each one's lines through the other. Height is cheap
to scroll; a crossed line is what made this picture unreadable. The reasoning
is recorded in the code so it is not re-tried blindly. Regions differ — they
sit far enough apart that few lines run between them.

Across the app's 51 screens: tallest picture 4,356 -> 3,796px, total height
45,744 -> 42,084px, lines-over-boxes 184 -> 120, crossings 13 -> 5, and no
screen is a tall ribbon any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* stuff

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Colby Mchenry 8 godzin temu
rodzic
commit
3ed73bc127

+ 12 - 0
CHANGELOG.md

@@ -27,6 +27,16 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions.
 
+- **A big screen's picture stops wrapping into a column.** How wide a screen's lines run before they wrap was worked out with a formula, and the formula was wrong for the way these pictures are actually drawn: a part of a screen spends lines on its own structure — a step that fires things gets a line to itself, and what it fires starts another — so estimating the lines from the boxes alone badly undercounted them, and one screen's 98 boxes wrapped into a 4,356px column. Laying a picture out is cheap and exact, so the widths are now simply tried and the one that comes out closest to the shape of a window is kept. Across one app's 51 screens the tallest picture went from 4,356px to 3,796px, total height fell 8%, and — because a shorter picture is also a picture whose lines have less far to go — lines running over other boxes fell by a third and lines crossing each other went from 13 to 5.
+
+- **A screen's picture stops being mostly empty.** The parts of a screen were tiled a row at a time, each row as tall as its tallest member — so one short region beside a tall one left the whole rest of that row blank. Measured across one app: the median screen's canvas was only 55% picture and 45% nothing, and its busiest screen was 44% — 4,860px tall to hold about 2,160px of content, all of which a reader has to scroll through. Each part now goes as high as it can and then as far left as it can, so a short one tucks under another short one instead of waiting for the tall one beside it. Reading order is unchanged: parts are still placed in the screen's own source order, and an earlier one is never pushed below a later one. That screen is now 3,584px instead of 4,860px, and close to square rather than a ribbon.
+
+- **The screen's own line into each part of itself stops sweeping across the picture.** A screen's picture holds back the screen's whole fan-out and draws one line into each region instead — but on a screen with ten regions those regions tile into bands, so the line into a region two bands down had to travel the height of the whole picture to get there. Measured across one app's 51 screens: those lines were 17% of everything drawn and caused **79% of every remaining crossing**. Two fixes. The line now lands on the box nearest the region's top-left that the screen actually leads to, rather than on whichever box the walk happened to meet first — which, once a screen is drawn in clusters, could be lines down inside the region. And when the region is still too far for a line to be followed, the link is said in words like any other, with no exception for the screen itself. Across those 51 screens, lines crossing each other went from 47 to **10**, with no screen above 10; the busiest screen went from 19 to 2.
+
+- **A busy screen's picture no longer draws itself as a web.** On a screen whose whole body is one component, the region grouping had nothing to divide — 98 of 100 boxes landed in a single column — and the picture came back as a 5,600px ribbon whose 113 lines crossed each other 652 times, each one running over five other boxes' names. Three things are fixed. A screen is now laid out in **clusters**: a step, then the steps it sets in motion on the line under it, stepped in — so a box and the thing it fires are one line apart instead of seven, which is what the crossings were made of. A region's lines run to a width its size earns rather than a fixed one, so a big screen comes out about as wide as it is tall instead of a narrow ribbon. And a link whose two boxes end up too far apart to follow is now **said in words at both ends** — `→ resumeInference` under the box that leads there, `← CaptureView` under the box it arrives at — rather than drawn as a line across the whole picture; select either box and every one of its real lines draws, exactly as before. On the screen that prompted this, the lines drawn at rest cross each other **once**, down from 652, with half of them still drawn as real lines. Steps that fire nothing still share a line, so a flat screen is unchanged. Nothing needs a re-index.
+
+- **A region that holds a cycle no longer stretches the picture.** Rows inside a region were settled by relaxation, which never converges on a graph that has a loop in it: one real screen's 65 boxes were pushed to rows 294-301 while the rest sat at rows 0-2, and the order they were drawn in had nothing to do with what leads to what. Cycle-closing links are now set aside before the rows are settled, as the in-order reading already did — the loop is still drawn, it just cannot stretch the picture.
+
 - **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.
@@ -173,6 +183,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 #### Screens, links and navigation
 
+- **A `<Card/>` is the Card that file imports, not the first one in the repo.** When two components share a name — `Section`, `Picker`, `FrameCard`, one per feature folder — the render link out of a component picked whichever happened to be indexed first, ignoring what the file actually declares or imports. That cost twice over: a component gained a link to something it never renders, and the one it does render was left with nothing pointing at it, so callers, impact and the Screens tab all dead-ended there. In one Expo app it left a folder sheet's navigation standing alone on Screens with no screen behind it, while the link pointed at an unrelated card in another sheet; in Excalidraw every `<Excalidraw/>` in the app pointed at a documentation preview file instead of the component itself. A tag now resolves to the component that file declares, then to the one it imports — through a `@/` path alias as readily as a relative path — and a JavaScript tag prefers a JavaScript component over a same-named class in a mobile app's native half. No links are added or removed, only pointed at the right place. Re-index after upgrading.
+
 - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.
 
 - **A screen that talks to native code keeps its own navigations.** In a React Native or Expo app, a `router.push` written inside a listener for a native event was credited to whichever screen had *started* that round trip, not to the screen the push is written on. In one app that moved seven transitions off the capture screen and onto the review screen it opens — leaving the review screen looking as though nothing in the app could reach it, stranded in the "no transition reaches this" band at the bottom of the Screens tab, and printing Swift conditions like `Thread.isMainThread` on a JavaScript navigation. A navigation now belongs to the screen whose file it is written in; an event arriving from native code, from an HTTP call or off a queue is no longer read backwards as if it were a caller.

+ 131 - 0
__tests__/jsx-child-disambiguation.test.ts

@@ -0,0 +1,131 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+/**
+ * A JSX tag names ONE component, and the file it is written in says which:
+ * the one that file declares, or the one it imports. The synthesizer used to
+ * take the first node of that name in the whole graph, which is a coin flip as
+ * soon as a name repeats — and repeated component names are the norm, not the
+ * exception (`Section`, `Picker`, `FrameCard`, one per feature folder).
+ *
+ * Getting it wrong costs twice: the parent gains an edge to a component it
+ * never renders, and the component it DOES render is left with no caller, so
+ * every walk back from that subtree — Screens' navigation attribution,
+ * `getCallers`, an impact radius — dead-ends there. On an Expo app that showed
+ * up as a navigation standing alone on the Screens tab with no screen behind
+ * it, while the edge pointed at an unrelated card in another sheet.
+ *
+ * Each decoy here is deliberately named to sort BEFORE the right answer, so a
+ * first-match resolver picks it and the test fails.
+ */
+describe('JSX child disambiguation among same-named components', () => {
+  let dir: string;
+  let cg: any;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-child-'));
+    fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
+  });
+
+  afterEach(() => {
+    cg?.close?.();
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  const write = (rel: string, body: string) => {
+    const p = path.join(dir, rel);
+    fs.mkdirSync(path.dirname(p), { recursive: true });
+    fs.writeFileSync(p, body);
+  };
+
+  async function index() {
+    cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    return (cg as any).db.db;
+  }
+
+  /** The files a jsx-render edge out of `parent` points into. */
+  const rendersFrom = (db: any, parent: string): string[] =>
+    db
+      .prepare(
+        `SELECT t.file_path AS f FROM edges e
+           JOIN nodes s ON s.id = e.source
+           JOIN nodes t ON t.id = e.target
+          WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
+          ORDER BY f`
+      )
+      .all(parent)
+      .map((r: any) => r.f);
+
+  it('follows the import when the same name is declared in another file', async () => {
+    write('a-decoy/card.tsx', `export function Card() { return <div>decoy</div>; }\n`);
+    write('real/card.tsx', `export function Card() { return <div>real</div>; }\n`);
+    write(
+      'grid.tsx',
+      `import { Card } from './real/card';
+export function Grid() { return <div><Card /></div>; }
+`
+    );
+    const db = await index();
+    expect(rendersFrom(db, 'Grid')).toEqual(['real/card.tsx']);
+  });
+
+  it('follows a tsconfig path alias the same way a relative import is followed', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'tsconfig.json'),
+      JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } })
+    );
+    write('src/a-decoy/row.tsx', `export function Row() { return <li>decoy</li>; }\n`);
+    write('src/real/row.tsx', `export function Row() { return <li>real</li>; }\n`);
+    write(
+      'src/list.tsx',
+      `import { Row } from '@/real/row';
+export function List() { return <ul><Row /></ul>; }
+`
+    );
+    const db = await index();
+    expect(rendersFrom(db, 'List')).toEqual(['src/real/row.tsx']);
+  });
+
+  it('prefers a component declared in the same file over a same-named import elsewhere', async () => {
+    write('a-decoy/pill.tsx', `export function Pill() { return <span>decoy</span>; }\n`);
+    write(
+      'toolbar.tsx',
+      `function Pill() { return <span>local</span>; }
+export function Toolbar() { return <div><Pill /></div>; }
+`
+    );
+    const db = await index();
+    expect(rendersFrom(db, 'Toolbar')).toEqual(['toolbar.tsx']);
+  });
+
+  it('prefers a JS component over a same-named class in the app’s native half', async () => {
+    // A React Native app: `<CaptureSettings/>` is the TS component the screen
+    // imports, never the Swift type that happens to share its name.
+    write('a-ios/CaptureSettings.swift', `class CaptureSettings {\n  func sync() {}\n}\n`);
+    write('ui/capture-settings.tsx', `export function CaptureSettings() { return <div />; }\n`);
+    write(
+      'ui/overlay.tsx',
+      `import { CaptureSettings } from './capture-settings';
+export function Overlay() { return <div><CaptureSettings /></div>; }
+`
+    );
+    const db = await index();
+    expect(rendersFrom(db, 'Overlay')).toEqual(['ui/capture-settings.tsx']);
+  });
+
+  it('still links a name that appears exactly once', async () => {
+    write('only/badge.tsx', `export function Badge() { return <b>1</b>; }\n`);
+    write(
+      'header.tsx',
+      `import { Badge } from './only/badge';
+export function Header() { return <h1><Badge /></h1>; }
+`
+    );
+    const db = await index();
+    expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']);
+  });
+});

+ 198 - 5
__tests__/ui-steps-model.test.ts

@@ -278,15 +278,60 @@ describe('a screen laid out by region', () => {
 
   it('keeps a step above what it sets in motion, inside its region', () => {
     expect(at(anchor.id).y).toBeLessThan(at(a1.id).y);
-    expect(at(a1.id).y).toBe(at(a2.id).y);
     expect(at(a3.id).y).toBeGreaterThan(at(a1.id).y);
+    // A step that fires something is drawn as a cluster of its own — itself,
+    // then what it fires under it, stepped in. A step that fires nothing does
+    // not need one, so the two are no longer on the same line.
+    expect(at(a3.id).x).toBeGreaterThan(at(a1.id).x);
+  });
+
+  it('spreads the steps that fire nothing along one line, and clusters the ones that do', () => {
+    // A screen's handlers are siblings, not a hierarchy: giving each its own
+    // line turned a flat region into a column. Only a step that sets something
+    // in motion earns a cluster.
+    const D = { id: 'component:PanelD', label: 'PanelD' };
+    const root = step('/', 'screen', 0, { anchor: true });
+    const flat = [0, 1, 2].map((i) => step(`tap${i}`, 'trigger', 1, { order: i, region: D }));
+    const hub = step('tapRun', 'trigger', 1, { order: 3, region: D });
+    const under = step('runThing', 'store', 2, { order: 4, region: D, node: ref('runThing', 'src/d.storage.ts') });
+    const m = buildStepsModel(
+      payload(
+        [root, ...flat, hub, under],
+        [...[...flat, hub].map((s) => link(root, s)), link(hub, under, { kind: 'store' })]
+      )
+    );
+    const y = (id: string) => m.layout.nodes.find((n) => n.id === id)!.y;
+    expect(new Set(flat.map((s) => y(s.id))).size).toBe(1);
+    expect(y(hub.id)).toBeGreaterThan(y(flat[0]!.id));
+    expect(y(under.id)).toBeGreaterThan(y(hub.id));
+  });
+
+  it('settles a region that holds a cycle instead of running to the bound', () => {
+    // Relaxation over a cyclic graph never stops moving: one real screen sent
+    // sixty-five of its boxes to rows 294-301 while the rest sat at 0-2.
+    const E = { id: 'component:PanelE', label: 'PanelE' };
+    const root = step('/', 'screen', 0, { anchor: true });
+    const p1 = step('one', 'trigger', 1, { order: 0, region: E });
+    const p2 = step('two', 'trigger', 2, { order: 1, region: E });
+    const p3 = step('three', 'trigger', 3, { order: 2, region: E });
+    const m = buildStepsModel(
+      payload([root, p1, p2, p3], [link(root, p1), link(p1, p2), link(p2, p3), link(p3, p1)])
+    );
+    const ys = [p1, p2, p3].map((s) => m.layout.nodes.find((n) => n.id === s.id)!.y);
+    const pitch = 40 + m.layerGap;
+    // Three boxes, so at most three lines of them — not one line per pass.
+    expect((Math.max(...ys) - Math.min(...ys)) / pitch).toBeLessThanOrEqual(2);
   });
 
   it('at rest hides only the screen’s own fan and what points back up; every other lead-to draws', () => {
-    expect(model.regionEntries).toEqual(new Set([a1.id, b1.id]));
+    // The screen's one line into a region lands on the box nearest the region's
+    // top-left that the screen leads to — `tapUndo`, which fires nothing and so
+    // sits on the region's first line, not `tapSave`, which clustering moves
+    // below it because it fires the store.
+    expect(model.regionEntries).toEqual(new Set([a2.id, b1.id]));
     // One line from the screen into each region stands in for its whole fan.
-    expect(stepEdgeVisible(model, edge(anchor.id, a1.id), null)).toBe(true);
-    expect(stepEdgeVisible(model, edge(anchor.id, a2.id), null)).toBe(false);
+    expect(stepEdgeVisible(model, edge(anchor.id, a2.id), null)).toBe(true);
+    expect(stepEdgeVisible(model, edge(anchor.id, a1.id), null)).toBe(false);
     expect(stepEdgeVisible(model, edge(anchor.id, b1.id), null)).toBe(true);
     // A region's internal line, and another region's way into a shared step.
     expect(stepEdgeVisible(model, edge(a1.id, a3.id), null)).toBe(true);
@@ -295,7 +340,7 @@ describe('a screen laid out by region', () => {
     expect(stepEdgeVisible(model, edge(a1.id, b1.id), null)).toBe(false);
     // Selecting a step brings out everything that touches it, and only that.
     expect(stepEdgeVisible(model, edge(a1.id, b1.id), a1.id)).toBe(true);
-    expect(stepEdgeVisible(model, edge(anchor.id, a2.id), a1.id)).toBe(false);
+    expect(stepEdgeVisible(model, edge(anchor.id, b1.id), a1.id)).toBe(false);
   });
 
   it('stacks a handler above the store it calls, even when both are one hop from the screen', () => {
@@ -320,3 +365,151 @@ describe('a screen laid out by region', () => {
     expect(plain.regionEntries).toBeNull();
   });
 });
+
+describe('a link too far to draw is said in words', () => {
+  // Two chains in one region, and a link from the tail of the first to the
+  // tail of the second. The clusters are drawn one under the other, so that
+  // one link has to cross the whole region — the kind of line that, times a
+  // hundred, crossed itself six hundred and fifty-two times on a real screen.
+  const G = { id: 'component:PanelG', label: 'PanelG' };
+  const anchor = step('/', 'screen', 0, { anchor: true });
+  const chain = (p: string) =>
+    [0, 1, 2, 3].map((i) => step(`${p}${i}`, 'trigger', i + 1, { order: i, region: G, node: ref(`${p}${i}`, 'src/g.tsx') }));
+  const a = chain('a');
+  const b = chain('b');
+  const links = [
+    link(anchor, a[0]!),
+    link(anchor, b[0]!),
+    ...a.slice(1).map((s, i) => link(a[i]!, s)),
+    ...b.slice(1).map((s, i) => link(b[i]!, s)),
+    // The long one, from the bottom of the first cluster to the bottom of the second.
+    link(a[3]!, b[3]!),
+  ];
+  const model = buildStepsModel(payload([anchor, ...a, ...b], links));
+  const far = model.layout.edges.find((e) => e.source === a[3]!.id && e.target === b[3]!.id)!;
+  const near = model.layout.edges.find((e) => e.source === a[0]!.id && e.target === a[1]!.id)!;
+
+  it('draws the hop a reader can follow and words the one they cannot', () => {
+    expect(stepEdgeVisible(model, near, null)).toBe(true);
+    expect(stepEdgeVisible(model, far, null)).toBe(false);
+    expect(model.stubbed.has(far.id)).toBe(true);
+    expect(model.stubbed.has(near.id)).toBe(false);
+  });
+
+  it('says it at BOTH ends, so neither box reads as wired to nothing', () => {
+    expect(model.stubs.get(a[3]!.id) ?? []).toContainEqual(
+      expect.objectContaining({ edge: far.id, dir: 'out', label: 'b3' })
+    );
+    expect(model.stubs.get(b[3]!.id) ?? []).toContainEqual(
+      expect.objectContaining({ edge: far.id, dir: 'in', label: 'a3' })
+    );
+  });
+
+  it('draws every one of a box\u2019s real lines again when it is selected', () => {
+    expect(stepEdgeVisible(model, far, a[3]!.id)).toBe(true);
+    expect(stepEdgeVisible(model, far, b[3]!.id)).toBe(true);
+    // ...and still not for an unrelated selection.
+    expect(stepEdgeVisible(model, far, a[1]!.id)).toBe(false);
+  });
+
+  it('never words the screen\u2019s own fan \u2014 that is already one line per region', () => {
+    for (const list of model.stubs.values()) {
+      for (const stub of list) expect(stub.other).not.toBe(anchor.id);
+    }
+  });
+
+  it('leaves a picture whose every line is local alone', () => {
+    const plain = buildStepsModel(
+      payload([step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1)], [
+        link(step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1)),
+      ])
+    );
+    expect(plain.stubbed.size).toBe(0);
+    expect(plain.stubs.size).toBe(0);
+  });
+});
+
+describe('a stub names the box without its kind mark', () => {
+  it('drops the ⇢ / ⇠ a bridge or an event wears, so the direction reads alone', () => {
+    const H = { id: 'component:PanelH', label: 'PanelH' };
+    const anchor = step('/', 'screen', 0, { anchor: true });
+    const mk = (p: string) => [
+      step(`${p}0`, 'trigger', 1, { order: 0, region: H, node: ref(`${p}0`, 'src/h.tsx') }),
+      step(`${p}1`, 'trigger', 2, { order: 1, region: H, node: ref(`${p}1`, 'src/h.tsx') }),
+      step(`${p}2`, 'trigger', 3, { order: 2, region: H, node: ref(`${p}2`, 'src/h.tsx') }),
+      step(`${p}3`, 'bridge', 4, { order: 3, region: H, node: ref(`${p}3`, 'ios/H.swift', 'swift') }),
+    ];
+    const a = mk('a');
+    const b = mk('b');
+    const links = [
+      link(anchor, a[0]!),
+      link(anchor, b[0]!),
+      ...a.slice(1).map((s, i) => link(a[i]!, s)),
+      ...b.slice(1).map((s, i) => link(b[i]!, s)),
+      link(a[3]!, b[3]!),
+    ];
+    const m = buildStepsModel(payload([anchor, ...a, ...b], links));
+    expect(stepLabel(b[3]!)).toBe('⇢ b3');
+    expect(m.stubs.get(a[3]!.id) ?? []).toContainEqual(
+      expect.objectContaining({ dir: 'out', label: 'b3' })
+    );
+  });
+});
+
+describe('regions fill the canvas instead of squaring off into rows', () => {
+  it('lets a short region tuck under another short one, without reordering them', () => {
+    // Squaring the regions into rows made every row as tall as its tallest
+    // member: one real screen's canvas came out 44% region and 56% nothing.
+    const anchor = step('/', 'screen', 0, { anchor: true });
+    const region = (n: string) => ({ id: `component:${n}`, label: n });
+    const short = (n: string, order: number) =>
+      step(n, 'trigger', 1, { order, region: region('R' + n), node: ref(n, `src/${n}.tsx`) });
+    // One tall region (a chain), then several short ones beside it.
+    const tallR = region('Tall');
+    const chain = [0, 1, 2, 3, 4, 5].map((i) =>
+      step(`t${i}`, 'trigger', i + 1, { order: i, region: tallR, node: ref(`t${i}`, 'src/t.tsx') })
+    );
+    const a = short('alpha', 10), b = short('beta', 11), c = short('gamma', 12);
+    const steps = [anchor, ...chain, a, b, c];
+    const links = [
+      ...[chain[0]!, a, b, c].map((s) => link(anchor, s)),
+      ...chain.slice(1).map((s, i) => link(chain[i]!, s)),
+    ];
+    const m = buildStepsModel(payload(steps, links));
+    const zone = (n: string) => m.regions!.find((z) => z.label === n)!;
+    const tall = zone('Tall');
+    // The short regions are laid out after the tall one and do not wait for it.
+    for (const n of ['Ralpha', 'Rbeta', 'Rgamma']) {
+      expect(zone(n).y).toBeLessThan(tall.y + tall.height);
+    }
+    // …and the order still reads left to right: an earlier region is never
+    // pushed below a later one.
+    expect(zone('Ralpha').y).toBeLessThanOrEqual(zone('Rgamma').y);
+    // The canvas is not taller than the tall region needs it to be.
+    const H = Math.max(...m.layout.nodes.map((n) => n.y + n.height));
+    expect(H).toBeLessThan(tall.y + tall.height + 200);
+  });
+});
+
+describe('the width a picture wraps at is tried, not estimated', () => {
+  it('lets a wide spread run wide instead of wrapping into a column', () => {
+    // A cluster spends lines on its own structure, so `total width / line
+    // width` badly under-counts the lines a region takes: a formula tuned on
+    // that estimate wrapped a 98-box region into a 4,356px column. The widths
+    // are cheap to try exactly, so they are tried.
+    const R = { id: 'component:Wide', label: 'Wide' };
+    const anchor = step('/', 'screen', 0, { anchor: true });
+    const hub = step('startEverything', 'trigger', 1, { order: 0, region: R, node: ref('startEverything', 'src/w.tsx') });
+    const leaves = Array.from({ length: 24 }, (_, i) =>
+      step(`writeSomeValue${i}`, 'store', 2, { order: i + 1, region: R, node: ref(`writeSomeValue${i}`, 'src/w.storage.ts') })
+    );
+    const m = buildStepsModel(
+      payload([anchor, hub, ...leaves], [link(anchor, hub), ...leaves.map((l) => link(hub, l, { kind: 'store' }))])
+    );
+    const W = Math.max(...m.layout.nodes.map((n) => n.x + n.width));
+    const H = Math.max(...m.layout.nodes.map((n) => n.y + n.height));
+    // At a fixed 720px these twenty-four boxes wrapped into eight lines and the
+    // picture came out taller than wide; it should now be at least as wide.
+    expect(W).toBeGreaterThan(H);
+  });
+});

+ 50 - 7
docs/design/codegraph-ui-design-spec.md

@@ -550,16 +550,42 @@ the server names it on the step (`WireStep.region` — the fold's first node; th
 in the screen body; the first-reaching parent's region for everything deeper — first reach wins, as `first` does, so a
 shared store is one box in the region that got there first and every other region's way in is a link). Endpoints and
 functions carry none: their rows already read in the code's order, and `view=order` is untouched. The viewer
-(`steps-model.ts`'s `packRegions`) then lays each region out as its own small column — a box above what it sets in
-motion, a line wrapping past ~720px — and tiles the columns into bands under a width budget aimed at a readable aspect,
-in the order the walk met them: the screen's own source order, top of the screen to the left. **Within a region the
-rows come from the region's own links** (longest lead-to path, settled by relaxation as the order reading's rows are),
-never from distance to the anchor, which is flat inside a region: a handler and the store it calls are both one hop
-from the screen, and side by side their line was a level arch, hidden at rest — the store looked wired to nothing.
+(`steps-model.ts`'s `packRegions`) then lays each region out as its own small column, and tiles the columns into bands
+under a width budget aimed at a readable aspect, in the order the walk met them: the screen's own source order, top of
+the screen to the left. Regions are **not** squared off into rows — that made every row as tall as its tallest member,
+leaving the median screen's canvas 55% region and 45% nothing (`/home` 44%: 4,860px to hold ~2,160px of picture, all of
+it scrolled through). Each region instead goes as HIGH as it can and then as far LEFT as it can (a skyline over the
+regions already placed, `floorAt`), which keeps the reading order — regions are placed in source order, so an earlier
+one is never pushed below a later one — while a short region tucks under another short one. `/home`: 4,860px -> 3,584px,
+aspect 0.59 -> 0.94. **Within a region the rows come from the region's own links**, never from distance to the
+anchor, which is flat inside a region: a handler and the store it calls are both one hop from the screen, and side by
+side their line was a level arch, hidden at rest — the store looked wired to nothing. Cycle-closing links are set
+aside before the rows are settled (`forwardLinks`, the twin of the order reading's `withoutBackEdges`): relaxation
+never converges on a cyclic graph, and one screen's 65 boxes had been pushed to rows 294-301 while the rest sat at
+0-2. **A region is packed as CLUSTERS, not as rows**: a step, then the steps it sets in motion on the line under it,
+stepped in by `CLUSTER_INDENT`; a step that fires nothing needs no cluster, so the region's own starting points that
+lead nowhere still share one line (a screen's handlers are siblings, not a hierarchy — giving each its own line turned
+a flat region into a column). Rows-then-wrap was the alternative and it failed for a measurable reason: it put every
+step of one distance on the same rows and wrapped them at a fixed 720px, so a box and the thing it fires ended up
+seven lines apart — 70 of 113 lines on `/capture` joined boxes ONE step apart and rendered seven lines apart, which is
+what the 652 crossings were made of. The width a picture's lines run to is **tried, not estimated** (`REGION_WIDTHS`,
+scored by `canvasCost` against `CANVAS_ASPECT` 1.4): a cluster spends lines on its own structure, so `total / width`
+undercounts a region's lines badly and a formula tuned on that estimate wrapped 98 boxes into a 4,356px column. Laying
+a region out is cheap (~2ms for the whole model, eight widths included) and exact, so `layoutAt` runs the whole pack at
+each width and the best finished CANVAS wins. It has to be the canvas, not each region: squaring each region off
+individually leaves fewer of them side by side, so `/home` went 3,584px -> 5,624px while every region looked better.
+Within a region the clusters STACK — dropping them side by side as the regions drop onto the canvas was measured
+(total height 42,084 -> 39,756px, -6%) and rejected, because two clusters side by side run each other's lines through
+the other: lines-over-boxes 120 -> 134, crossings 5 -> 8. Regions differ, being far enough apart that few lines run
+between them. `/capture` went from a 1,227x5,588 ribbon to 3,417x3,348.
 Each region wears a caption (`RegionCaption.svelte` — its component's name over a hairline spanning its width)
 and the key explains it. **At rest the picture hides exactly two things** (`stepEdgeVisible`): the anchor's own fan —
 the anchor leads to everything *by definition*, `/home`'s 104 ways of saying so were the moiré, so one line into each
-region's first box stands in for it — and, as everywhere on the canvas, what points back up the layering. Every other
+region stands in for it, landing on the box nearest that region's top-left the anchor actually leads to (the walk's
+first member used to stand for the region, but clustering moves a step that fires something BELOW the ones that fire
+nothing, so that box could sit lines down inside the region and the line had to reach past everything above it); those
+stand-in lines are themselves subject to the stub rule, since on a ten-region screen the ones reaching into a lower
+band were 17% of all lines drawn and **79% of every crossing left** — and, as everywhere on the canvas, 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 an earlier cut that reserved cross-region lines for selection made a box that leads three places read
 as wired to nothing. The two hidings compose well: a shared step fed from below — the toast action every handler calls
@@ -571,6 +597,23 @@ 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.
 
+**Stubs — a link too far to follow is said in words, not drawn.** Clustering makes most links local, but not all: a
+step reached from two places is drawn under whichever reached it first, so the *other* way in has to cross the picture.
+A line is a good drawing of a hop between two boxes a reader takes in at once and a bad one of a hop across two
+thousand pixels — on `/capture` the 113 lines drawn at rest crossed each other **652 times** and each ran over ~5 other
+boxes' names, so no line could be followed and the boxes could not be read either. So `packStubs` (over the finished
+layout, since this is a question about geometry) keeps a link as a line only when it runs down the layering and its two
+boxes are within `STUB_SPAN_LINES` (3) lines and `REGION_LINE_MIN` (720px) across; everything else — back edges
+included, which drew nothing at all before, **and the screen's own line into a region**, which has no exception —
+becomes a `StepStub` at **both** ends: `→ resumeInference` under the box
+that leads there, `← CaptureView` under the box it arrives at, rendered by `StepStubs.svelte` in the gap under the box,
+capped at three with `+N more`. This is not a hiding: the link is *stated*, which says more than a line vanishing off
+the edge of the screen does, and it is the one at-rest cut that does not produce the "box that leads somewhere and
+draws nothing" every earlier cut produced (§ the arc above). Selecting the box draws every one of its real lines, as
+before, and the stub block steps aside while it is selected. The anchor's fan is never stubbed — it is already one line
+per region. Result on `/capture`: 48 lines drawn at rest crossing each other **once** (was 652), lines-over-boxes 553 →
+26, with every quiet box still one the screen itself fires directly.
+
 **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

+ 14 - 0
docs/from-chat/no-it-s-the-opposite.md

@@ -0,0 +1,14 @@
+# No — it's the opposite.
+
+No — it's the opposite. CG-72 exists to *prevent* the scenario you're describing, and nothing in this epic puts any plugin on a slow path. Let me untangle the two things the card compresses.
+
+**What's shipping in this epic (CG-64–71) has nothing to do with the kernel.** v1 plugins are framework/semantic plugins — route resolvers and synthesis passes, the same `FrameworkResolver` contract the ~35 built-ins (Express, Next.js, Django, Drupal…) use. They don't parse files; they run in the resolution layer after extraction. An external Acme-routes plugin executes on *exactly* the same path, at exactly the same speed, as the in-tree Drupal resolver. There is no legacy piping anywhere in v1. (The one real cost, which built-ins pay identically: any resolver with an `extract()` hook makes its languages' files take the decoded path instead of the kernel's raw-buffer shortcut — that's the price of framework extraction itself, in-tree or plugin, and CG-71's perf gate measures it.)
+
+**CG-72 is about a different, not-yet-existing thing: plugins that add a whole new *language*.** For language support there are two implementations today — the Rust kernel (hot path for migrated languages) and the TS/tree-sitter extractor (the fallback, which plenty of in-tree languages still use). If we published a language-plugin API *today*, the only interface we could hand authors is the TS `LanguageExtractor` — and then yes, your worry becomes real, twice over:
+
+1. Third-party languages would run the fallback path while first-party ones go native — permanently second-class, because
+2. a published API is frozen. Semver obligations would fossilize the legacy interface as the public contract, and every future kernel change would have to tiptoe around it. We'd have built the slow lane *into the spec*.
+
+So the deferral isn't "language plugins will be slow" — it's "we refuse to ship an API whose only possible implementation is the slow lane." CG-72's job is to design a backend-neutral `LanguageProvider` centered on the kernel's batch/buffer contract, so tree-sitter and the Rust walker become two implementations *behind* the same boundary and a third-party provider (likely WASM) plugs into the fast contract, not the legacy one. Until that spike lands, the honest position — stated in the doc — is that a new language is still a core contribution: nobody can write a language plugin at all yet, fast or slow, and that's deliberate.
+
+One calibration on "way slower": the fallback path is just tree-sitter — it's what every language used before the kernel existed and what several still use. The kernel is a big win, not the difference between usable and unusable. The real stake in CG-72 is API-freezing, with performance as the visible symptom.

+ 67 - 6
src/resolution/callback-synthesizer.ts

@@ -21,7 +21,7 @@
  * need receiver-type matching, deferred to Phase 3). All synthesized edges are
  * tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
  */
-import type { Edge, Node, NodeKind } from '../types';
+import type { Edge, Language, Node, NodeKind } from '../types';
 import type { QueryBuilder } from '../db/queries';
 import type { ResolutionContext } from './types';
 import { isGeneratedFile } from '../extraction/generated-detection';
@@ -37,6 +37,7 @@ import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-syn
 import { createYielder, type MaybeYield } from './cooperative-yield';
 import { crossTierEdges } from './tier-synthesizer';
 import { enclosingFn, makeLineAt } from './synth-utils';
+import { resolveImportPath } from './import-resolver';
 
 const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
 const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -47,6 +48,7 @@ const ON_RE = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:function\
 const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
 const SETSTATE_RE = /this\.setState\s*\(/;
 const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
+const JS_FAMILY = ['typescript', 'javascript', 'tsx', 'jsx'];
 const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
 const MAX_JSX_CHILDREN = 30;
 // Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
@@ -1187,6 +1189,65 @@ async function goGrpcStubImplEdges(queries: QueryBuilder, onYield: MaybeYield):
   return edges;
 }
 
+/** Kinds a JSX tag can name. A tag that resolves only to a type is markup we drop. */
+const JSX_CHILD_KINDS = new Set<NodeKind>(['component', 'function', 'class']);
+
+/**
+ * The languages a JSX tag can plausibly name a component in. Preferred over a
+ * same-named symbol in another language, never required — a React Native tag
+ * whose only match is the native class it bridges to (`requireNativeComponent`)
+ * still links there.
+ */
+const JSX_CHILD_LANGUAGES = [...JS_FAMILY, 'vue', 'svelte'];
+
+/** `localName` → the project file it is imported from, for one file's imports. */
+function importedFrom(ctx: ResolutionContext, file: string, language: Language): Map<string, string> {
+  const out = new Map<string, string>();
+  for (const im of ctx.getImportMappings(file, language)) {
+    // The mappings name the module as written; the file it is comes from the
+    // same resolution the import resolver uses (aliases, extensions, index files).
+    const resolved = im.resolvedPath ?? resolveImportPath(im.source, file, language, ctx);
+    if (resolved) out.set(im.localName, resolved);
+  }
+  return out;
+}
+
+/**
+ * The component a JSX tag names, among every node that shares the name.
+ *
+ * A tag is written in one file, and that file already says which `FrameCard` it
+ * means: the one it declares, or the one it imports. Taking the FIRST node of
+ * that name — which is what this did — is a coin flip once a name repeats, and
+ * it costs twice over. The parent gets an edge to a component it never renders,
+ * and the component it does render is left with no caller at all, so every walk
+ * back from that subtree dead-ends: on an Expo app whose `<FrameCard/>` was one
+ * of two, the folder sheet's `openBackgroundNoiseDetail` stood alone on Screens
+ * with no screen behind it, while the edge pointed at an unrelated card in
+ * another sheet.
+ *
+ * Same file first — a small component declared beside its use is the commonest
+ * shape, and the one an import can never disambiguate. Then the file the name
+ * is imported from. Then the language, which only decides a tie: a `.tsx` tag
+ * naming both a TS component and a same-named Swift class means the TS one.
+ */
+function jsxChild(
+  ctx: ResolutionContext,
+  name: string,
+  file: string,
+  importsOf: () => Map<string, string>
+): Node | undefined {
+  const candidates = ctx.getNodesByName(name).filter((n) => JSX_CHILD_KINDS.has(n.kind));
+  if (candidates.length <= 1) return candidates[0];
+  const local = candidates.find((n) => n.filePath === file);
+  if (local) return local;
+  const from = importsOf().get(name);
+  if (from) {
+    const imported = candidates.find((n) => n.filePath === from);
+    if (imported) return imported;
+  }
+  return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0];
+}
+
 /**
  * Phase 5: React JSX child rendering. A component that returns `<Child .../>`
  * mounts Child — React calls it — but JSX instantiation isn't a static call edge,
@@ -1213,6 +1274,10 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
       (n) => PARENT_KINDS.has(n.kind) && JS_FAMILY.includes(n.language)
     );
     if (parents.length === 0) continue;
+    // Read once per file, and only when a name actually turns out ambiguous.
+    let imports: Map<string, string> | null = null;
+    const importsOf = () =>
+      (imports ??= importedFrom(ctx, file, parents[0]!.language));
     for (const parent of parents) {
       const src = sliceLines(content, parent.startLine, parent.endLine);
       if (!src || (!src.includes('</') && !src.includes('/>'))) continue;
@@ -1223,9 +1288,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
       let added = 0;
       for (const name of names) {
         if (added >= MAX_JSX_CHILDREN) break;
-        const child = ctx.getNodesByName(name).find(
-          (n) => n.kind === 'component' || n.kind === 'function' || n.kind === 'class'
-        );
+        const child = jsxChild(ctx, name, file, importsOf);
         if (!child || child.id === parent.id) continue;
         const key = `${parent.id}>${child.id}`;
         if (seen.has(key)) continue;
@@ -3512,8 +3575,6 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P
  * pre/post marks) so adding a pass without bumping this fails loudly instead
  * of silently skewing the bar.
  */
-const JS_FAMILY = ['typescript', 'javascript', 'tsx', 'jsx'];
-
 /** `has(...)` shape passed to pass gates — true when the project contains any of the languages. */
 type HasLang = (...ls: string[]) => boolean;
 

+ 76 - 0
ui/src/components/steps/StepStubs.svelte

@@ -0,0 +1,76 @@
+<script lang="ts">
+  /**
+   * What a box leads to, and what reaches it, when the line would be too long
+   * to follow — said in words under the box instead of drawn across the canvas
+   * ({@link StepStub}).
+   *
+   * It sits in the gap under its box, takes no pointer of its own, and shows
+   * the first few; a box that fires twenty things says so and keeps its size.
+   * Clicking the box draws every one of its real lines, so this is the resting
+   * summary, never the only way to see them.
+   */
+  import type { NodeProps } from '@xyflow/svelte';
+  import type { StepStub } from '../../lib/steps-model';
+
+  /** The most that are named before the rest become a count. */
+  const MAX = 3;
+
+  let { data }: NodeProps = $props();
+  const node = $derived(data as unknown as { stubs: StepStub[]; width: number; dimmed: boolean });
+  const stubs = $derived(node.stubs);
+  const width = $derived(node.width);
+  const dimmed = $derived(node.dimmed);
+
+  const shown = $derived(stubs.slice(0, MAX));
+  const rest = $derived(stubs.length - shown.length);
+  const restTitle = $derived(
+    stubs
+      .slice(MAX)
+      .map((s) => `${s.dir === 'out' ? 'leads to' : 'arrives from'} ${s.label}`)
+      .join('\n')
+  );
+</script>
+
+<div class="stubs" class:dimmed style={`width:${width}px`} aria-hidden="true">
+  {#each shown as stub (stub.edge + stub.dir)}
+    <span class="stub" title={`${stub.dir === 'out' ? 'Leads to' : 'Arrives from'} ${stub.label} — too far across the picture to draw as a line. Click the box to draw it.`}>
+      <span class="arrow">{stub.dir === 'out' ? '→' : '←'}</span>{stub.label}
+    </span>
+  {/each}
+  {#if rest > 0}
+    <span class="stub more" title={restTitle}>+{rest} more</span>
+  {/if}
+</div>
+
+<style>
+  .stubs {
+    display: flex;
+    flex-direction: column;
+    gap: 1px;
+    padding-top: 3px;
+    box-sizing: border-box;
+    pointer-events: none;
+    font-size: 10.5px;
+    line-height: 13px;
+    color: var(--ink-3);
+  }
+  .stubs.dimmed {
+    opacity: 0.25;
+  }
+  .stub {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-family: var(--mono);
+    pointer-events: auto;
+  }
+  .arrow {
+    display: inline-block;
+    width: 11px;
+    color: var(--ink-4);
+  }
+  .more {
+    color: var(--ink-4);
+  }
+</style>

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

@@ -58,6 +58,16 @@
           >
         </div>
       {/if}
+      {#if !order}
+        <div class="lrow">
+          <span class="k-label mono">→ name</span>
+          <span
+            >What a box leads to, or what reaches it (←), when the two are too far apart for a line to be followed — said
+            in words under the box rather than drawn across the picture. Select the box and every one of its real lines
+            draws</span
+          >
+        </div>
+      {/if}
       {#if project === 'api'}
         <div class="lrow">
           <span class="k-box mono">POST /x</span>

+ 18 - 2
ui/src/lib/program-model.ts

@@ -424,8 +424,24 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
   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. 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: [] };
+  // decisions are points between steps, not captions under a box. It keeps
+  // every line: a reading of when things happen is the lines, and its rows are
+  // short enough that they stay local — `stubbed` empty leaves the filter be.
+  return {
+    layout,
+    nodes,
+    edges,
+    layerGap: SCREEN_LAYER_GAP,
+    curves,
+    polylines,
+    counts,
+    regions: null,
+    regionEntries: null,
+    forks,
+    decisions: [],
+    stubs: new Map(),
+    stubbed: new Set(),
+  };
 }
 
 /**

+ 445 - 128
ui/src/lib/steps-model.ts

@@ -110,6 +110,14 @@ export interface StepsModel extends Picture {
    * holds none.
    */
   decisions: StepDecision[];
+  /**
+   * Per box, the links it does NOT draw a line for at rest, said in words on
+   * it instead ({@link StepStub}). Empty for a picture whose every line is
+   * local.
+   */
+  stubs: Map<string, StepStub[]>;
+  /** The edges drawn as stubs rather than lines — what {@link stepEdgeVisible} keeps back. */
+  stubbed: ReadonlySet<string>;
 }
 
 /**
@@ -128,6 +136,31 @@ export interface StepForkInfo {
   label: string;
 }
 
+/**
+ * One end of a link said in WORDS on its box instead of drawn as a line across
+ * the canvas.
+ *
+ * A line is a good drawing of a hop between two boxes a reader can see at
+ * once. It is a bad drawing of a hop across two thousand pixels: on one real
+ * screen the hundred and thirteen lines drawn at rest crossed each other six
+ * hundred and fifty-two times and each ran over five other boxes' names, so
+ * no single line could be followed and the boxes could not be read either.
+ * The link is not dropped — it is stated at BOTH ends, `→ resumeInference` on
+ * the box that leads there and `← CaptureView` on the box it arrives at, which
+ * says more than a line disappearing off the edge of the screen does. Select
+ * the box and every one of its real lines draws, exactly as before.
+ */
+export interface StepStub {
+  /** The layout edge this stands for, so selecting draws the real line. */
+  edge: string;
+  /** The box at the other end. */
+  other: string;
+  /** What that box calls itself. */
+  label: string;
+  /** `out` — this box leads there; `in` — it arrives from there. */
+  dir: 'out' | 'in';
+}
+
 /** One region of a screen's picture: its caption, and the space its boxes hold. */
 export interface StepRegionZone {
   id: string;
@@ -471,6 +504,8 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
   const curves = trackedCurves(layout, layerGap);
   const polylines = new Map<string, Point[]>();
   for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
+  const entries = zones === null ? null : new Set(zones.map((z) => z.entry));
+  const { stubs, stubbed } = packStubs(layout, nodes, layerGap, entries);
   return {
     layout,
     nodes,
@@ -480,14 +515,101 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
     polylines,
     counts,
     regions: zones,
-    regionEntries: zones === null ? null : new Set(zones.map((z) => z.entry)),
+    regionEntries: entries,
     forks: null,
+    stubs,
+    stubbed,
     // 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),
   };
 }
 
+/* ----------------------------------------------------------------- stubs -- */
+
+/**
+ * How far apart two boxes may be, in lines, for the hop between them to still
+ * read as a line. Three lines is about as far as an eye follows a curve
+ * through other boxes without losing which one it left.
+ */
+const STUB_SPAN_LINES = 3;
+
+/**
+ * Which links are said in words rather than drawn, and the words for each box.
+ *
+ * A link is drawn when both its boxes are close enough to take it in at once —
+ * within {@link STUB_SPAN_LINES} lines, and no further across than a region's
+ * own lines start out running ({@link REGION_LINE_MIN}), so a drawn line stays
+ * inside one column of reading — and it runs down the layering. Everything else becomes a
+ * {@link StepStub} at both ends — the one link into each region included, so a
+ * region tiled into a lower band no longer reaches back up to the start with a
+ * line across the whole picture. Those 96 lines were 17% of what a real app's
+ * 51 screens drew and 79% of everything they crossed.
+ */
+/**
+ * The name a stub points at, without the mark its box wears for its kind: the
+ * stub already leads with a direction, and `← ⇠ onCaptureProgress` reads as
+ * two arrows arguing. The box itself keeps its mark, where nothing competes.
+ */
+function stubLabel(label: string): string {
+  return label.startsWith('⇢ ') || label.startsWith('⇠ ') ? label.slice(2) : label;
+}
+
+function packStubs(
+  layout: MapLayout,
+  infos: Map<string, StepNodeInfo>,
+  layerGap: number,
+  entries: ReadonlySet<string> | null
+): { stubs: Map<string, StepStub[]>; stubbed: ReadonlySet<string> } {
+  const stubs = new Map<string, StepStub[]>();
+  const stubbed = new Set<string>();
+  const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
+  const pitch = NODE_HEIGHT + layerGap;
+  const add = (id: string, stub: StepStub): void => {
+    stubs.set(id, [...(stubs.get(id) ?? []), stub]);
+  };
+  for (const edge of layout.edges) {
+    const from = nodeById.get(edge.source);
+    const to = nodeById.get(edge.target);
+    if (!from || !to) continue;
+    // On a regioned picture the anchor's fan is already stood in for by one
+    // link into each region ({@link StepsModel.regionEntries}); only THAT link
+    // is a line worth keeping or words worth saying, and the rest of the fan
+    // stays quiet as it was. An unregioned picture has no stand-in, so its
+    // anchor's links follow the same rule as every other.
+    if (entries !== null && infos.get(edge.source)?.step.anchor && !entries.has(edge.target)) continue;
+    const lines = Math.abs(to.y - from.y) / pitch;
+    const across = Math.abs(to.x + to.width / 2 - (from.x + from.width / 2));
+    // A back edge points up the layering: it is not a local hop however near
+    // it is, and it was already drawing nothing at rest — now it says so.
+    const near = !edge.back && !edge.thin && lines <= STUB_SPAN_LINES + 0.01 && across <= REGION_LINE_MIN;
+    if (near) continue;
+    stubbed.add(edge.id);
+    add(edge.source, {
+      edge: edge.id,
+      other: edge.target,
+      label: stubLabel(infos.get(edge.target)?.label ?? edge.target),
+      dir: 'out',
+    });
+    add(edge.target, {
+      edge: edge.id,
+      other: edge.source,
+      label: stubLabel(infos.get(edge.source)?.label ?? edge.source),
+      dir: 'in',
+    });
+  }
+  // What a box leads to reads before what reaches it, and each side in the
+  // order the picture puts the other end — down the page, then across.
+  const place = (id: string): number => {
+    const n = nodeById.get(id);
+    return n ? n.y * 100000 + n.x : 0;
+  };
+  for (const list of stubs.values()) {
+    list.sort((a, b) => (a.dir === b.dir ? place(a.other) - place(b.other) : a.dir === 'out' ? -1 : 1));
+  }
+  return { stubs, stubbed };
+}
+
 /* --------------------------------------------------------------- regions -- */
 
 /**
@@ -500,8 +622,29 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
 const REGION_GAP_Y = 72;
 /** The vertical rhythm of a regioned picture: one line of boxes and the gap under it. */
 const REGION_PITCH = NODE_HEIGHT + REGION_GAP_Y;
-/** A region's line of boxes wraps past this natural width. */
-const REGION_LINE_MAX = 720;
+/** The least width a region's line of boxes runs to before it wraps. */
+const REGION_LINE_MIN = 720;
+/**
+ * The widths a region's lines are tried at, narrowest first — a tie keeps the
+ * narrowest, so a small region never sprawls. A fixed 720 was the whole reason
+ * a big screen came out a 6,500px ribbon.
+ */
+const REGION_WIDTHS = [REGION_LINE_MIN, 1000, 1300, 1600, 1900, 2200, 2600, 3000];
+/**
+ * The shape the whole picture is aimed at: a little wider than tall. Boxes are
+ * wide and short, and so is the window a reader has, so a landscape picture
+ * wastes less of both than a square one — and a reader scrolls a tall picture
+ * far more than they pan a wide one.
+ */
+const CANVAS_ASPECT = 1.4;
+/** How far a finished canvas is from {@link CANVAS_ASPECT}, in log space so wide and tall cost alike. */
+function canvasCost(laid: { width: number; height: number }): number {
+  return Math.abs(Math.log(Math.max(1, laid.width) / Math.max(1, laid.height) / CANVAS_ASPECT));
+}
+/** How far a cluster's boxes sit in from the step that fires them. */
+const CLUSTER_INDENT = 26;
+/** Clusters stop stepping in past this depth, so a long chain stays on screen. */
+const CLUSTER_DEPTH_MAX = 6;
 /** Between two regions side by side. */
 const REGION_GUTTER = 72;
 /** Extra room between two rows of regions — the captions of the next row live in it. */
@@ -511,10 +654,55 @@ function bandBudget(area: number, widest: number): number {
   return Math.max(widest, Math.min(3400, Math.max(1440, Math.ceil(Math.sqrt(area * 2.4)))));
 }
 
+/**
+ * The links minus the ones that close a cycle — those whose end is still open
+ * on the way in, found by one walk from every link's source in order, so the
+ * walk's own order decides which way round a cycle is the forward one. The
+ * twin of the order reading's `withoutBackEdges`, over map links.
+ */
+function forwardLinks(links: readonly WireMapLink[]): WireMapLink[] {
+  const out = new Map<string, WireMapLink[]>();
+  const nodes = new Set<string>();
+  for (const l of links) {
+    nodes.add(l.source);
+    nodes.add(l.target);
+    const list = out.get(l.source);
+    if (list) list.push(l);
+    else out.set(l.source, [l]);
+  }
+  /** 1 = open on the way in, 2 = done with. */
+  const state = new Map<string, number>();
+  const closes = new Set<WireMapLink>();
+  const visit = (root: string): void => {
+    const stack: { id: string; next: number }[] = [{ id: root, next: 0 }];
+    state.set(root, 1);
+    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 link = list[top.next++]!;
+      const seen = state.get(link.target) ?? 0;
+      if (seen === 1) {
+        closes.add(link);
+        continue;
+      }
+      if (seen === 2) continue;
+      state.set(link.target, 1);
+      stack.push({ id: link.target, next: 0 });
+    }
+  };
+  for (const id of nodes) if (!state.has(id)) visit(id);
+  return links.filter((l) => !closes.has(l));
+}
+
 /**
  * The layout of a screen's picture: each region a small column of lines —
- * a box above what it sets in motion, a line wrapping when it grows past
- * {@link REGION_LINE_MAX} — and the regions tiled left to right, wrapping
+ * a box above what it sets in motion, a line wrapping when it grows past the
+ * width its shape earns ({@link REGION_WIDTHS}) — and the regions tiled left to right, wrapping
  * into bands, in the order the walk met them: the screen's own source order.
  * The anchor sits alone on top. Everything downstream — the tracked curves,
  * the pills, the pointer — is the same machinery over the same shapes.
@@ -563,148 +751,274 @@ function packRegions(
     regions.set(id, region);
   }
 
-  // Within a region, a step goes under the steps that lead to it.
+  // Within a region, a step goes under the steps that lead to it — minus the
+  // links that close a cycle. Relaxation over a cyclic graph never settles: it
+  // adds a row per pass until the bound, so a region holding one cycle sent
+  // sixty-five of its boxes to rows 294-301 while the rest sat at 0-2, and the
+  // order they were then packed in had nothing to do with what leads to what.
+  // The order reading learned this first ({@link withoutBackEdges} there); the
+  // cycle is still drawn, it just cannot stretch the picture.
   const regionOf = new Map(members.map((s) => [s.id, s.region?.id ?? anchor.id]));
+  const intra = links.filter(
+    (l) =>
+      l.source !== anchor.id &&
+      l.target !== anchor.id &&
+      l.source !== l.target &&
+      regionOf.get(l.source) === regionOf.get(l.target)
+  );
+  /** The steps the screen itself leads to — where its one line into a region can land. */
+  const fromAnchor = new Set(links.filter((l) => l.source === anchor.id).map((l) => l.target));
   const parentsOf = new Map<string, string[]>();
-  for (const l of links) {
-    if (l.source === anchor.id || l.target === anchor.id) continue;
-    if (regionOf.get(l.source) !== regionOf.get(l.target)) continue;
+  for (const l of forwardLinks(intra)) {
     const list = parentsOf.get(l.target) ?? [];
     list.push(l.source);
     parentsOf.set(l.target, list);
   }
 
-  interface Packed {
-    lines: string[][];
-    width: number;
-  }
-  const packed = new Map<string, Packed>();
-  for (const region of regions.values()) {
-    // A step goes under the steps that lead to it — rows from the region's OWN
-    // links, never from distance to the anchor, which is flat inside a region:
-    // a handler and the store it calls are both one hop from the screen, and
-    // side by side their line was a level arch, hidden at rest, so the store
-    // looked wired to nothing. Longest lead-to path, settled by relaxation as
-    // the order reading settles its rows; a cycle stops moving at the bound.
-    const rowOf = new Map<string, number>(region.members.map((m) => [m.id, 0]));
-    for (let pass = 0; pass < region.members.length; pass++) {
-      let moved = false;
-      for (const m of region.members) {
-        const above = (parentsOf.get(m.id) ?? [])
-          .map((p) => rowOf.get(p))
-          .filter((x): x is number => x !== undefined);
-        if (above.length === 0) continue;
-        const next = Math.max(...above) + 1;
-        if (next > rowOf.get(m.id)!) {
-          rowOf.set(m.id, next);
-          moved = true;
-        }
-      }
-      if (!moved) break;
-    }
-    const rows = new Map<number, WireStep[]>();
-    for (const m of region.members) {
-      const d = rowOf.get(m.id)!;
-      rows.set(d, [...(rows.get(d) ?? []), m]);
-    }
-    const lines: string[][] = [];
-    // The order a step was placed in, for putting its children near it.
-    const placedAt = new Map<string, number>();
-    let width = 0;
-    for (const d of [...rows.keys()].sort((a, b) => a - b)) {
-      const row = rows.get(d)!;
-      const near = (s: WireStep): number => {
-        const placed = (parentsOf.get(s.id) ?? []).map((p) => placedAt.get(p)).filter((x): x is number => x !== undefined);
-        if (placed.length === 0) return Number.MAX_SAFE_INTEGER;
-        return placed.reduce((a, b) => a + b, 0) / placed.length;
-      };
-      row.sort(
-        (a, b) => near(a) - near(b) || (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id)
+  // Who a step is drawn under: the FIRST step in the region that leads to it,
+  // in the walk's order — the same first-reach-wins the walk itself uses. A
+  // step belongs to one cluster, so a box that fires twenty things has those
+  // twenty under it rather than scattered down the region.
+  const childrenOf = new Map<string, string[]>();
+  {
+    const order = new Map(members.map((s) => [s.id, s.order ?? Number.MAX_SAFE_INTEGER]));
+    const out = new Map<string, string[]>();
+    for (const l of forwardLinks(intra)) out.set(l.source, [...(out.get(l.source) ?? []), l.target]);
+    const owned = new Set<string>();
+    const queue = members.filter((s) => (parentsOf.get(s.id) ?? []).length === 0).map((s) => s.id);
+    for (const id of queue) owned.add(id);
+    while (queue.length > 0) {
+      const id = queue.shift()!;
+      const kids = [...new Set(out.get(id) ?? [])].sort(
+        (a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0) || a.localeCompare(b)
       );
-      let line: string[] = [];
-      let w = 0;
-      for (const m of row) {
-        const bw = widthOf(m.id);
-        if (line.length > 0 && w + NODE_GAP + bw > REGION_LINE_MAX) {
-          lines.push(line);
-          width = Math.max(width, w);
-          line = [];
-          w = 0;
-        }
-        line.push(m.id);
-        w += (line.length > 1 ? NODE_GAP : 0) + bw;
-        placedAt.set(m.id, placedAt.size);
-      }
-      if (line.length > 0) {
-        lines.push(line);
-        width = Math.max(width, w);
+      for (const kid of kids) {
+        if (owned.has(kid)) continue;
+        owned.add(kid);
+        childrenOf.set(id, [...(childrenOf.get(id) ?? []), kid]);
+        queue.push(kid);
       }
     }
-    packed.set(region.id, { lines, width });
   }
 
-  // Tile the regions into bands under a width budget.
-  interface Band {
-    regions: Region[];
+  interface Packed {
+    /** Where each member sits: x from the region's left, and which line it is on. */
+    pos: Map<string, { x: number; line: number }>;
     lines: number;
     width: number;
+    entry: string;
   }
-  let area = 0;
-  let widest = 0;
-  for (const region of regions.values()) {
-    const p = packed.get(region.id)!;
-    area += p.width * p.lines.length * REGION_PITCH;
-    widest = Math.max(widest, p.width);
-  }
-  const budget = bandBudget(area, widest);
-  const bands: Band[] = [];
-  let band: Band | null = null;
-  for (const region of regions.values()) {
-    const p = packed.get(region.id)!;
-    if (band === null || band.width + REGION_GUTTER + p.width > budget) {
-      band = { regions: [], lines: 0, width: -REGION_GUTTER };
-      bands.push(band);
+  const anchorY = PADDING;
+  /**
+   * The whole picture at one line width: every region packed with its lines
+   * allowed to run that wide, then the regions dropped onto the canvas.
+   *
+   * The width cannot be estimated from the boxes alone — a cluster spends
+   * lines on its own structure, so `total / width` badly under-counts what a
+   * region takes — and it cannot be chosen per region either: widening one
+   * region to square it off leaves fewer of them side by side, so the CANVAS
+   * gets taller even as each region looks better (`/home` went 3,584px to
+   * 5,624px that way). One width, scored on the finished canvas.
+   */
+  const layoutAt = (lineMax: number) => {
+    const packed = new Map<string, Packed>();
+    for (const region of regions.values()) {
+      // A region is drawn as CLUSTERS, not as rows: a step, then the steps it
+      // sets in motion on the line under it, indented. Rows-then-wrap put every
+      // step of one distance on the same rows and wrapped them at a fixed width,
+      // so a box and the thing it fires ended up seven
+      // lines apart and their line crossed everything between — 70 of 113 lines
+      // on one real screen joined boxes ONE step apart and rendered seven lines
+      // apart. Under a cluster the same line is one line long.
+      const ids = new Set(region.members.map((m) => m.id));
+      const kidsOf = (id: string): string[] => (childrenOf.get(id) ?? []).filter((k) => ids.has(k));
+      const starts = region.members.filter((m) => (parentsOf.get(m.id) ?? []).length === 0).map((m) => m.id);
+
+      /** Lay the region out with its lines allowed to run this wide. */
+      const layAt = (lineMax: number): { pos: Map<string, { x: number; line: number }>; lines: number; width: number } => {
+        /**
+         * One cluster, in its own coordinates: a step, then the steps it sets
+         * in motion on the line under it, stepped in, and a cluster of its own
+         * for anything that leads on further.
+         */
+        const cluster = (root: string): { pos: Map<string, { x: number; line: number }>; lines: number; width: number } => {
+          const pos = new Map<string, { x: number; line: number }>();
+          let line = 0;
+          let width = 0;
+          const spread = (list: string[], left: number): void => {
+            if (list.length === 0) return;
+            let lx = left;
+            for (const id of list) {
+              const bw = widthOf(id);
+              if (lx > left && lx + bw - left > lineMax) {
+                line += 1;
+                lx = left;
+              }
+              pos.set(id, { x: lx, line });
+              width = Math.max(width, lx + bw);
+              lx += bw + NODE_GAP;
+            }
+            line += 1;
+          };
+          const place = (id: string, depth: number): void => {
+            const x = Math.min(depth, CLUSTER_DEPTH_MAX) * CLUSTER_INDENT;
+            pos.set(id, { x, line });
+            width = Math.max(width, x + widthOf(id));
+            line += 1;
+            const kids = kidsOf(id);
+            spread(kids.filter((k) => kidsOf(k).length === 0), x + CLUSTER_INDENT);
+            for (const hub of kids.filter((k) => kidsOf(k).length > 0)) place(hub, depth + 1);
+          };
+          place(root, 0);
+          return { pos, lines: line, width };
+        };
+
+        /** The steps that fire nothing, side by side — a screen's handlers are siblings, not a hierarchy. */
+        const flat = (list: string[]): { pos: Map<string, { x: number; line: number }>; lines: number; width: number } => {
+          const pos = new Map<string, { x: number; line: number }>();
+          let line = 0;
+          let width = 0;
+          let lx = 0;
+          for (const id of list) {
+            const bw = widthOf(id);
+            if (lx > 0 && lx + bw > lineMax) {
+              line += 1;
+              lx = 0;
+            }
+            pos.set(id, { x: lx, line });
+            width = Math.max(width, lx + bw);
+            lx += bw + NODE_GAP;
+          }
+          return { pos, lines: list.length === 0 ? 0 : line + 1, width };
+        };
+
+        // The region's blocks, in the walk's order: everything that fires
+        // nothing first, as one spread, then a cluster per step that does.
+        const blocks: { pos: Map<string, { x: number; line: number }>; lines: number; width: number }[] = [];
+        const bare = starts.filter((id) => kidsOf(id).length === 0);
+        if (bare.length > 0) blocks.push(flat(bare));
+        const placed = new Set(bare);
+        for (const id of starts) {
+          if (placed.has(id)) continue;
+          const b = cluster(id);
+          for (const k of b.pos.keys()) placed.add(k);
+          blocks.push(b);
+        }
+        // A cycle can leave a member with no reachable start; it stands alone.
+        for (const m of region.members) {
+          if (placed.has(m.id)) continue;
+          const b = cluster(m.id);
+          for (const k of b.pos.keys()) placed.add(k);
+          blocks.push(b);
+        }
+
+        // The blocks stack, one under the next, in the walk's order.
+        //
+        // Dropping them side by side the way the REGIONS drop onto the canvas
+        // was tried and measured across a real app's 51 screens, and it is a
+        // bad trade: total height 42,084px -> 39,756px (-6%), but lines running
+        // over other boxes 120 -> 134 and lines crossing each other 5 -> 8,
+        // because two clusters side by side put each one's lines through the
+        // other. Height is cheap to scroll; a crossed line is what made this
+        // picture unreadable in the first place. Regions differ — they are far
+        // enough apart that few lines run between them.
+        const pos = new Map<string, { x: number; line: number }>();
+        let width = 0;
+        let lines = 0;
+        for (const b of blocks) {
+          for (const [id, at2] of b.pos) pos.set(id, { x: at2.x, line: lines + at2.line });
+          width = Math.max(width, b.width);
+          lines += b.lines;
+        }
+        return { pos, lines, width };
+      };
+
+      const { pos, lines: line, width } = layAt(lineMax);
+      // Where the screen's own line into this region lands: the box nearest the
+      // region's top-left that the screen actually leads to. The walk's first
+      // member used to stand for the region, but clustering moves a step that
+      // fires something below the ones that fire nothing, so that box could sit
+      // lines down inside the region and the line from the start had to reach
+      // past everything above it to get there.
+      const topmost = (ids: string[]): string | null =>
+        ids
+          .filter((id) => pos.has(id))
+          .sort((a, b) => pos.get(a)!.line - pos.get(b)!.line || pos.get(a)!.x - pos.get(b)!.x)[0] ?? null;
+      const entry =
+        topmost(region.members.filter((m) => fromAnchor.has(m.id)).map((m) => m.id)) ??
+        topmost(region.members.map((m) => m.id)) ??
+        region.members[0]!.id;
+      packed.set(region.id, { pos, lines: line, width, entry });
     }
-    band.regions.push(region);
-    band.lines = Math.max(band.lines, p.lines.length);
-    band.width += REGION_GUTTER + p.width;
-  }
-  const contentWidth = Math.max(widthOf(anchor.id), ...bands.map((b) => b.width));
 
-  // Place everything. The anchor is alone on top; each band's regions centre
-  // as a row of columns; a region's lines centre within its own width.
-  const at = new Map<string, { x: number; y: number; line: number }>();
-  const zones: StepRegionZone[] = [];
-  const anchorY = PADDING;
-  let y = anchorY + NODE_HEIGHT + SCREEN_LAYER_GAP + BAND_GAP;
-  let globalLine = 0;
-  for (const b of bands) {
-    let x = PADDING + (contentWidth - b.width) / 2;
-    for (const region of b.regions) {
+    // How wide the picture may run before a region has to go underneath.
+    let area = 0;
+    let widest = 0;
+    for (const region of regions.values()) {
       const p = packed.get(region.id)!;
-      p.lines.forEach((line, j) => {
-        const lw = line.reduce((a, id) => a + widthOf(id), 0) + NODE_GAP * (line.length - 1);
-        let lx = x + (p.width - lw) / 2;
-        for (const id of line) {
-          at.set(id, { x: lx, y: y + j * REGION_PITCH, line: globalLine + j });
-          lx += widthOf(id) + NODE_GAP;
-        }
-      });
-      zones.push({
-        id: region.id,
-        label: region.label,
-        x,
-        y,
-        width: p.width,
-        height: (p.lines.length - 1) * REGION_PITCH + NODE_HEIGHT,
-        entry: p.lines[0]![0]!,
-      });
-      x += p.width + REGION_GUTTER;
+      area += p.width * p.lines * REGION_PITCH;
+      widest = Math.max(widest, p.width);
     }
-    y += b.lines * REGION_PITCH + BAND_GAP;
-    globalLine += b.lines;
-  }
-  const height = y - REGION_PITCH - BAND_GAP + NODE_HEIGHT + PADDING;
+    const budget = bandBudget(area, widest);
+
+    // Place everything. The anchor is alone on top; each region, in the order
+    // the walk met them, goes as high as it can and then as far left as it can.
+    //
+    // Squaring the regions off into bands — a row at a time, the row as tall as
+    // its tallest member — left a screen's canvas 55% region and 45% nothing
+    // (`/home` 44%: 4,860px tall to hold 2,160px of picture), and that emptiness
+    // is what a reader scrolls through. Going highest-then-leftmost keeps the
+    // reading order (an earlier region is placed first, so it is never pushed
+    // below a later one) while a short region tucks under another short one
+    // instead of waiting for the tall one beside it.
+    const at = new Map<string, { x: number; y: number; line: number }>();
+    const zones: StepRegionZone[] = [];
+    const topY = anchorY + NODE_HEIGHT + SCREEN_LAYER_GAP + BAND_GAP;
+    /** What each stretch of the canvas is filled to, so far. */
+    const sky: { x0: number; x1: number; y: number }[] = [];
+    const floorAt = (x0: number, x1: number): number => {
+      let f = topY;
+      for (const s of sky) if (s.x1 > x0 + 1 && s.x0 < x1 - 1) f = Math.max(f, s.y);
+      return f;
+    };
+    for (const region of regions.values()) {
+      const p = packed.get(region.id)!;
+      const rh = Math.max(0, p.lines - 1) * REGION_PITCH + NODE_HEIGHT;
+      // Somewhere to start, plus the right-hand edge of everything already down.
+      const spots = [PADDING, ...sky.map((s) => s.x1 + REGION_GUTTER)]
+        .filter((x, i, all) => all.indexOf(x) === i && x + p.width <= PADDING + Math.max(budget, p.width))
+        .sort((m, n) => m - n);
+      let best = { x: PADDING, y: floorAt(PADDING, PADDING + p.width) };
+      for (const x of spots) {
+        const y = floorAt(x, x + p.width);
+        if (y < best.y - 1) best = { x, y };
+      }
+      const { x, y } = best;
+      // A cluster reads from its left edge, not from the region's centre: the
+      // indent is what says which step fired which.
+      for (const [id, at2] of p.pos) {
+        at.set(id, { x: x + at2.x, y: y + at2.line * REGION_PITCH, line: 0 });
+      }
+      zones.push({ id: region.id, label: region.label, x, y, width: p.width, height: rh, entry: p.entry });
+      // The gap under a region carries the next one's caption.
+      sky.push({ x0: x, x1: x + p.width, y: y + rh + BAND_GAP });
+    }
+    const contentWidth = Math.max(widthOf(anchor.id), ...zones.map((z) => z.x + z.width - PADDING));
+    // The layering comes from the finished geometry, not from a band counter:
+    // once regions drop independently, what a reader sees as one row IS one row.
+    for (const spot of at.values()) spot.line = Math.round((spot.y - topY) / REGION_PITCH);
+    const globalLine = Math.max(0, ...[...at.values()].map((v) => v.line)) + 1;
+    const height = Math.max(anchorY + NODE_HEIGHT, ...zones.map((z) => z.y + z.height)) + PADDING;
+    return { at, zones, contentWidth, globalLine, height, width: contentWidth + PADDING * 2 };
+  };
+
+  // Try the widths and keep the picture that comes out closest to the shape a
+  // window has. A tie keeps the narrowest, so a small picture never sprawls.
+  const tries = REGION_WIDTHS.map((w) => layoutAt(w));
+  const { at, zones, contentWidth, globalLine, height } = tries.reduce((a, b) =>
+    canvasCost(a) <= canvasCost(b) ? a : b
+  );
+
 
   // Layers count from the bottom, as the Map's do: the route of an edge and
   // which sides it uses fall out of the comparison alone.
@@ -856,6 +1170,9 @@ export function stepEdgeVisible(
     return r.has(edge.source) || r.has(edge.target);
   }
   if (edge.thin || edge.back) return false;
+  // A hop too far to follow says itself in words on both its boxes instead
+  // ({@link StepStub}); drawing it as well is the web those words replace.
+  if (model.stubbed.has(edge.id)) return false;
   if (model.regions === null) return true;
   const from = model.nodes.get(edge.source)?.step;
   if (from?.anchor) return model.regionEntries?.has(edge.target) ?? true;

+ 22 - 1
ui/src/views/StepsView.svelte

@@ -17,6 +17,7 @@
   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 StepStubs from '../components/steps/StepStubs.svelte';
   import ForkPoint from '../components/steps/ForkPoint.svelte';
   import DecisionCaption from '../components/steps/DecisionCaption.svelte';
   import RegionCaption from '../components/steps/RegionCaption.svelte';
@@ -120,7 +121,7 @@
     }
   });
 
-  const nodeTypes = { step: StepNode, region: RegionCaption, fork: ForkPoint, decision: DecisionCaption };
+  const nodeTypes = { step: StepNode, region: RegionCaption, fork: ForkPoint, decision: DecisionCaption, stubs: StepStubs };
 
   /** Two clicks on one box closer than this are a double-click. */
   const DOUBLE_CLICK_MS = 400;
@@ -285,6 +286,26 @@
         data: { label: d.label, width: d.width, dimmed: neighbours !== null && !neighbours.has(owner) },
       });
     }
+    // A box's far links, said in words in the gap under it. Not while it is
+    // selected: then every one of its real lines is drawn, and the words would
+    // be saying a second time what the reader can now see.
+    for (const node of model.layout.nodes) {
+      const list = model.stubs.get(node.id);
+      if (list === undefined || list.length === 0 || selected === node.id) continue;
+      captions.push({
+        id: `stubs:${node.id}`,
+        type: 'stubs',
+        position: { x: node.x, y: node.y + node.height },
+        draggable: false,
+        selectable: false,
+        connectable: false,
+        data: {
+          stubs: list,
+          width: node.width,
+          dimmed: neighbours !== null && !neighbours.has(node.id),
+        },
+      });
+    }
     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.