Преглед изворни кода

feat(steps): lay out screen pictures by region and render region captions

Adds region-based layout support for screens: steps now carry region information, and the server packs regions into dedicated bands with per-region captions. UI changes introduce RegionCaption and region-aware step rendering; StepsModel and related views (StepsView) consume region data, while the region-aware layout keeps anchor and region boundaries intact. Tests and docs updated to reflect region-driven organization and visualization of screen regions. This enables visualizing a screen’s picture as region-based columns rather than a single distance-driven row.
Colby McHenry пре 4 дана
родитељ
комит
882ea143e8

+ 12 - 0
CHANGELOG.md

@@ -14,6 +14,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **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.
+
+- **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.
 
 - **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.
@@ -40,6 +44,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- **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 handler called from under a binding says what it passes.** A press that runs `tryCatchSync(onClosePress)` drew a box for the wrapper and stopped — leaving the one thing a reader asks ("what is being wrapped?") unsaid, even though every other call-shaped site already prints its arguments. The panel and tooltip now say `tryCatchSync(onClosePress)` — the argument is the answer.
+
+- **A step the walk stopped at keeps its whole name.** A boundary — another screen, or a cap the walk hit — ends its name with an ellipsis by design, but the box was not sized for it, so a longer name lost its last letters instead (`/scan-to-verif…` for `/scan-to-verify …`). The anchor's start mark clipped a long path the same way (`/sheets/forgot-passw…`). The box now makes room for both.
+
+- **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.
+
 - **A link written under a condition says so on the Screens tab.** A checkout stepper whose tabs are each enabled by their own prop, and a navbar whose admin links only render for an admin, both read as **always** — every transition written in markup was drawn with no condition at all, while the ones written as calls carried theirs. They are read the same way now: a store's checkout tabs say `step1` … `step4`, its navbar says `userInfo && userInfo.isAdmin` for the admin links and `!userInfo` for sign-in, and 59 of that store's 74 transitions carry the condition they actually run under, up from 20. A template language with no condition rules of its own still says nothing rather than guessing.
 
 - **A link in markup no longer reads as a helper's return value.** `<Link to='/shipping'>` was labelled `return /shipping`, which in this picture means the destination came back from somewhere else and was inferred. It is written right there, so it now reads `link /shipping` — and an internal `<a href>` reads `a`. Only a destination that genuinely arrives from elsewhere still says `return`.

+ 178 - 1
__tests__/expo-router.test.ts

@@ -5,6 +5,7 @@ import * as os from 'os';
 import { CodeGraph } from '../src';
 import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
 import { buildScreens } from '../src/ui-server/api/screens';
+import { buildSteps } from '../src/ui-server/api/steps';
 import {
   expoRouterResolver,
   routePathForFile,
@@ -465,7 +466,12 @@ describe('expo-router: end-to-end', () => {
     );
     write(
       'src/services/post-login.ts',
-      'export const resolvePostLoginRoute = async (): Promise<string> => {\n' +
+      // The literal-union return type is the trap: its routes are string
+      // literals too, BEFORE the ternary — the scan must skip the signature
+      // or the annotation's guardless positions win.
+      'export const resolvePostLoginRoute = async (): Promise<\n' +
+        "  '/welcome/' | '/'\n" +
+        '> => {\n' +
         "  return (await seen()) ? '/' : '/welcome/'\n" +
         '}\n' +
         'async function seen() { return true }\n' +
@@ -531,6 +537,15 @@ describe('expo-router: end-to-end', () => {
     expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
     expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
     expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
+    // Each return literal carries its own POSITION: the two arms of
+    // `return (await seen()) ? '/' : '/welcome/'` share a line, and only the
+    // column lets the guard reader say which arm an edge is — without it both
+    // navigations read as `always`.
+    const welcomeEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/welcome')!;
+    const rootEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/')!;
+    expect(rootEdge.line).toBe(welcomeEdge.line);
+    expect(typeof rootEdge.column).toBe('number');
+    expect(welcomeEdge.column!).toBeGreaterThan(rootEdge.column!);
     const finishLogin = cg.getNodesByName('finishLogin')[0]!;
     expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
     const apiPath = cg.getNodesByName('apiPath')[0]!;
@@ -555,6 +570,168 @@ describe('expo-router: end-to-end', () => {
     expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
     expect(screens.dropped).toBe(0);
 
+    // The steps walk reads each arm's own condition off the literal's column:
+    // where the app goes after login is a fork, not two `always`es.
+    const steps = await buildSteps(cg, tmpDir, new URLSearchParams({ symbol: 'finishLogin' }));
+    const stepByLabel = (label: string) => steps.steps.find((s) => s.label === label)!;
+    const toRoot = steps.links.find((l) => l.to === stepByLabel('/').id)!;
+    const toWelcome = steps.links.find((l) => l.to === stepByLabel('/welcome').id)!;
+    expect(toRoot.when).toMatch(/await seen\(\)/);
+    expect(toRoot.when).not.toMatch(/!/);
+    expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
+
+    cg.close();
+  });
+});
+
+// =============================================================================
+// The backward walk must not leave the app's own execution context
+// =============================================================================
+
+/**
+ * A navigation written inside a component the graph can only reach BACKWARDS
+ * through the native bridge belongs to the screen whose file it is written in
+ * — not to whichever screen happened to start the round trip.
+ *
+ * The shape, from a real Expo app: `/capture` renders `ARCapturePage`, which
+ * renders `memo(CaptureComponent)`; the `router.push` lives in an inline
+ * listener inside `CaptureComponent`. Nothing points at `CaptureComponent`
+ * except Swift emitters — the walk skips `file` nodes, and `memo(x)` leaves no
+ * edge from the memo to the function — so before the guard the walk escaped
+ * through `rn-event-channel`, came back down into `ReviewScreen` (which had
+ * called the native module), and filed four of `/capture`'s navigations under
+ * `/capture/review`, whose only remaining feed was itself. It also carried the
+ * Swift guards home as conditions on a JavaScript navigation.
+ */
+describe('expo-router screens: attribution stops at the native bridge', () => {
+  let tmpDir: string | undefined;
+
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  function write(rel: string, content: string) {
+    const full = path.join(tmpDir!, rel);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, content);
+  }
+
+  it('files the push on the screen whose file holds it, not on the screen that started the round trip', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-bridge-'));
+    write(
+      'package.json',
+      JSON.stringify({
+        name: 'app',
+        dependencies: { expo: '52', 'expo-router': '4', react: '18', 'react-native': '0.76' },
+      })
+    );
+    write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
+    write('src/app/index.tsx', 'export default function Home() { return null }\n');
+
+    // The Swift side: a method the JS calls, which ends in the event emit.
+    write(
+      'ios/CaptureView.swift',
+      `import Foundation
+@objc(CaptureView)
+class CaptureView: NSObject {
+  @objc func startRetake() {
+    CaptureEvents.shared.emitCaptureComplete()
+  }
+}
+`
+    );
+    // The ObjC bridging shim, without which the JS side never reaches Swift.
+    write(
+      'ios/CaptureView.m',
+      `#import <React/RCTBridgeModule.h>
+@interface RCT_EXTERN_MODULE(CaptureView, NSObject)
+RCT_EXTERN_METHOD(startRetake)
+@end
+`
+    );
+    write(
+      'ios/CaptureEvents.swift',
+      `import Foundation
+class CaptureEvents: RCTEventEmitter {
+  func emitCaptureComplete() {
+    guard Thread.isMainThread else { return }
+    sendEvent(withName: "onCaptureComplete", body: nil)
+  }
+}
+`
+    );
+
+    // /capture — the push is written HERE, in an inline listener inside a
+    // sibling of the route's own default export.
+    write(
+      'src/app/capture/index.tsx',
+      `import { memo, useEffect } from 'react'
+import { router } from 'expo-router'
+const MemoizedCaptureComponent = memo(CaptureComponent)
+export default function ARCapturePage() {
+  return <MemoizedCaptureComponent />
+}
+function CaptureComponent() {
+  useEffect(() => {
+    const sub = nativeEmitter.addListener('onCaptureComplete', (data) => {
+      if (!isRetakeBatchActive) {
+        router.push('/capture/review')
+      }
+    })
+    return () => sub.remove()
+  }, [])
+  return null
+}
+`
+    );
+
+    // /capture/review — calls into the native module, which is what makes the
+    // Swift emitter backwards-reachable from this screen.
+    write(
+      'src/app/capture/review/index.tsx',
+      `import { NativeModules } from 'react-native'
+const { CaptureView } = NativeModules
+export default function ReviewScreen() {
+  function handleRetake() {
+    CaptureView.startRetake()
+  }
+  return handleRetake
+}
+`
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    // The escape route the walk used to take really is in the graph.
+    const capture = cg.getNodesByName('CaptureComponent').find((n) => n.kind !== 'route')!;
+    const bridged = cg
+      .getIncomingEdgesTo([capture.id], ['calls'])
+      .filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'rn-event-channel');
+    expect(bridged.length).toBeGreaterThan(0);
+    // …and it really is a route back OUT to the other screen: without the
+    // guard the walk runs handleRetake > startRetake > emitCaptureComplete >
+    // CaptureComponent and lands the push on /capture/review.
+    const startRetake = cg.getNodesByName('startRetake').find((n) => n.language === 'swift')!;
+    expect(cg.getIncomingEdgesTo([startRetake.id], ['calls']).map((e) => cg.getNodesByIds([e.source]).get(e.source)?.name)).toContain(
+      'handleRetake'
+    );
+
+    const screens = await buildScreens(cg, tmpDir);
+    const from = (path: string) => screens.screens.find((s) => s.path === path)!;
+    const review = from('/capture/review');
+    const links = screens.links.filter((l) => l.to === review.id);
+
+    // One transition into /capture/review, and it comes from /capture.
+    expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']);
+    // Written right there: no chain, and no Swift guard smuggled in.
+    expect(links[0]!.via).toEqual([]);
+    expect(links[0]!.when).toBe('!isRetakeBatchActive');
+    expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
+    // …and /capture/review is not left feeding only itself.
+    expect(screens.links.some((l) => l.from === review.id && l.to === review.id)).toBe(false);
+
     cg.close();
   });
 });

+ 77 - 0
__tests__/ui-steps-api.test.ts

@@ -129,6 +129,33 @@ beforeAll(async () => {
       '  }\n' +
       '}\n'
   );
+  write(
+    'src/api/remove-thing.ts',
+    "import { client } from './client'\n" +
+      'export async function removeThing(name: string) {\n' +
+      "  await client.post('/things/remove', { name })\n" +
+      '}\n'
+  );
+  // The dialog-confirm-then-act pattern: the prompt is an effect box AND the
+  // thing that fires the handler bound in its buttons.
+  write(
+    'src/app/confirm.tsx',
+    "import { Alert, Button } from 'react-native'\n" +
+      "import { removeThing } from '../api/remove-thing'\n" +
+      'export default function ConfirmScreen() {\n' +
+      '  return (\n' +
+      '    <Button\n' +
+      '      title="remove"\n' +
+      '      onPress={() =>\n' +
+      "        Alert.prompt('Remove thing', 'Which one?', [\n" +
+      "          { text: 'Cancel' },\n" +
+      "          { text: 'OK', onPress: (name) => { if (name) removeThing(name) } },\n" +
+      '        ])\n' +
+      '      }\n' +
+      '    />\n' +
+      '  )\n' +
+      '}\n'
+  );
   cg = CodeGraph.initSync(tmpDir);
   await cg.indexAll();
 });
@@ -302,3 +329,53 @@ describe('buildSteps', () => {
     await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
   });
 });
+
+describe('screen regions', () => {
+  it('a screen names every step’s region: the screen body for its own code, inherited down the walk', async () => {
+    const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
+    const byLabel = Object.fromEntries(payload.steps.map((s) => [s.label, s]));
+    // Every step of a screen's picture belongs somewhere.
+    for (const s of payload.steps) if (!s.anchor) expect(s.region, s.label).toBeDefined();
+    // A handler declared in the screen body belongs to the screen's own component…
+    expect(byLabel['handleApprove']!.region!.label).toBe('ReviewScreen');
+    // …and what it reaches inherits the region that got there first.
+    expect(byLabel['finalizeCaptureSession']!.region!.id).toBe(byLabel['handleApprove']!.region!.id);
+    expect(byLabel['setZipUri']!.region!.label).toBe('ReviewScreen');
+  });
+
+  it('a step reached through a folded component belongs to that component — the fold’s first node', async () => {
+    const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
+    const handler = payload.steps.find((s) => s.label === 'handleOpen')!;
+    const toHandler = payload.links.find((l) => l.to === handler.id)!;
+    expect(handler.region!.label).toBe(toHandler.via[0]!.name);
+  });
+
+  it('an anchor with a body carries no regions — its rows read in the code’s order', async () => {
+    const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
+    for (const s of payload.steps) expect(s.region).toBeUndefined();
+  });
+});
+
+describe('fired from a dialog', () => {
+  it('a handler bound inside a dialog’s buttons arrives from the dialog, not from the screen', async () => {
+    const confirm = cg.getNodesByKind('route').find((r) => r.name === '/confirm')!;
+    const payload = await buildSteps(cg, tmpDir, q({ anchor: confirm.id }));
+    const prompt = payload.steps.find((s) => s.kind === 'effect' && s.label.startsWith('Alert.prompt'))!;
+    const handler = payload.steps.find((s) => s.label === 'removeThing')!;
+    const into = payload.links.filter((l) => l.to === handler.id);
+    expect(into).toHaveLength(1);
+    expect(into[0]!.from).toBe(prompt.id);
+    expect(into[0]!.trigger?.of).toBe('Alert.prompt');
+    // A handler CALLED from under a binding says what it passes, as every
+    // call-shaped site does — the argument is what a wrapper wraps.
+    expect(into[0]!.sites[0]!.args).toBe('name');
+    // One step deeper than the prompt that fires it, in the prompt's region.
+    expect(handler.depth).toBe(prompt.depth + 1);
+    expect(handler.region!.id).toBe(prompt.region!.id);
+    // …and what the handler does hangs on below.
+    const post = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
+    expect(payload.links.some((l) => l.from === handler.id && l.to === post.id)).toBe(true);
+  });
+});

+ 82 - 1
__tests__/ui-steps-model.test.ts

@@ -4,7 +4,7 @@
  * rule, and the panel's two lists.
  */
 import { describe, it, expect } from 'vitest';
-import { buildStepsModel, countWords, kindWord, kindWords, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
+import { 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';
 
@@ -150,3 +150,84 @@ describe('row order', () => {
     expect(row).toEqual([a.id, b.id, c.id, d.id]);
   });
 });
+
+describe('a screen laid out by region', () => {
+  const A = { id: 'component:PanelA', label: 'PanelA' };
+  const B = { id: 'component:PanelB', label: 'PanelB' };
+  const anchor = step('/', 'screen', 0, { anchor: true });
+  const a1 = step('tapSave', 'trigger', 1, { order: 0, region: A });
+  const a2 = step('tapUndo', 'trigger', 1, { order: 1, region: A });
+  const a3 = step('saveThing', 'store', 2, { order: 0, region: A, node: ref('saveThing', 'src/things.storage.ts') });
+  const b1 = step('tapShare', 'trigger', 1, { order: 2, region: B, node: ref('tapShare', 'src/b.tsx') });
+  const links = [
+    link(anchor, a1),
+    link(anchor, a2),
+    link(anchor, b1),
+    link(a1, a3, { kind: 'store' }),
+    link(a1, b1),
+    // Another region's way into a shared store — a lead-to line like any other.
+    link(b1, a3, { kind: 'store' }),
+  ];
+  const model = buildStepsModel(payload([anchor, a1, a2, b1, a3], links));
+  const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
+  const between = (id: string, zone: { x: number; width: number }) => {
+    const n = at(id);
+    return n.x >= zone.x && n.x + n.width <= zone.x + zone.width;
+  };
+  const edge = (from: string, to: string) => model.layout.edges.find((e) => e.source === from && e.target === to)!;
+
+  it('names the regions in the order the walk met them, each holding its own boxes', () => {
+    expect(model.regions!.map((z) => z.label)).toEqual(['PanelA', 'PanelB']);
+    const [zoneA, zoneB] = model.regions!;
+    expect(between(a1.id, zoneA!)).toBe(true);
+    expect(between(a2.id, zoneA!)).toBe(true);
+    expect(between(a3.id, zoneA!)).toBe(true);
+    expect(between(b1.id, zoneB!)).toBe(true);
+    // Side by side, not overlapping: the second region starts past the first.
+    expect(zoneB!.x).toBeGreaterThanOrEqual(zoneA!.x + zoneA!.width);
+  });
+
+  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);
+  });
+
+  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]));
+    // 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, 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);
+    expect(stepEdgeVisible(model, edge(b1.id, a3.id), null)).toBe(true);
+    // Two boxes on one row point sideways — back-ish, a click away as everywhere.
+    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);
+  });
+
+  it('stacks a handler above the store it calls, even when both are one hop from the screen', () => {
+    // Anchor distance is flat inside a region: both of these are depth 1, and
+    // side by side their link was a level arch, hidden at rest — the store
+    // floated. The region's own links order its rows instead.
+    const C = { id: 'component:PanelC', label: 'PanelC' };
+    const root = step('/', 'screen', 0, { anchor: true });
+    const h = step('tapCopy', 'trigger', 1, { order: 0, region: C });
+    const s = step('copyThing', 'store', 1, { order: 1, region: C, node: ref('copyThing', 'src/c.storage.ts') });
+    const m = buildStepsModel(payload([root, h, s], [link(root, h), link(root, s), link(h, s, { kind: 'store' })]));
+    const y = (id: string) => m.layout.nodes.find((n) => n.id === id)!.y;
+    expect(y(s.id)).toBeGreaterThan(y(h.id));
+    const e = m.layout.edges.find((x) => x.source === h.id && x.target === s.id)!;
+    expect(e.route).toBe('down');
+    expect(stepEdgeVisible(m, e, null)).toBe(true);
+  });
+
+  it('a payload without regions keeps the rows, and the Map’s at-rest rule', () => {
+    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.regions).toBeNull();
+    expect(plain.regionEntries).toBeNull();
+  });
+});

+ 41 - 2
docs/design/codegraph-ui-design-spec.md

@@ -504,7 +504,9 @@ whole app; so is a native event that lands in a COMPONENT (the capture overlay t
 another screen's body — `cut: 'component'`). A bridge or event step needs evidence — a bridge resolver's edge or a synthesized channel's; a plain
 name-matched call across the families (`arr.flat()` landing on a Swift `flat`) is neither drawn nor walked. Effects
 are one box per (function, category), labelled by the first call and counting the rest (`client.post +1`), the calls
-listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step) also
+listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step, a
+handler CALLED from under a binding — a bound one passes nothing, but `tryCatchSync(onClosePress)`'s argument is the
+whole answer to what a wrapper wraps) also
 carries **what it passes** — `graph/branch-guards.ts`'s `callArgumentsForFile`, read from the same cached tree as the
 guards: string literals and names whole, an object as its keys (`{ email, password }`), arrays `[…]`, functions
 `() => …`, nested calls `f(…)`, Swift labels kept (`withName: "onZipComplete"`), ≤ 96 chars — printed on the panel's
@@ -517,7 +519,15 @@ runs-later call (`useEffect`, `setTimeout`, `addListener('onZipComplete')`, `.th
 handleX = useCallback(…)`) is a boundary, its own story. A function called from under such a binding is a
 **handler step** even though nothing passed it as a value (`onPress={() => handleLogin(values)}` — the common
 case, and the Formik case), and every call-shaped link carries its trigger: a store action or an effect fired
-straight from a tap says so. The pill on a handler link says the event (`onPress · <Button>`,
+straight from a tap says so. **And when the binding sits in the ARGUMENTS of a call that itself became an effect
+step, the line arrives from that box, not from the step that owns the fold** — `Alert.prompt('Add Folder', …,
+[{ onPress: (name) => createBackgroundFolder(name) }])` is two facts, the prompt as a device box and the prompt's
+button firing the handler, and "the screen fires it" says nothing when the screen fires everything. The walk keeps
+each effect call's span per function (`firedSpans`); a site whose trigger NAMES the call (`onPress ·
+Alert.prompt(…)`) and whose position falls inside that span is rewired to it, innermost span first, one step
+deeper — 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. An `onSubmit · useFormik(…)` names no effect and stays where it was.
+The pill on a handler link says the event (`onPress · <Button>`,
 `onSubmit · useFormik(…)`), not the conditions; the box's second line says it before the file; the panel prints
 `FIRES FROM onPress · <Button> in LoginButton` above the `via` chain, which is set in `--ink-2` at the
 condition's size — it is the answer to "where on the screen", not an afterthought. Caps, each announced: depth in steps (default 8, ≤ 14, `cut: 'depth'` on the step it stopped
@@ -526,6 +536,35 @@ hubs (fan-in ≥ 40) and shared chrome (a component rendered by ≥ 5 parents 
 attributes navigations rather than deciding what to walk into) are dead ends, counted in `truncated`. A step several
 events land on says `⇠ first +N` and lists them in the panel.
 
+**Regions — a 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 rows-by-distance degenerate there: on the mobile app's `/home`, 89 of 120 steps sat one hop out — one
+28,000px row, every line a near-horizontal sweep. The walk already knows the missing structure: a step reached out of the
+anchor descends through the fold's chain, whose first node is the top-level component (or hook) of the screen's tree, so
+the server names it on the step (`WireStep.region` — the fold's first node; the screen's own component for a call written
+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.
+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
+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
+— stays quiet through the back rule alone, no hub threshold needed, and selection still brings a step's whole story
+out. A box nothing points at is then a fact, not an accident — the region runs it directly, on render or mount or from
+a binding written inline (`Alert.prompt` in an empty-state view, a store read during render, `Keyboard.addListener` in
+an effect) — and the key says so; selecting it lights its line from the anchor, with what fires it. Same boxes, same
+tracked curves (over a tighter in-region gap), same pills, pointer and panel. Result across the app's 52 screens: widest
+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.
+
 **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 —

+ 1 - 1
src/extraction/extraction-version.ts

@@ -21,4 +21,4 @@
  * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
  * in the product is load-bearing").
  */
-export const EXTRACTION_VERSION = 25;
+export const EXTRACTION_VERSION = 26;

+ 1 - 1
src/resolution/callback-synthesizer.ts

@@ -3137,7 +3137,7 @@ async function nixOptionPathEdges(queries: QueryBuilder, onYield: MaybeYield): P
   // own namespace (`attrsOf (submodule { options = ...; })`) — its internals
   // are not globally addressable, so the sentinel blocks registration below it
   // while still excluding the region from write candidates.
-  const SUBMODULE = 'submodule';
+  const SUBMODULE = '\u0000submodule';
   const decls = new Map<string, Rec[]>();
   const writes: Rec[] = [];
   const register = (path: string[], rec: Rec) => {

+ 21 - 4
src/resolution/expo-router-synthesizer.ts

@@ -82,7 +82,7 @@ export async function expoRouterReturnEdges(ctx: ResolutionContext, onYield: May
   // 2. Each callee → the project function it names → the screens in its body.
   const edges: Edge[] = [];
   const seen = new Set<string>();
-  const screensByHelper = new Map<string, Array<{ node: Node; href: string; line: number }> | null>();
+  const screensByHelper = new Map<string, Array<{ node: Node; href: string; line: number; column: number }> | null>();
   for (const site of sites) {
     await onYield();
     const helper = resolveHelper(site, ctx);
@@ -102,6 +102,11 @@ export async function expoRouterReturnEdges(ctx: ResolutionContext, onYield: May
         target: s.node.id,
         kind: 'navigates',
         line: s.line,
+        // The literal's own column, not the line's start: the two arms of
+        // `return (await seen()) ? '/home/' : '/welcome/'` share a line, and
+        // only the column lets the guard reader say WHICH arm each edge is —
+        // without it both drew as `always`.
+        column: s.column,
         provenance: 'heuristic',
         metadata: {
           synthesizedBy: 'expo-router-return',
@@ -152,15 +157,23 @@ function screensInBody(
   helper: Node,
   ctx: ResolutionContext,
   table: ReturnType<typeof routeTable>
-): Array<{ node: Node; href: string; line: number }> | null {
+): Array<{ node: Node; href: string; line: number; column: number }> | null {
   const lines = ctx.getFileLines?.(helper.filePath) ?? ctx.readFile(helper.filePath)?.split(/\r?\n/);
   if (!lines) return null;
   const body = stripCommentsForRegex(
     lines.slice(helper.startLine - 1, helper.endLine).join('\n'),
     'typescript'
   );
-  const found = new Map<string, { node: Node; href: string; line: number }>();
-  for (let i = 0; i < body.length; i++) {
+  // Scan the BODY, not the signature: a return type of literal routes —
+  // `async (): Promise<'/welcome/' | '/home/'> => …` — is string literals
+  // too, and they come FIRST, so first-occurrence-wins kept the annotation's
+  // positions: inside no branch, so both navigations read as `always`. The
+  // body starts at the arrow, or at the first brace for a declaration.
+  const arrow = body.indexOf('=>');
+  const brace = body.indexOf('{');
+  const scanFrom = arrow >= 0 && (brace < 0 || arrow < brace) ? arrow + 2 : brace >= 0 ? brace + 1 : 0;
+  const found = new Map<string, { node: Node; href: string; line: number; column: number }>();
+  for (let i = scanFrom; i < body.length; i++) {
     const ch = body[i];
     if (ch !== '"' && ch !== "'" && ch !== '`') continue;
     const start = i;
@@ -173,10 +186,14 @@ function screensInBody(
     if (segs === null) continue;
     const route = matchRoute(segs, table);
     if (!route || found.has(route.id)) continue;
+    // `body` begins at column 0 of the helper's first line and the comment
+    // stripper preserves offsets, so the literal's column is exact.
+    const lineStart = body.lastIndexOf('\n', start - 1) + 1;
     found.set(route.id, {
       node: route,
       href: href.display,
       line: helper.startLine + body.slice(0, start).split('\n').length - 1,
+      column: start - lineStart,
     });
     if (found.size > MAX_SCREENS_PER_HELPER) return null;
   }

+ 1 - 1
src/telemetry/index.ts

@@ -238,7 +238,7 @@ export class Telemetry {
     const day = this.utcDay();
     const cn = client?.name?.slice(0, 64);
     const cv = client?.version?.slice(0, 32);
-    const key = [day, kind, name, cn ?? '', cv ?? ''].join('');
+    const key = [day, kind, name, cn ?? '', cv ?? ''].join('\u0000');
     const line = this.counts.get(key);
     if (line) {
       line.c += 1;

+ 37 - 1
src/ui-server/api/screens.ts

@@ -150,6 +150,41 @@ const MAX_MENTION_FILE_BYTES = 256 * 1024;
  */
 const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'contains', 'references'];
 
+/**
+ * An edge that arrives from another execution context, never from a caller.
+ *
+ * Walked FORWARDS these are the point of the synthesizers — a flow question
+ * follows a native event or an HTTP call to the code that runs next. Walked
+ * BACKWARDS they answer a different question than this walk asks. "Which
+ * screen is this navigation written on" is about where the reader is standing;
+ * "what could have triggered the event that got us here" is about a chain that
+ * already left the screen, the language and the process.
+ *
+ * Following one costs an answer that is not merely vague but wrong. In an Expo
+ * app, `CaptureComponent` — the body of `app/capture/index.tsx`, whose sibling
+ * `ARCapturePage` the `/capture` route renders — is reached by nothing but six
+ * Swift `emit` calls: the walk skips `file` nodes, and `memo(CaptureComponent)`
+ * leaves no edge from the memo to the function. So every `router.push` written
+ * in that file escaped through the bridge, wandered back down into whichever
+ * screen had started the round trip, and was attributed there — putting four
+ * of `/capture`'s navigations on `/capture/review`, leaving `/capture/review`
+ * fed only by itself, and dropping it into the unreached band. It also carried
+ * the Swift guards home: `Thread.isMainThread` printed as a condition on a
+ * JavaScript navigation.
+ *
+ * Stopping here leaves the walk with no callers at all, which is the honest
+ * result — and the file fallback below then answers from the holder's own
+ * file, which is where the push is actually written.
+ */
+function arrivesFromAnotherContext(edge: Edge): boolean {
+  const meta = edge.metadata as Record<string, unknown> | undefined;
+  if (!meta) return false;
+  // The cross-tier synthesizer marks every edge it makes with the tier it
+  // crossed (a client's fetch onto its route, a queue job onto its consumer).
+  if (meta.tier !== undefined) return true;
+  return meta.synthesizedBy === 'rn-event-channel';
+}
+
 /** A component rendered by at least this many screens is chrome, not a screen's own behaviour. */
 const SHARED_CHROME_MIN = 3;
 
@@ -322,7 +357,7 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
       if (fromOrigin && start.path[0]!.node.id !== holder.id) {
         // A collapsed chain: the origin's own name is not "via".
       }
-      const id = `${fromId}${target.id}${viaKey}`;
+      const id = `${fromId}\u0000${target.id}\u0000${viaKey}`;
       const existing = links.get(id);
       if (existing) {
         existing.sites.push(site);
@@ -422,6 +457,7 @@ async function attribute(
     const byTarget = new Map<string, Edge[]>();
     for (const e of incoming) {
       if (e.kind === 'references' && (e.metadata as Record<string, unknown> | undefined)?.fnRef !== true) continue;
+      if (arrivesFromAnotherContext(e)) continue;
       const list = byTarget.get(e.target) ?? [];
       list.push(e);
       byTarget.set(e.target, list);

+ 102 - 8
src/ui-server/api/steps.ts

@@ -123,6 +123,17 @@ export interface WireStep {
    * counting before that site. The viewer lays the row out in it.
    */
   order?: number;
+  /**
+   * For a SCREEN anchor's picture: the region of the screen this step belongs
+   * to — the top-level component (or hook) of the screen's tree the walk first
+   * reached it through, the screen's own component for a call written in the
+   * screen body, and the first-reaching parent's region for everything deeper.
+   * The viewer lays a screen's picture out by these: a screen is a set of
+   * handlers with no order between them, so distance alone put ninety boxes on
+   * one row. Absent for an endpoint's or a function's picture, whose rows
+   * already read in the code's order.
+   */
+  region?: { id: string; label: string };
   /**
    * For a screen or an endpoint: its path and the symbol that serves it — the
    * component a screen renders, the handler an endpoint runs. `endpoint` when
@@ -602,6 +613,43 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
   const fileScopeRefs = new Map<string, Edge[]>();
   const fileScopeUnresolved = new Map<string, UnresolvedReference[]>();
 
+  /**
+   * The spans of the calls that became effect steps, per function — and the
+   * step a binding written inside one arrives from. `Alert.prompt('Add
+   * Folder', …, [{ onPress: (name) => createBackgroundFolder(name) }])` is
+   * two facts: the prompt is a device box, and the prompt's button FIRES the
+   * handler — so the handler's line belongs to the prompt, not to the screen
+   * the prompt is written on, which fires everything and says nothing. A site
+   * is rewired only when its own trigger names the call (`onPress ·
+   * Alert.prompt(…)`) and its position falls inside that call's span in the
+   * same function; the innermost such span wins. An `onSubmit · useFormik(…)`
+   * names no effect and stays where it was.
+   */
+  const firedSpans = new Map<
+    string,
+    Array<{ start: { line: number; column: number }; end: { line: number; column: number }; step: StepRecord }>
+  >();
+  const firedByEffect = (fnId: string, at: { line?: number; column?: number }, of: string): StepRecord | null => {
+    if (at.line === undefined) return null;
+    const spans = firedSpans.get(fnId);
+    if (!spans) return null;
+    const last = (n: string) => n.replace(/\([^()]*\)/g, '').split(/[.:]/).pop() ?? n;
+    const want = last(of);
+    const line = at.line;
+    const column = at.column ?? 0;
+    let best: (typeof spans)[number] | null = null;
+    for (const s of spans) {
+      if (line < s.start.line || line > s.end.line) continue;
+      if (line === s.start.line && column < s.start.column) continue;
+      if (line === s.end.line && column > s.end.column) continue;
+      if (!s.step.effect?.apis.some((api) => last(api) === want)) continue;
+      if (best === null || s.start.line > best.start.line || (s.start.line === best.start.line && s.start.column > best.start.column)) {
+        best = s;
+      }
+    }
+    return best?.step ?? null;
+  };
+
   const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
     const existing = steps.get(node.id);
     if (existing) {
@@ -758,7 +806,12 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       effect.category === 'response'
         ? (responseStatus(text, args, ref.referenceKind) ?? (usable && typeof site.status === 'number' ? site.status : null) ?? implicitResponseStatus(text))
         : null;
-    const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, step.depth + 1, status);
+    // What fires this call, read before its box is made: a call bound inside
+    // ANOTHER effect's arguments — the axios.delete in a confirm dialog's
+    // button — hangs off that box, one step deeper, not off the screen.
+    const fired = trigger ?? (await triggerAt(fold.node, at));
+    const from = fired?.of ? (firedByEffect(fold.node.id, at, fired.of) ?? step) : step;
+    const target = effectStep(fold.node, { referenceName: text, line: ref.line }, effect, from.depth + 1, status);
     if (target === null) return true;
     const guards = await guardsAt(fold.node, at);
     const when = guardLabel(guards);
@@ -775,10 +828,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
       end: site?.span?.end ?? { line: ref.line, column: ref.column ?? 0 },
       within: site?.within ?? null,
     };
+    // This call's own span, for the bindings written inside its arguments.
+    if (usable && site?.span) {
+      const list = firedSpans.get(fold.node.id) ?? [];
+      list.push({ start: { line: local.line, column: local.column }, end: local.end, step: target });
+      firedSpans.set(fold.node.id, list);
+    }
     const hop: HopSite = fold.first ?? local;
     if (!target.first) target.first = hop;
-    const fired = trigger ?? (await triggerAt(fold.node, at));
-    const id = link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within);
+    if (!target.region) target.region = regionOf(from, fold.chain);
+    const id = link(from, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within);
     record(fold.node, local, guards, { step: target.id, link: id }, fired, await loopsAt(fold.node, at));
     return true;
   };
@@ -860,6 +919,23 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
     const t = await consumerTrigger(anchor);
     if (t) first.trigger = t;
   }
+  // A screen's picture is laid out by REGION — the part of the screen each
+  // step belongs to. The evidence is the walk's own: a step reached out of the
+  // anchor descends through the fold's chain, whose FIRST node is the
+  // top-level component (or hook) of the screen's tree; a chain of nothing is
+  // a call written in the screen body itself; and a step reached from any
+  // other step belongs where its first-reaching parent does. First reach wins,
+  // as `first` does — a shared store is one box, in the region that got there
+  // first, and every other region's way in is a link. An endpoint or a
+  // function reads in the code's order and carries none of this.
+  const regions = first.kind === 'screen' && !first.screen?.endpoint;
+  const regionOf = (from: StepRecord, chain: readonly Node[]): WireStep['region'] => {
+    if (!regions) return undefined;
+    if (!from.anchor) return from.region;
+    const head = chain[0];
+    if (head) return { id: head.id, label: head.name };
+    return { id: from.root?.id ?? from.id, label: from.root?.name ?? from.label };
+  };
   const queue: StepRecord[] = [first];
   /** Steps whose exploration has been queued — each is explored once, from the first row it appears on. */
   const explored = new Set<string>([first.id]);
@@ -1159,14 +1235,19 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
 
         for (const a of arrivals) {
           if (a.kind === null) continue;
+          const at = { line: a.e.line, column: a.e.column };
+          // A binding written inside an effect call's arguments — the dialog's
+          // `onPress` — arrives from that box, not from the step that owns
+          // the fold: the prompt fires it.
+          const firedBy = a.trigger?.of ? firedByEffect(fold.node.id, at, a.trigger.of) : null;
+          const from = firedBy ?? step;
           const fresh = !steps.has(a.target.id);
-          const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
+          const to = stepFor(a.target, a.kind, from.depth + 1, a.extra);
           if (to === null) continue;
           if (fresh && !to.trigger) {
             const t = a.target.kind === 'route' ? await requestTrigger(a.target, to.root) : await consumerTrigger(a.target);
             if (t) to.trigger = t;
           }
-          const at = { line: a.e.line, column: a.e.column };
           const guards = await guardsAt(fold.node, at);
           const when = guardLabel(guards);
           // A call-shaped hop says what it passes; a navigation already says
@@ -1178,14 +1259,26 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           if (typeof a.meta.channel === 'string' && a.meta.channel !== 'server-action') {
             const written = await callAt(fold.node, at);
             site = written && written.callee ? { ...a.site, text: written.callee, args: written.args } : await withArgs(a.site, fold.node, at);
-          } else if (a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls') site = await withArgs(a.site, fold.node, at);
+          } else if (
+            a.linkKind === 'bridge' ||
+            a.linkKind === 'store' ||
+            a.linkKind === 'calls' ||
+            // A handler BOUND passes nothing (`onPress={handleX}`), but a
+            // handler CALLED from under a binding is a call like any other —
+            // and `tryCatchSync(onClosePress)`'s argument is the whole answer
+            // to what a wrapper wraps.
+            (a.linkKind === 'handler' && (a.e.kind === 'calls' || a.e.kind === 'instantiates'))
+          ) {
+            site = await withArgs(a.site, fold.node, at);
+          }
           // Where this step is first reached from: the hop out of the root
           // this fold descends from, else this site — its position orders the row.
           const isCallHop = a.e.kind === 'calls' || a.e.kind === 'instantiates' || a.e.kind === 'navigates';
           const local = isCallHop ? await hopAt(fold.node, at, a.target.name) : pointHop(fold.node, at);
           const hop = fold.first ?? local;
           if (!to.first) to.first = hop;
-          const id = link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
+          if (!to.region) to.region = regionOf(from, fold.chain);
+          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)) {
             explored.add(to.id);
@@ -1220,10 +1313,11 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
           if (known) {
             if (known.id !== step.id) {
               const at = { line: e.line, column: e.column };
+              const firedBy = a.trigger?.of ? firedByEffect(fold.node.id, at, a.trigger.of) : null;
               const guards = await guardsAt(fold.node, at);
               const local = await hopAt(fold.node, at, target.name);
               const hop = fold.first ?? local;
-              const id = link(step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
+              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);
               record(fold.node, local, guards, { step: known.id, link: id }, a.trigger, await loopsAt(fold.node, at));
             }
             continue;

BIN
telemetry-dashboard/src/auth.ts


+ 29 - 0
ui/src/components/steps/RegionCaption.svelte

@@ -0,0 +1,29 @@
+<script lang="ts">
+  /**
+   * The caption over one region of a screen's picture — the component that
+   * owns the boxes under it, set small in the gap above them, with a hairline
+   * spanning the region's width so the grouping reads from any zoom. Text
+   * only: it takes no pointer, so hovering a line through it still works.
+   */
+  import type { NodeProps } from '@xyflow/svelte';
+
+  let { data }: NodeProps = $props();
+  const caption = $derived(data as unknown as { label: string; width: number });
+</script>
+
+<div class="rcap" style={`width:${caption.width}px`}>{caption.label}</div>
+
+<style>
+  .rcap {
+    box-sizing: border-box;
+    padding-bottom: 4px;
+    border-bottom: 1px solid var(--rule-soft);
+    font: 500 11px/16px var(--mono);
+    color: var(--ink-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    pointer-events: none;
+    user-select: none;
+  }
+</style>

+ 33 - 2
ui/src/components/steps/StepsKey.svelte

@@ -15,12 +15,14 @@
     project: ProjectKind;
     /** The reading on screen: the rail's rows, or the canvas's. */
     order: boolean;
+    /** A screen's picture, laid out by region rather than by distance. */
+    regions?: boolean;
     /** In the flow of a scrolling rail rather than floating over a canvas. */
     flow: boolean;
     open: boolean;
     onToggle: (open: boolean) => void;
   }
-  let { project, order, flow, open, onToggle }: Props = $props();
+  let { project, order, regions = false, flow, open, onToggle }: Props = $props();
 </script>
 
 <div class="legend" class:open class:flow>
@@ -31,8 +33,31 @@
     <div class="legend-body">
       <div class="lrow">
         <span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
-        <span>{order ? 'Where the picture starts; below it, what it does in the code’s own order' : 'Where the picture starts; each row down is one more step away'}</span>
+        <span
+          >{order
+            ? 'Where the picture starts; below it, what it does in the code’s own order'
+            : regions
+              ? 'Where the picture starts; below it, the parts of the ' + kindWord('screen', project) + ', each with what happens there'
+              : 'Where the picture starts; each row down is one more step away'}</span
+        >
       </div>
+      {#if regions && !order}
+        <div class="lrow">
+          <span class="k-cap mono">Name</span>
+          <span
+            >A region — the component that owns the boxes under its rule, a box above what it sets in motion. One line from
+            the start into each region stands in for the {kindWord('screen', project)}'s whole fan-out; every other line
+            draws where it leads</span
+          >
+        </div>
+        <div class="lrow">
+          <span class="k-label">no line</span>
+          <span
+            >A box nothing points at is the region's own doing — run on render or mount, or from a binding written inline.
+            Select it and its line from the {kindWord('screen', project)} lights, with what fires it</span
+          >
+        </div>
+      {/if}
       {#if project === 'api'}
         <div class="lrow">
           <span class="k-box mono">POST /x</span>
@@ -188,6 +213,12 @@
   font-size: 10.5px;
   color: var(--ink-3);
 }
+.k-cap {
+  font-size: 10.5px;
+  color: var(--ink-3);
+  border-bottom: 1px solid var(--rule-soft);
+  padding-bottom: 2px;
+}
 .k-box {
   box-sizing: border-box;
   padding: 1px 5px;

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

@@ -269,7 +269,11 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
       minWeight: 0,
       sizing: (m) => {
         const info = nodes.get(m.id);
-        return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
+        // Size for the ` …` a cut step wears and the anchor's ● mark, or the
+        // CSS ellipsis eats the name's tail.
+        const cut = info?.step.cut != null ? ' …' : '';
+        const mark = info?.step.anchor ? '● ' : '';
+        return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
       },
       layering,
       order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
@@ -281,7 +285,8 @@ 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));
-  return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
+  // 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 };
 }
 
 /**

+ 427 - 27
ui/src/lib/steps-model.ts

@@ -13,7 +13,21 @@
  */
 
 import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
-import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
+import {
+  buildMapLayout,
+  linkId,
+  nodeWidth,
+  strokeWidthFor,
+  NODE_GAP,
+  NODE_HEIGHT,
+  PADDING,
+  PORT_PITCH,
+  type EdgeRoute,
+  type MapEdgeLayout,
+  type MapLayout,
+  type MapNodeLayout,
+  type PortRef,
+} from './map-model';
 import {
   edgeLabel,
   samplePolyline,
@@ -56,6 +70,26 @@ export interface StepsModel extends Picture {
   polylines: Map<string, Point[]>;
   /** Steps per kind, for the panel's summary. */
   counts: Record<WireStep['kind'], number>;
+  /**
+   * A screen's picture only: the regions its boxes are laid out by, for the
+   * captions. Null when the steps carry no regions — an endpoint's or a
+   * function's picture, and the order reading — and the rows are distance.
+   */
+  regions: StepRegionZone[] | null;
+  /** The first box of each region — where the anchor's at-rest line arrives. */
+  regionEntries: ReadonlySet<string> | null;
+}
+
+/** One region of a screen's picture: its caption, and the space its boxes hold. */
+export interface StepRegionZone {
+  id: string;
+  label: string;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  /** The region's first box, in the walk's order. */
+  entry: string;
 }
 
 /** Points a curve is sampled at for hit-testing (as the Screens view's). */
@@ -243,34 +277,400 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
     });
   }
 
-  // Layer = distance from the anchor, counted by the server. Layer 0 is the
-  // bottom, so the deepest row is 0 and the anchor is on top.
-  const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
-  const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
-  const layering = (ids: string[]): Map<string, number> =>
-    new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
-
-  const layout = buildMapLayout(
-    { modules, links },
-    {
-      includeTests: true,
-      minWeight: 0,
-      sizing: (m) => {
-        const info = nodes.get(m.id);
-        return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
-      },
-      layering,
-      // The server ordered each row the way the code reads; keep it.
-      order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
-      layerGap: SCREEN_LAYER_GAP,
-      portPitch: PORT_PITCH,
-      ports: 'directional',
-    }
-  );
-  const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
+  // A screen's picture is laid out by its REGIONS when the server named them
+  // (`WireStep.region`): a screen is a set of handlers with no order between
+  // them, so distance alone put ninety boxes on one enormous row. An
+  // endpoint's or a function's picture keeps the rows: there, distance IS the
+  // reading.
+  const regioned = payload.steps.some((s) => s.region !== undefined);
+  let layout: MapLayout;
+  let zones: StepRegionZone[] | null = null;
+  if (regioned) {
+    const packed = packRegions(payload.steps, nodes, modules, links);
+    layout = packed.layout;
+    zones = packed.zones;
+  } else {
+    // Layer = distance from the anchor, counted by the server. Layer 0 is the
+    // bottom, so the deepest row is 0 and the anchor is on top.
+    const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
+    const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
+    const layering = (ids: string[]): Map<string, number> =>
+      new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
+
+    layout = buildMapLayout(
+      { modules, links },
+      {
+        includeTests: true,
+        minWeight: 0,
+        sizing: (m) => {
+          const info = nodes.get(m.id);
+          // Size for the ` …` a cut step wears and the anchor's ● mark, as `packRegions` does.
+          const cut = info?.step.cut != null ? ' …' : '';
+          const mark = info?.step.anchor ? '● ' : '';
+          return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
+        },
+        layering,
+        // The server ordered each row the way the code reads; keep it.
+        order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
+        layerGap: SCREEN_LAYER_GAP,
+        portPitch: PORT_PITCH,
+        ports: 'directional',
+      }
+    );
+  }
+  const layerGap = regioned ? REGION_GAP_Y : SCREEN_LAYER_GAP;
+  const curves = trackedCurves(layout, layerGap);
   const polylines = new Map<string, Point[]>();
   for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
-  return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
+  return {
+    layout,
+    nodes,
+    edges,
+    layerGap,
+    curves,
+    polylines,
+    counts,
+    regions: zones,
+    regionEntries: zones === null ? null : new Set(zones.map((z) => z.entry)),
+  };
+}
+
+/* --------------------------------------------------------------- regions -- */
+
+/**
+ * The gap under a line of boxes within a region — tighter than the row gap of
+ * an unregioned picture, whose gaps carry every line of a whole row's fan-out;
+ * here a gap holds a few local hops, and a screen's picture is tall enough
+ * already. The tracked curves take the same number, so a level arch stays
+ * inside it.
+ */
+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;
+/** 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. */
+const BAND_GAP = 84;
+/** Bands may run this wide: enough for the widest region, aiming at a readable aspect. */
+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 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
+ * 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.
+ */
+function packRegions(
+  steps: WireStep[],
+  infos: Map<string, StepNodeInfo>,
+  modules: WireMapModule[],
+  links: WireMapLink[]
+): { layout: MapLayout; zones: StepRegionZone[] } {
+  const anchor = steps.find((s) => s.anchor)!;
+  const members = steps.filter((s) => !s.anchor);
+  const moduleOf = new Map(modules.map((m) => [m.id, m]));
+
+  // A box is wide enough for its words and for its ports — the anchor touches
+  // most of the picture, and its lines need somewhere to leave from.
+  const degree = new Map<string, number>();
+  for (const l of links) {
+    degree.set(l.source, (degree.get(l.source) ?? 0) + 1);
+    degree.set(l.target, (degree.get(l.target) ?? 0) + 1);
+  }
+  const widthOf = (id: string): number => {
+    const info = infos.get(id);
+    // A cut step wears ` …` after its name and the anchor its ● mark before
+    // it; size for both, or the CSS ellipsis eats the name's tail instead
+    // (`/scan-to-verif…` for `/scan-to-verify …`).
+    const cut = info?.step.cut != null ? ' …' : '';
+    const mark = info?.step.anchor ? '● ' : '';
+    return Math.max(
+      nodeWidth(mark + (info?.label ?? id) + cut, info?.sub ?? ''),
+      ((degree.get(id) ?? 0) + 1) * PORT_PITCH
+    );
+  };
+
+  // Regions in the order the walk met them — the screen's own source order.
+  interface Region {
+    id: string;
+    label: string;
+    members: WireStep[];
+  }
+  const regions = new Map<string, Region>();
+  for (const s of members) {
+    const id = s.region?.id ?? anchor.id;
+    const region = regions.get(id) ?? { id, label: s.region?.label ?? anchor.label, members: [] };
+    region.members.push(s);
+    regions.set(id, region);
+  }
+
+  // Within a region, a step goes under the steps that lead to it.
+  const regionOf = new Map(members.map((s) => [s.id, s.region?.id ?? anchor.id]));
+  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;
+    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)
+      );
+      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);
+      }
+    }
+    packed.set(region.id, { lines, width });
+  }
+
+  // Tile the regions into bands under a width budget.
+  interface Band {
+    regions: Region[];
+    lines: number;
+    width: number;
+  }
+  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);
+    }
+    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) {
+      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;
+    }
+    y += b.lines * REGION_PITCH + BAND_GAP;
+    globalLine += b.lines;
+  }
+  const height = y - REGION_PITCH - BAND_GAP + NODE_HEIGHT + PADDING;
+
+  // 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.
+  const layerOf = (id: string): number =>
+    id === anchor.id ? globalLine + 1 : globalLine - (at.get(id)?.line ?? 0);
+
+  const nodesById = new Map<string, MapNodeLayout>();
+  const place = (id: string, x: number, yy: number): void => {
+    nodesById.set(id, {
+      id,
+      module: moduleOf.get(id)!,
+      island: false,
+      generated: false,
+      layer: layerOf(id),
+      x,
+      y: yy,
+      width: widthOf(id),
+      height: NODE_HEIGHT,
+      sourceHandles: [],
+      targetHandles: [],
+      ports: { top: [], bottom: [] },
+    });
+  };
+  place(anchor.id, PADDING + (contentWidth - widthOf(anchor.id)) / 2, anchorY);
+  for (const [id, p] of at) place(id, p.x, p.y);
+
+  // Edges and ports, exactly as the Map lays them: the route from the layers,
+  // the sides from the route, the ports spread in the order the other end
+  // appears left to right.
+  const edges: MapEdgeLayout[] = [];
+  interface SidePort extends PortRef {
+    other: number;
+  }
+  const sidePorts = new Map<string, { top: SidePort[]; bottom: SidePort[] }>();
+  const centreOf = (id: string): number => {
+    const n = nodesById.get(id);
+    return n ? n.x + n.width / 2 : 0;
+  };
+  for (const link of links) {
+    const from = nodesById.get(link.source);
+    const to = nodesById.get(link.target);
+    if (!from || !to) continue;
+    const id = linkId(link);
+    const route: EdgeRoute = from.layer > to.layer ? 'down' : from.layer < to.layer ? 'up' : 'level';
+    edges.push({
+      id,
+      source: link.source,
+      target: link.target,
+      sourceHandle: `s:${id}`,
+      targetHandle: `t:${id}`,
+      link,
+      width: strokeWidthFor(link.count),
+      back: from.layer <= to.layer,
+      thin: false,
+      route,
+    });
+    const sides =
+      route === 'down'
+        ? { source: 'bottom' as const, target: 'top' as const }
+        : route === 'up'
+          ? { source: 'top' as const, target: 'bottom' as const }
+          : { source: 'top' as const, target: 'top' as const };
+    const bySide = (node: string): { top: SidePort[]; bottom: SidePort[] } => {
+      const found = sidePorts.get(node) ?? { top: [], bottom: [] };
+      sidePorts.set(node, found);
+      return found;
+    };
+    bySide(link.source)[sides.source].push({ id, type: 'source', other: centreOf(link.target) });
+    bySide(link.target)[sides.target].push({ id, type: 'target', other: centreOf(link.source) });
+  }
+  const byOther = (a: SidePort, b: SidePort): number => a.other - b.other || a.id.localeCompare(b.id);
+  for (const [id, sides] of sidePorts) {
+    const node = nodesById.get(id);
+    if (!node) continue;
+    sides.top.sort(byOther);
+    sides.bottom.sort(byOther);
+    node.ports = {
+      top: sides.top.map((p) => ({ id: p.id, type: p.type })),
+      bottom: sides.bottom.map((p) => ({ id: p.id, type: p.type })),
+    };
+    node.sourceHandles = sides.bottom.filter((p) => p.type === 'source').map((p) => p.id);
+    node.targetHandles = sides.top.filter((p) => p.type === 'target').map((p) => p.id);
+  }
+
+  const layout: MapLayout = {
+    nodes: [...nodesById.values()],
+    edges,
+    layers: [],
+    width: contentWidth + PADDING * 2,
+    height,
+    basis: { kind: 'all', declaredLinks: links.length, totalLinks: links.length },
+    minWeight: 0,
+    hiddenLinks: 0,
+    mutual: [],
+    moduleCycles: [],
+  };
+  return { layout, zones };
+}
+
+/**
+ * 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.
+ */
+export function stepEdgeVisible(model: StepsModel, edge: MapEdgeLayout, selected: string | null): boolean {
+  if (selected !== null) return edge.source === selected || edge.target === selected;
+  if (edge.thin || edge.back) 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;
+  return true;
 }
 
 /** The side panel's two lists for a selected step. */

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

@@ -762,6 +762,13 @@ export interface WireStep {
   trigger?: WireStepTrigger;
   /** The step's place in its row, in the code's order (a hop written inside another site's arguments before that site). */
   order?: number;
+  /**
+   * A screen anchor's picture only: the region of the screen this step belongs
+   * to — the top-level component (or hook) the walk first reached it through,
+   * the screen's own component for the screen body. The viewer lays a screen's
+   * picture out by these; absent, the rows are distance.
+   */
+  region?: { id: string; label: string };
   /**
    * For a screen or an endpoint — also a `bridge` step that is an endpoint
    * reached across a tier: its path and the symbol that serves it.

+ 24 - 7
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 RegionCaption from '../components/steps/RegionCaption.svelte';
   import StepsKey from '../components/steps/StepsKey.svelte';
   import ScreenEdge from '../components/screens/ScreenEdge.svelte';
   import KindGlyph from '../components/KindGlyph.svelte';
@@ -32,13 +33,14 @@
   } from '../lib/api';
   import { live } from '../lib/live.svelte';
   import { fileHref, flowHref, navigate, stepsHref, symbolHref } from '../lib/navigation';
-  import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
+  import type { MapEdgeLayout } from '../lib/map-model';
   import { hoverPill, nearestEdge, placeLabels } from '../lib/screens-model';
   import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
   import {
     buildStepsModel,
     kindWord,
     kindWords,
+    stepEdgeVisible,
     stepNeighbourhood,
     stepPairId,
     stepViaText,
@@ -118,14 +120,17 @@
   /**
    * The fit. A picture of a few boxes is centred — and the key, bottom left,
    * would sit on its second row; it is fitted to the right of the key instead.
-   * A picture of many boxes is fitted to the whole stage, as the Screens view's.
+   * A picture of many boxes is fitted to the whole stage, as the Screens view's
+   * — and a picture laid out by region may fit far out: the regions and their
+   * captions are the overview, and the reader zooms into one, where a 0.4
+   * floor on a big screen's picture opened on a window torn out of its middle.
    */
   const fitOptions = $derived(
     model !== null && model.layout.nodes.length <= 24 && legendOpen
       ? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
-      : { padding: 0.1, maxZoom: 1, minZoom: 0.4 }
+      : { padding: 0.1, maxZoom: 1, minZoom: model !== null && model.regions !== null ? 0.2 : 0.4 }
   );
-  const nodeTypes = { step: StepNode };
+  const nodeTypes = { step: StepNode, region: RegionCaption };
 
   /** Two clicks on one box closer than this are a double-click. */
   const DOUBLE_CLICK_MS = 400;
@@ -232,7 +237,18 @@
 
   const nodes = $derived.by<Node[]>(() => {
     if (model === null) return [];
-    return model.layout.nodes.map((node) => ({
+    // A screen's picture carries a caption over each region — text in the gap
+    // above the region's first line, taking no pointer.
+    const captions: Node[] = (model.regions ?? []).map((zone) => ({
+      id: `region:${zone.id}`,
+      type: 'region',
+      position: { x: zone.x, y: zone.y - 32 },
+      draggable: false,
+      selectable: false,
+      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 },
@@ -264,14 +280,14 @@
         // 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) => isEdgeVisible(edge, selected))
+      .filter((edge) => stepEdgeVisible(model, edge, selected))
       .map((edge) => {
         const touches = selected !== null && (edge.source === selected || edge.target === selected);
         const isFocus = focus === edge.id;
@@ -537,6 +553,7 @@
       <StepsKey
         project={payload.project}
         order={readAs === 'order'}
+        regions={model.regions !== null}
         flow={false}
         open={legendOpen}
         onToggle={(next) => (legendOpen = next)}