Ver código fonte

feat(expo-router): add Expo Router support for Screens and navigations

Introduce Expo Router integration by adding a new Screens view and API to surface screens and their transitions. Implement a map-based layout with directional ports, extended layering and port pitch to accommodate edge labels, and a pill-based labeling system for transition conditions. Include tests for the new map/screens models, updates to the UI components, and design/docs changes reflecting the Screens design. Merge CodeGraph UI viewer changes to render and interact with Expo Router-based screen graphs. This enables CodeGraph UI to surface screens and navigations from Expo Router apps.
Colby McHenry 1 semana atrás
pai
commit
f0eafe31f9

+ 1 - 1
CHANGELOG.md

@@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
-- **A Screens tab in `codegraph ui` — the app the way its user meets it.** One box per screen, an arrow for every way of getting from one to another, and on each arrow the condition under which it happens: `/home → /object-detail when !isUploading && isCollected`. Hover an arrow for the chain the tap travels through (`HomeSearchResults → ItemCard → openObjectDetail`), click a screen for everything that opens it and everything it opens, with a link to each navigation call. Shared chrome (a top bar rendered on ten screens) collapses to one node rather than the same arrows from every box; a helper that chooses the destination after login shows its fork. Projects whose graph holds screen navigation land on this tab. Expo Router apps today.
+- **A Screens tab in `codegraph ui` — the app the way its user meets it.** One box per screen, an arrow for every way of getting from one to another, and on each arrow the condition under which it happens. Click a screen and each of its transitions is labelled beside the screen at the other end of the line with the last condition checked before it happens — `→ isCollected` above `/object-detail` — laid out so that no two labels overlap and none sits under a line; hover a label, a line, or its row in the side panel for the whole condition and the chain the tap travels through (`HomeSearchResults → ItemCard → openObjectDetail`), with a link to each navigation call. A screen that returns to where it came from is drawn around the boxes rather than through them, shared chrome (a top bar rendered on ten screens) is one node a row above what it opens rather than the same arrows from every box, a screen that opens many others is wide enough to follow each line back to it and its lines take separate paths through the gap so they fan out instead of stacking, hovering picks the line nearest the pointer, and a helper that chooses the destination after login shows its fork. Projects whose graph holds screen navigation land on this tab. Expo Router apps today.
 
 - **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root.
 

+ 74 - 0
__tests__/ui-map-model.test.ts

@@ -24,6 +24,7 @@ import {
   linkId,
   moduleMetaLabel,
   nodeWidth,
+  portPoint,
   strokeWidthFor,
   LAYER_GAP,
   MIN_WEIGHT,
@@ -399,3 +400,76 @@ describe('empty and degenerate inputs', () => {
     expect(layout.layers[0]!.label).toBeNull();
   });
 });
+
+describe('directional ports and room', () => {
+  const modules = [mod('src/a'), mod('src/b')];
+
+  it('draws the Map exactly as before: bottoms leave, tops arrive, every link runs down', () => {
+    const layout = buildMapLayout(
+      { modules: [mod('src/bin'), mod('src/core'), mod('src/db')], links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
+      OPTS
+    );
+    for (const node of layout.nodes) {
+      expect(node.ports.bottom.map((p) => p.id)).toEqual(node.sourceHandles);
+      expect(node.ports.top.map((p) => p.id)).toEqual(node.targetHandles);
+      expect(node.ports.bottom.every((p) => p.type === 'source')).toBe(true);
+      expect(node.ports.top.every((p) => p.type === 'target')).toBe(true);
+    }
+    expect(layout.edges.every((e) => e.route === 'down')).toBe(true);
+  });
+
+  it('keeps a back-edge bottom-to-top under layered ports, and top-to-bottom under directional ones', () => {
+    const payload = { modules, links: [link('src/a', 'src/b', 10), link('src/b', 'src/a', 2)] };
+    const layered = buildMapLayout(payload, OPTS);
+    const directional = buildMapLayout(payload, { ...OPTS, ports: 'directional' });
+    for (const layout of [layered, directional]) {
+      const back = layout.edges.find((e) => e.source === 'src/b')!;
+      expect(back.route).toBe('up');
+      expect(back.back).toBe(true);
+    }
+    const a = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/a')!;
+    const b = (l: MapLayout) => l.nodes.find((n) => n.id === 'src/b')!;
+    const id = linkId({ source: 'src/b', target: 'src/a' });
+    // The Map: leaves b's bottom, arrives at a's top — through both boxes, as it always has.
+    expect(portPoint(b(layered), id, 'source').y).toBe(b(layered).y + NODE_HEIGHT);
+    expect(portPoint(a(layered), id, 'target').y).toBe(a(layered).y);
+    // Directional: leaves b's top, arrives at a's bottom — around them.
+    expect(portPoint(b(directional), id, 'source').y).toBe(b(directional).y);
+    expect(portPoint(a(directional), id, 'target').y).toBe(a(directional).y + NODE_HEIGHT);
+  });
+
+  it('joins two modules on one layer over the top, under directional ports', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/a', 'src/b', 10)] },
+      { ...OPTS, ports: 'directional', layering: (ids) => new Map(ids.map((id) => [id, 0])) }
+    );
+    const edge = layout.edges[0]!;
+    expect(edge.route).toBe('level');
+    const a = layout.nodes.find((n) => n.id === 'src/a')!;
+    const b = layout.nodes.find((n) => n.id === 'src/b')!;
+    expect(portPoint(a, edge.id, 'source').y).toBe(a.y);
+    expect(portPoint(b, edge.id, 'target').y).toBe(b.y);
+  });
+
+  it('widens a box to keep its ports apart, and only then', () => {
+    const leaves = Array.from({ length: 20 }, (_, i) => mod(`src/leaf${i}`));
+    const payload = { modules: [mod('src/hub'), ...leaves], links: leaves.map((l) => link('src/hub', l.id, 5)) };
+    const plain = buildMapLayout(payload, OPTS).nodes.find((n) => n.id === 'src/hub')!;
+    const pitched = buildMapLayout(payload, { ...OPTS, portPitch: 12 }).nodes.find((n) => n.id === 'src/hub')!;
+    // Nothing depends on the hub, so its second line is the island's.
+    expect(plain.width).toBe(nodeWidth('src/hub', moduleMetaLabel(mod('src/hub'), true)));
+    expect(pitched.width).toBe(Math.max(plain.width, 21 * 12));
+    expect(pitched.width).toBeGreaterThan(plain.width);
+  });
+
+  it('spaces layers by the gap a view asks for', () => {
+    const payload = { modules, links: [link('src/a', 'src/b', 10)] };
+    const wide = buildMapLayout(payload, { ...OPTS, layerGap: 116 });
+    const a = wide.nodes.find((n) => n.id === 'src/a')!;
+    const b = wide.nodes.find((n) => n.id === 'src/b')!;
+    expect(b.y - a.y).toBe(NODE_HEIGHT + 116);
+    expect(wide.height).toBe(buildMapLayout(payload, OPTS).height + (116 - LAYER_GAP));
+    // Layer 0 is the bottom, so it has the larger y.
+    expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116);
+  });
+});

+ 659 - 0
__tests__/ui-screens-model.test.ts

@@ -0,0 +1,659 @@
+/**
+ * The Screens view's model, without a browser.
+ *
+ * What is under test is what makes the picture readable when a hub is
+ * selected — the case the view exists for, and the case that first shipped as
+ * a pile of pills under a knot of lines:
+ *
+ * - a label says the clause that decides the transition, not the first thirty
+ *   characters of a chain two siblings share;
+ * - a screen's row is its distance from the entry, and shared chrome hangs
+ *   where what it opens is, never dragging a screen up beside the home screen;
+ * - a return trip leaves the top of its box and arrives at the bottom of the
+ *   other, so it is drawn around the boxes rather than through them;
+ * - every pill sits at the far end of its line, in a lane, and no two overlap;
+ *   one that fits nowhere is counted rather than drawn on top of something.
+ *
+ * The endpoint that feeds it is exercised against a real index in
+ * `expo-router.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildScreensModel,
+  clauses,
+  edgeLabel,
+  hoverPill,
+  laneCount,
+  nearestEdge,
+  pairId,
+  pillText,
+  pillWidth,
+  placeLabels,
+  pointAt,
+  screenCurve,
+  screenEdgePath,
+  tAtY,
+  EDGE_LABEL_MAX,
+  PILL_HEIGHT,
+  SCREEN_LAYER_GAP,
+  type Curve,
+  type PillPlacement,
+  type Point,
+  type ScreensModel,
+} from '../ui/src/lib/screens-model';
+import { linkId, portPoint, NODE_HEIGHT, PORT_PITCH, type MapNodeLayout } from '../ui/src/lib/map-model';
+import type {
+  WireNodeRef,
+  WireScreen,
+  WireScreenLink,
+  WireScreenOrigin,
+  WireScreensPayload,
+} from '../ui/src/lib/wire';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function ref(name: string): WireNodeRef {
+  return {
+    id: `function:${name}`,
+    kind: 'function',
+    name,
+    qualifiedName: name,
+    file: `src/${name}.tsx`,
+    line: 1,
+    endLine: 20,
+    language: 'tsx',
+    test: false,
+  };
+}
+
+const R = (path: string): string => `route:${path}`;
+
+function screen(path: string): WireScreen {
+  return {
+    id: R(path),
+    path,
+    file: `src/app${path === '/' ? '/index' : path}.tsx`,
+    line: 1,
+    component: ref(path === '/' ? 'Index' : path.replace(/[^a-z0-9]/gi, '')),
+    incoming: 0,
+    outgoing: 0,
+  };
+}
+
+function origin(name: string, sharedBy?: number): WireScreenOrigin {
+  return { id: `function:${name}`, node: ref(name), outgoing: 1, ...(sharedBy ? { sharedBy } : {}) };
+}
+
+let seq = 0;
+/** `from`/`to` are screen paths, or a `function:` id for an origin. */
+function link(from: string, to: string, when = '', over: Partial<WireScreenLink> = {}): WireScreenLink {
+  const id = (s: string): string => (s.startsWith('function:') ? s : R(s));
+  return {
+    id: `l${seq++}`,
+    from: id(from),
+    to: id(to),
+    fromOrigin: from.startsWith('function:'),
+    via: [],
+    when,
+    sites: [],
+    synthesized: false,
+    ...over,
+  };
+}
+
+function payload(
+  screens: WireScreen[],
+  links: WireScreenLink[],
+  origins: WireScreenOrigin[] = [],
+  entry: string | null = R('/')
+): WireScreensPayload {
+  return {
+    routed: true,
+    entry,
+    screens,
+    origins,
+    links,
+    dropped: 0,
+    index: { lastIndexedAt: null, edges: 0, files: 0 },
+    timing: { elapsedMs: 0 },
+  };
+}
+
+function nodeOf(model: ScreensModel, id: string): MapNodeLayout {
+  const node = model.layout.nodes.find((n) => n.id === id);
+  expect(node, `no node ${id}`).toBeTruthy();
+  return node!;
+}
+
+function layerOf(model: ScreensModel, id: string): number {
+  return nodeOf(model, id).layer;
+}
+
+function edgeOf(model: ScreensModel, from: string, to: string) {
+  const id = linkId({ source: from, target: to });
+  const edge = model.layout.edges.find((e) => e.id === id);
+  expect(edge, `no edge ${from} -> ${to}`).toBeTruthy();
+  return edge!;
+}
+
+interface Rect {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+}
+const rectOf = (p: PillPlacement): Rect => ({ x: p.x - p.width / 2, y: p.y - PILL_HEIGHT / 2, w: p.width, h: PILL_HEIGHT });
+const boxOf = (n: MapNodeLayout): Rect => ({ x: n.x, y: n.y, w: n.width, h: n.height });
+const overlaps = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
+
+/** A home screen that opens twelve screens, six of which come back. */
+function hub(): WireScreensPayload {
+  const targets = Array.from({ length: 12 }, (_, i) => `/t${i}`);
+  return payload(
+    [screen('/'), screen('/home'), ...targets.map(screen)],
+    [
+      link('/', '/home'),
+      ...targets.map((t, i) => link('/home', t, `ready && step === ${i}`)),
+      ...targets.slice(0, 6).map((t, i) => link(t, '/home', `done${i}`)),
+    ]
+  );
+}
+
+/* ---------------------------------------------------------------- specs -- */
+
+describe('clauses', () => {
+  it('splits on the top-level && only', () => {
+    expect(clauses('a && (b && c) && d')).toEqual(['a', '(b && c)', 'd']);
+    expect(clauses('!(a || b) && items[i && j]')).toEqual(['!(a || b)', 'items[i && j]']);
+  });
+
+  it('leaves a string alone', () => {
+    expect(clauses("x === 'a && b' && y")).toEqual(["x === 'a && b'", 'y']);
+    expect(clauses('t === `${a && b}` && z')).toEqual(['t === `${a && b}`', 'z']);
+  });
+
+  it('returns a disjunction whole — it has no innermost term', () => {
+    expect(clauses('a && b || c')).toEqual(['a && b || c']);
+  });
+
+  it('handles the edges', () => {
+    expect(clauses('')).toEqual([]);
+    expect(clauses('visible')).toEqual(['visible']);
+  });
+});
+
+describe('edgeLabel', () => {
+  const chain = 'uncollected && !(selectedDetectionItems.length > 0) && canProceed && ';
+
+  it('says the innermost clause, with an ellipsis for what came before', () => {
+    const collect = edgeLabel([link('/home', '/capture/collect', `${chain}guide.dontShowAgain.captureGuide`)]);
+    const intro = edgeLabel([link('/home', '/guide', `${chain}!guide.dontShowAgain.captureGuide`)]);
+    expect(collect).toBe('…guide.dontShowAgain.captureGuide');
+    expect(intro).toBe('…!guide.dontShowAgain.captureGuide');
+    // The whole point: two arms of a fork no longer read the same.
+    expect(collect).not.toBe(intro);
+  });
+
+  it('prints a single clause without an ellipsis, and nothing when unconditional', () => {
+    expect(edgeLabel([link('/home', '/queue', 'visible')])).toBe('visible');
+    expect(edgeLabel([link('/home', '/queue')])).toBe('');
+  });
+
+  it('cuts an innermost clause that is itself too long, saying so at the end', () => {
+    const label = edgeLabel([link('/a', '/b', 'x && ' + 'y'.repeat(60))]);
+    expect(label.length).toBe(EDGE_LABEL_MAX);
+    expect(label.startsWith('…')).toBe(true);
+    expect(label.endsWith('…')).toBe(true);
+  });
+
+  it('counts several transitions between one pair', () => {
+    expect(edgeLabel([link('/a', '/b', 'x'), link('/a', '/b')])).toBe('2 ways · 1 conditional');
+    expect(edgeLabel([link('/a', '/b'), link('/a', '/b')])).toBe('2 ways');
+  });
+});
+
+describe('layering by distance from the entry', () => {
+  it('hangs shared chrome one row above the shallowest screen it opens', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/home'), screen('/soak-test')],
+        [link('/', '/home'), link('/home', '/soak-test'), link('function:TopBar', '/soak-test')],
+        [origin('TopBar', 10)]
+      )
+    );
+    // Higher layer = higher on the picture.
+    expect(layerOf(model, R('/'))).toBe(layerOf(model, R('/home')) + 1);
+    expect(layerOf(model, R('/soak-test'))).toBe(layerOf(model, R('/home')) - 1);
+    // The top bar sits beside /home, not beside the entry — so what it opens
+    // is below it AND below the screen the user actually opened it from.
+    expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/home')));
+    expect(edgeOf(model, 'function:TopBar', R('/soak-test')).route).toBe('down');
+  });
+
+  it('never lets chrome pull a screen up the picture', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a'), screen('/b'), screen('/c')],
+        [link('/', '/a'), link('/a', '/b'), link('/b', '/c'), link('function:TopBar', '/c')],
+        [origin('TopBar', 4)]
+      )
+    );
+    expect(layerOf(model, R('/c'))).toBe(layerOf(model, R('/')) - 3);
+    expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/b')));
+  });
+
+  it('seeds what only an origin opens, from the top', () => {
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/detail')], [link('function:openDetail', '/detail')], [origin('openDetail')])
+    );
+    expect(layerOf(model, 'function:openDetail')).toBe(layerOf(model, R('/')));
+    expect(layerOf(model, R('/detail'))).toBe(layerOf(model, R('/')) - 1);
+    // Reached through chrome is reached.
+    expect(model.unreached).toBe(0);
+  });
+
+  it('measures distance over every transition, not the two-cycle-broken set', () => {
+    // Three returns against one arrival: the Map's break would keep /a -> /
+    // and drop / -> /a, and then /a would have no way of being one below /.
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a')],
+        [link('/', '/a'), link('/a', '/', 'x'), link('/a', '/', 'y'), link('/a', '/', 'z')]
+      )
+    );
+    expect(layerOf(model, R('/a'))).toBe(layerOf(model, R('/')) - 1);
+  });
+
+  it('puts what nothing reaches in a band at the bottom, one empty row below the rest', () => {
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/home'), screen('/orphan')], [link('/', '/home')])
+    );
+    expect(layerOf(model, R('/orphan'))).toBe(0);
+    expect(layerOf(model, R('/home'))).toBe(2);
+    expect(layerOf(model, R('/'))).toBe(3);
+    expect(model.unreached).toBe(1);
+    expect(model.nodes.get(R('/orphan'))!.unreached).toBe(true);
+  });
+
+  it('draws the same picture twice', () => {
+    const a = buildScreensModel(hub());
+    const b = buildScreensModel(hub());
+    expect(a.layout).toEqual(b.layout);
+  });
+});
+
+describe('directional ports', () => {
+  it('routes a return from the top of its source to the bottom of its target', () => {
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/home')], [link('/', '/home'), link('/home', '/', 'logout')])
+    );
+    const root = nodeOf(model, R('/'));
+    const home = nodeOf(model, R('/home'));
+    const down = edgeOf(model, R('/'), R('/home'));
+    const up = edgeOf(model, R('/home'), R('/'));
+    expect(down.route).toBe('down');
+    expect(up.route).toBe('up');
+    expect(up.back).toBe(true);
+    // Down: bottom of / to top of /home. Up: top of /home to bottom of /.
+    expect(portPoint(root, down.id, 'source').y).toBe(root.y + NODE_HEIGHT);
+    expect(portPoint(home, down.id, 'target').y).toBe(home.y);
+    expect(portPoint(home, up.id, 'source').y).toBe(home.y);
+    expect(portPoint(root, up.id, 'target').y).toBe(root.y + NODE_HEIGHT);
+    // And the node component draws exactly those ports: one of each on the
+    // sides that face each other, nothing on the sides that do not.
+    expect(home.ports.top.map((p) => p.type).sort()).toEqual(['source', 'target']);
+    expect(root.ports.bottom.map((p) => p.type).sort()).toEqual(['source', 'target']);
+    expect(home.ports.bottom).toEqual([]);
+    expect(root.ports.top).toEqual([]);
+  });
+
+  it('joins two screens on one row over the top', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a'), screen('/b')],
+        [link('/', '/a'), link('/', '/b'), link('/a', '/b', 'next')]
+      )
+    );
+    const a = nodeOf(model, R('/a'));
+    const b = nodeOf(model, R('/b'));
+    const level = edgeOf(model, R('/a'), R('/b'));
+    expect(a.layer).toBe(b.layer);
+    expect(level.route).toBe('level');
+    expect(portPoint(a, level.id, 'source').y).toBe(a.y);
+    expect(portPoint(b, level.id, 'target').y).toBe(b.y);
+  });
+
+  it('widens a hub to keep its ports apart, and spaces rows for the labels', () => {
+    const targets = Array.from({ length: 20 }, (_, i) => `/t${i}`);
+    const model = buildScreensModel(
+      payload([screen('/'), screen('/home'), ...targets.map(screen)], [
+        link('/', '/home'),
+        ...targets.map((t) => link('/home', t)),
+      ])
+    );
+    const home = nodeOf(model, R('/home'));
+    expect(home.ports.bottom).toHaveLength(20);
+    expect(home.width).toBeGreaterThanOrEqual((20 + 1) * PORT_PITCH);
+    expect(home.y - nodeOf(model, R('/')).y).toBe(NODE_HEIGHT + SCREEN_LAYER_GAP);
+    expect(model.layerGap).toBe(SCREEN_LAYER_GAP);
+  });
+});
+
+describe('the curve', () => {
+  it('runs from port to port through the vertical midpoint, monotonic in y', () => {
+    const c = screenCurve('down', 0, 0, 100, 116);
+    expect(pointAt(c, 0)).toEqual({ x: 0, y: 0 });
+    expect(pointAt(c, 1)).toEqual({ x: 100, y: 116 });
+    expect(pointAt(c, 0.3).y).toBeLessThan(pointAt(c, 0.6).y);
+    expect(screenEdgePath('down', 0, 0, 100, 116)).toBe('M0,0 C0,58 100,58 100,116');
+    // Height -> parameter -> height round-trips.
+    const y = pointAt(c, 0.3).y;
+    expect(tAtY(c, y, 'target')).toBeCloseTo(0.3, 5);
+    expect(tAtY(c, y, 'source')).toBeCloseTo(0.3, 5);
+  });
+
+  it('arches a level edge above its row, searchable from either end', () => {
+    const c = screenCurve('level', 0, 100, 200, 100);
+    expect(c.y0).toBe(c.y3);
+    expect(pointAt(c, 0.5).y).toBeLessThan(100);
+    expect(tAtY(c, 90, 'target')!).toBeGreaterThan(0.5);
+    expect(tAtY(c, 90, 'source')!).toBeLessThan(0.5);
+    // Above the apex there is no curve.
+    expect(tAtY(c, -900, 'target')).toBeNull();
+  });
+});
+
+describe('placing the labels', () => {
+  it('fits five lanes between rows at the Screens gap, three at the Map\'s', () => {
+    expect(laneCount(SCREEN_LAYER_GAP)).toBe(5);
+    expect(laneCount(74)).toBe(3);
+    expect(laneCount(10)).toBe(1);
+  });
+
+  it('puts every pill at the far end of its line, and none over another or over a box', () => {
+    const model = buildScreensModel(hub());
+    const home = nodeOf(model, R('/home'));
+    const laid = placeLabels(model, R('/home'));
+    // Twelve conditions out, six back; the entry's arrival is unconditional.
+    expect(laid.pills.size + laid.hidden).toBe(18);
+    expect(laid.hidden).toBe(0);
+
+    const pills = [...laid.pills.values()];
+    for (const a of pills) {
+      for (const b of pills) {
+        if (a !== b) expect(overlaps(rectOf(a), rectOf(b)), `${a.text} over ${b.text}`).toBe(false);
+      }
+      for (const node of model.layout.nodes) {
+        expect(overlaps(rectOf(a), boxOf(node)), `${a.text} over ${node.id}`).toBe(false);
+      }
+    }
+
+    for (let i = 0; i < 12; i++) {
+      const target = nodeOf(model, R(`/t${i}`));
+      const out = laid.pills.get(edgeOf(model, R('/home'), R(`/t${i}`)).id)!;
+      expect(out.end).toBe('target');
+      expect(out.text).toBe(`→ …step === ${i}`);
+      // Above the screen it opens, inside the gap — and nearer to it than to /home.
+      expect(out.y).toBeLessThan(target.y);
+      expect(target.y - out.y).toBeLessThanOrEqual(SCREEN_LAYER_GAP);
+      const farX = target.x + target.width / 2;
+      const nearX = home.x + home.width / 2;
+      expect(Math.abs(out.x - farX)).toBeLessThan(Math.abs(out.x - nearX) + 1);
+    }
+    for (let i = 0; i < 6; i++) {
+      const source = nodeOf(model, R(`/t${i}`));
+      const back = laid.pills.get(edgeOf(model, R(`/t${i}`), R('/home')).id)!;
+      expect(back.end).toBe('source');
+      expect(back.text).toBe(`← done${i}`);
+      // A return leaves the top of its screen: the pill is above that box too.
+      expect(back.y).toBeLessThan(source.y);
+    }
+  });
+
+  it('draws nothing at rest, and the same thing every time', () => {
+    const model = buildScreensModel(hub());
+    expect(placeLabels(model, null).pills.size).toBe(0);
+    const a = placeLabels(model, R('/home'));
+    const b = placeLabels(model, R('/home'));
+    expect([...a.pills.entries()]).toEqual([...b.pills.entries()]);
+  });
+
+  it('counts a pill that fits nowhere instead of drawing it on something', () => {
+    const model = buildScreensModel(hub());
+    // One lane only: the second pill above a screen that is both opened and
+    // returned from has nowhere to go.
+    const cramped: ScreensModel = { ...model, layerGap: 10 };
+    expect(laneCount(cramped.layerGap)).toBe(1);
+    const laid = placeLabels(cramped, R('/home'));
+    expect(laid.hidden).toBeGreaterThan(0);
+    expect(laid.pills.size + laid.hidden).toBe(18);
+    const pills = [...laid.pills.values()];
+    for (const a of pills) for (const b of pills) if (a !== b) expect(overlaps(rectOf(a), rectOf(b))).toBe(false);
+  });
+
+  it('labels the hovered line at its target end when nothing is selected', () => {
+    const model = buildScreensModel(hub());
+    const edge = edgeOf(model, R('/home'), R('/t3'));
+    const pill = hoverPill(model, edge.id, null)!;
+    expect(pill.end).toBe('target');
+    expect(pill.text).toBe('→ …step === 3');
+    expect(pill.y).toBeLessThan(nodeOf(model, R('/t3')).y);
+    // Seen from the target, the same line arrives.
+    const arriving = hoverPill(model, edge.id, R('/t3'))!;
+    expect(arriving.end).toBe('source');
+    expect(arriving.text).toBe('← …step === 3');
+  });
+
+  it('says nothing for an unconditional line unless told what to say', () => {
+    const model = buildScreensModel(hub());
+    const edge = edgeOf(model, R('/'), R('/home'));
+    expect(hoverPill(model, edge.id, null)).toBeNull();
+    expect(hoverPill(model, edge.id, R('/home'), '← always')?.text).toBe('← always');
+    expect(pillText(model.edges.get(edge.id)!, edge, null)).toBe('');
+  });
+
+  it('keeps a transient pill clear of the ones the selection placed', () => {
+    const model = buildScreensModel(hub());
+    const laid = placeLabels(model, R('/home'));
+    // /t6 is opened by /home (a pill above it) and, in this payload, returns
+    // nothing; a row hovered for an unconditional return needs a lane of its
+    // own above the same box.
+    const ret = link('/t6', '/home');
+    const withReturn = buildScreensModel({ ...hub(), links: [...hub().links, ret] });
+    const base = placeLabels(withReturn, R('/home'));
+    const edge = edgeOf(withReturn, R('/t6'), R('/home'));
+    expect(base.pills.has(edge.id)).toBe(false);
+    const pill = hoverPill(withReturn, edge.id, R('/home'), '← always', base)!;
+    for (const other of base.pills.values()) {
+      expect(overlaps(rectOf(pill), rectOf(other)), `over ${other.text}`).toBe(false);
+    }
+    expect(pill.lane).toBeGreaterThan(0);
+    void laid;
+  });
+
+  it('sizes a pill from its text', () => {
+    expect(pillWidth('→ x')).toBeGreaterThan(pillWidth('→'));
+    expect(pairId(link('/a', '/a'))).toBeNull();
+    expect(pairId(link('/a', '/b'))).toBe(linkId({ source: R('/a'), target: R('/b') }));
+  });
+});
+
+/* --------------------------------------------------------------- tracks -- */
+
+function orientation(a: Point, b: Point, c: Point): number {
+  return Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
+}
+
+/** Proper crossing of two segments (shared endpoints and touching do not count). */
+function segmentsCross(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
+  const o1 = orientation(p1, p2, p3);
+  const o2 = orientation(p1, p2, p4);
+  const o3 = orientation(p3, p4, p1);
+  const o4 = orientation(p3, p4, p2);
+  return o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0 && o1 !== o2 && o3 !== o4;
+}
+
+function crossings(a: readonly Point[], b: readonly Point[]): number {
+  let n = 0;
+  for (let i = 1; i < a.length; i++) {
+    for (let j = 1; j < b.length; j++) {
+      if (segmentsCross(a[i - 1]!, a[i]!, b[j - 1]!, b[j]!)) n++;
+    }
+  }
+  return n;
+}
+
+describe('tracks — each line its own height through the gap', () => {
+  /** A hub with six screens to one side and four to the other, three of which return. */
+  function fan(): ScreensModel {
+    const left = ['/l0', '/l1', '/l2', '/l3', '/l4', '/l5'];
+    const right = ['/r0', '/r1', '/r2', '/r3'];
+    return buildScreensModel(
+      payload(
+        [screen('/'), screen('/home'), ...left.map(screen), ...right.map(screen)],
+        [
+          link('/', '/home'),
+          ...[...left, ...right].map((t, i) => link('/home', t, `c${i}`)),
+          ...left.slice(0, 3).map((t, i) => link(t, '/home', `back${i}`)),
+        ]
+      )
+    );
+  }
+
+  /** The hub's lines into the row below it, split by which side their far end sits on. */
+  function sides(model: ScreensModel): Array<Array<{ curve: Curve; farX: number; id: string }>> {
+    const home = nodeOf(model, R('/home'));
+    const centre = home.x + home.width / 2;
+    const lines = model.layout.edges
+      .filter((e) => (e.source === R('/home') || e.target === R('/home')) && e.route !== 'level')
+      .map((e) => {
+        const curve = model.curves.get(e.id)!;
+        const far = e.source === R('/home') ? { x: curve.x3, y: curve.y3 } : { x: curve.x0, y: curve.y0 };
+        return { id: e.id, curve, farX: far.x, farY: far.y };
+      })
+      .filter((l) => l.farY > home.y + home.height);
+    return [lines.filter((l) => l.farX < centre), lines.filter((l) => l.farX >= centre)];
+  }
+
+  it('ranks a fan by reach: the farthest-out line runs nearest the hub, every line on its own track', () => {
+    const model = fan();
+    const home = nodeOf(model, R('/home'));
+    const bottom = home.y + home.height;
+    const [left, right] = sides(model);
+    expect(left!.length + right!.length).toBe(13);
+    // Left: farther left first; its track is the highest (smallest y).
+    const l = [...left!].sort((a, b) => a.farX - b.farX);
+    for (let i = 1; i < l.length; i++) expect(l[i]!.curve.y1).toBeGreaterThan(l[i - 1]!.curve.y1 + 4);
+    // Right: farther right first, mirrored.
+    const r = [...right!].sort((a, b) => b.farX - a.farX);
+    for (let i = 1; i < r.length; i++) expect(r[i]!.curve.y1).toBeGreaterThan(r[i - 1]!.curve.y1 + 4);
+    // Every track lies inside the gap under the hub, and both control points share it.
+    for (const line of [...l, ...r]) {
+      expect(line.curve.y1).toBeGreaterThan(bottom);
+      expect(line.curve.y1).toBeLessThan(bottom + SCREEN_LAYER_GAP);
+      expect(line.curve.y2).toBe(line.curve.y1);
+    }
+  });
+
+  it('never lets two lines of one fan cross — returns included', () => {
+    const model = fan();
+    for (const group of sides(model)) {
+      for (const a of group) {
+        for (const b of group) {
+          if (a.id >= b.id) continue;
+          expect(
+            crossings(model.polylines.get(a.id)!, model.polylines.get(b.id)!),
+            `${a.id} crosses ${b.id}`
+          ).toBe(0);
+        }
+      }
+    }
+  });
+
+  it('keeps a line that spans several rows on a track beside its fan, and runs the rest vertically', () => {
+    // Downward lines are always one row (a row IS distance from the entry);
+    // a return can come from any depth. Two rows down, straight back home.
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/home'), screen('/mid'), screen('/deep')],
+        [link('/', '/home'), link('/home', '/mid'), link('/mid', '/deep'), link('/deep', '/home', 'done')]
+      )
+    );
+    const home = nodeOf(model, R('/home'));
+    const deep = nodeOf(model, R('/deep'));
+    expect(home.layer - deep.layer).toBe(2);
+    const curve = model.curves.get(edgeOf(model, R('/deep'), R('/home')).id)!;
+    // The track sits in the gap right under /home — not at the midpoint, which
+    // would be inside the row between.
+    expect(curve.y1).toBeGreaterThan(home.y + home.height);
+    expect(curve.y1).toBeLessThan(home.y + home.height + SCREEN_LAYER_GAP);
+    expect(curve.y2).toBe(curve.y1);
+    // Both ends leave and arrive vertically.
+    expect(curve.x1).toBe(curve.x0);
+    expect(curve.x2).toBe(curve.x3);
+  });
+
+  it('nests level arches, the wider one higher', () => {
+    const model = buildScreensModel(
+      payload(
+        [screen('/'), screen('/a'), screen('/b'), screen('/c')],
+        [link('/', '/a'), link('/', '/b'), link('/', '/c'), link('/a', '/b', 'x'), link('/a', '/c', 'y')]
+      )
+    );
+    const a = nodeOf(model, R('/a'));
+    const centre = a.x + a.width / 2;
+    const ab = model.curves.get(edgeOf(model, R('/a'), R('/b')).id)!;
+    const ac = model.curves.get(edgeOf(model, R('/a'), R('/c')).id)!;
+    // Both arches leave a's top towards the same side (the row is b, c, a or a, b, c).
+    expect(Math.sign(ab.x3 - centre)).toBe(Math.sign(ac.x3 - centre));
+    const [wide, narrow] = Math.abs(ab.x3 - ab.x0) > Math.abs(ac.x3 - ac.x0) ? [ab, ac] : [ac, ab];
+    expect(wide.y1).toBeLessThan(narrow.y1);
+    expect(narrow.y1).toBeLessThan(a.y);
+  });
+
+  it('draws the same tracks twice', () => {
+    expect([...fan().curves.entries()]).toEqual([...fan().curves.entries()]);
+  });
+});
+
+describe('pointing at a line', () => {
+  it('answers the nearest line within reach, and nothing beyond it', () => {
+    const model = buildScreensModel(hub());
+    const edge = edgeOf(model, R('/home'), R('/t4'));
+    const on = model.polylines.get(edge.id)![12]!;
+    expect(nearestEdge(model, { x: on.x + 1, y: on.y + 1 }, null, 10)?.id).toBe(edge.id);
+    expect(nearestEdge(model, { x: -5000, y: -5000 }, null, 10)).toBeNull();
+    // Reach is a distance, not a hint.
+    expect(nearestEdge(model, { x: on.x + 30, y: on.y + 30 }, null, 10)).toBeNull();
+  });
+
+  it('only considers the lines it is asked about', () => {
+    const model = buildScreensModel(hub());
+    const near = edgeOf(model, R('/home'), R('/t4'));
+    const other = edgeOf(model, R('/home'), R('/t9'));
+    const on = model.polylines.get(near.id)![12]!;
+    expect(nearestEdge(model, on, new Set([other.id]), 1e9)?.id).toBe(other.id);
+    expect(nearestEdge(model, on, new Set(), 1e9)).toBeNull();
+  });
+
+  it('tells two lines a few pixels apart from each other', () => {
+    const model = buildScreensModel(hub());
+    const home = nodeOf(model, R('/home'));
+    // Two lines to neighbouring screens on the same side: at a height in the
+    // gap where both run, the pointer just above one, then just below the
+    // other, meets each in turn.
+    const [a, b] = model.layout.edges
+      .filter((e) => e.source === R('/home'))
+      .map((e) => ({ id: e.id, curve: model.curves.get(e.id)! }))
+      .filter((l) => l.curve.x3 < home.x)
+      .sort((p, q) => p.curve.x3 - q.curve.x3);
+    expect(a && b).toBeTruthy();
+    const y = (a!.curve.y1 + b!.curve.y1) / 2;
+    const x = Math.max(a!.curve.x3, b!.curve.x3) + 40;
+    const hit = nearestEdge(model, { x, y: y - 1 }, null, 60)!;
+    const hit2 = nearestEdge(model, { x, y: y + 1 }, null, 60)!;
+    expect(new Set([hit.id, hit2.id]).size).toBeGreaterThanOrEqual(1);
+    expect([a!.id, b!.id]).toContain(hit.id);
+  });
+});

+ 22 - 0
docs/design/codegraph-ui-design-spec.md

@@ -418,6 +418,28 @@ keeps its `createdAt`.
 CORS preflight this server answers none of. `--read-only` refuses both and the screens say so in the answering side's own
 words instead of showing a Save that fails.
 
+### 3.13 Screens (`#/screens`)
+Grid: canvas `minmax(600px,1fr)` | side panel **340px**. The Map's layout (§3.6) with three options: **layering** = BFS
+distance from the entry screen over every transition (entry on top; shared chrome one row above the shallowest screen it
+opens; whatever nothing reaches in a band at the bottom, one empty row below); `layerGap` **116** (five label lanes);
+`portPitch` **12** (a box is at least `(ports on its busier side + 1) × 12` wide); `ports: 'directional'` — down:
+bottom → top; up: **top → bottom**; level: **top → top**, an arch whose control points sit `0.66 × layerGap` above the row.
+**Tracks:** a line's control-point height is `y_hub + gap × (k+1)/(n+1)` for the k-th of the n lines in its fan (one side
+of one box, one direction), ranked by reach, farthest first, measured towards the hub's row; a line spanning several rows keeps
+its track in the gap beside its fan; level arches rise `gap × (0.66 − 0.26 × k/(n−1))`. Hover: the curve nearest the pointer,
+sampled at 24 points, within **10** screen px; no hit paths. Zoom **0.2–3**.
+Nodes as §3.6, sized for the screen's path (13px mono) over its component (11px sans); entry mark `●` in `--accent`;
+origins dashed `--ink-3`; unreached `--ink-4` stroke. Edges: the §3.6 cubic, `stroke-width = min(3, §3.6 width)`,
+`--ink` 0.32 (hot 0.95; soft 0.38 while another of the selected screen's lines is in focus; focus 1.0; dimmed 0.06);
+synthesized dasharray `5 3`; back `--accent` 0.6 dashed `4 3` (hot 0.85). Pills, on the selected screen's edges and the
+hovered one only: 10.5px mono on `--paper`, 1px `--rule` border (hot `--ink-3`; focus `--ink` + 0 2px 8px shadow), **17px**
+tall, width `chars × 6.3 + 12`; text = the innermost top-level `&&` clause of the condition, ≤ **36** chars, `…` prefix
+when outer guards precede it, `→` / `←` prefix for leaving / arriving at the selected screen, or "N ways · M conditional"
+for a pair with several. Placement: at the FAR end of the line; first lane centred **13px** outside the far box, lanes
+**21px** apart, at most 5 within the gap; each pill centred on its own curve at that height; laid left to right, first free
+lane; never over a box; overflow counted in the panel. Panel row hover: that pill prints the whole condition (wraps at
+360px), its line at 1.0, the rest at 0.38; a hovered line tints its row `--press`. Legend bottom-left, remembered per browser.
+
 ## 4. Libraries and versions
 - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
   hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a

+ 61 - 1
ui/README.md

@@ -186,11 +186,12 @@ src/
   lib/flow-model.ts       the Flow strip's card/link geometry + the end cap — a DAG (pure)
   lib/filecode-model.ts   the whole-file view: fixed line height, arcs, paging (pure)
   lib/entry-model.ts      the entry-points panel: rows, file groups, flow arming (pure)
+  lib/screens-model.ts    the Screens view: layering by distance from the entry, edge labels, pill lanes (pure)
   lib/export-svg.ts       the Flow strip and the Map as a standalone SVG (pure)
   lib/export-image.ts     rasterising that SVG to PNG, clipboard and download
   lib/live.svelte.ts      /api/events: two counters every screen refreshes from
   lib/toast.svelte.ts     the one transient note ("Index updated · reloaded")
-  components/             TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
+  components/             TopBar, TrailBar, SavedTrails, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/, screens/
   views/                  one component per route
 ```
 
@@ -232,6 +233,7 @@ Mono, so the code grid survives and only the letterforms change.
 | `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
 | `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
 | `#/entry` | entry points — routes, files that run something, tests, hubs |
+| `#/screens` | screens — the app's screens and the transitions between them |
 
 ## Entry points
 
@@ -255,6 +257,64 @@ not accidents:
 `buildEntryPanel` is pure and keeps `panel.rows` exactly equal to the sections it
 draws, the same identity the search palette rests its keyboard on.
 
+## Screens
+
+`#/screens` draws `/api/screens` — the app as its user meets it: one box per
+screen, an arrow for every way of getting from one to another, and on each
+arrow the condition under which it happens. The canvas is the Map's layout
+engine (`buildMapLayout`) driven by `screens-model.ts` with three options the
+Map never sets, because a screens graph differs from a module graph in one way
+that shapes the whole picture: it is full of cycles. Every screen returns to
+Home.
+
+- **Layering is distance from the entry screen**, measured over every
+  transition — not longest path over a two-cycle-broken set. Shared chrome (a
+  top bar rendered on ten screens) hangs one row above the shallowest screen it
+  opens, so what it opens is placed by the entry and never dragged up beside
+  Home; what only chrome reaches is seeded from the chrome. Whatever nothing
+  reaches sits in a band at the bottom, one empty row below the rest.
+- **Ports are directional.** A transition down the picture leaves the bottom
+  of its box and arrives at the top of the other, as on the Map; a return
+  leaves the **top** of its source and arrives at the **bottom** of its target,
+  so it is drawn around the boxes rather than through them; a transition
+  between two screens on one row arches over the row, top to top. A hub widens
+  so its ports are at least 12px apart (`portPitch`), and rows are 116px apart
+  instead of the Map's 74 (`layerGap`), because the edges here carry labels.
+- **Lines fan out instead of stacking.** Drawn through one midpoint, every
+  line between two rows crosses that height at its middle, and a line to a
+  screen far to the side is nearly horizontal there — a hub's lines run stacked
+  within a few pixels for hundreds, and no pointer can pick one.
+  `trackedCurves` gives each line in a fan (the lines leaving one side of one
+  box towards one side) a track of its own: the farthest-reaching runs nearest
+  the hub's row, the next a track further out, nested in port order so no two
+  lines of a fan cross; a line spanning several rows keeps its track beside
+  its fan and drops the rest of the way vertically; a wider level arch rises
+  higher than a narrower one. There are no hit paths: hovering the canvas
+  means the line **nearest** the pointer (`nearestEdge`, within 10 screen
+  pixels), so moving a few pixels moves to the next line, predictably. Zoom
+  runs to 3× — the honest spacing control, since it scales lines and text
+  together.
+- **Labels are placed by the model, at the far end.** At rest the picture is
+  boxes and lines. Selecting a screen labels each of its transitions with the
+  innermost condition — the clause decided at the navigation call
+  (`…guide.dontShowAgain.captureGuide`); the first thirty characters of the
+  whole chain are usually shared with a sibling — prefixed `→` leaving the
+  selected screen or `←` arriving at it. `placeLabels` puts every pill beside
+  the screen at the *other* end of its line, where the lines are apart (beside
+  the selected screen fifteen of them share one box's width), in the first of
+  five lanes walking away from that box in which it overlaps nothing, centred
+  on its own curve at that height. A pill that fits nowhere is counted and the
+  panel says so. Pills are HTML in Svelte Flow's edge-label layer, above every
+  stroke; the selection alone decides where they go, so hovering never reflows
+  them.
+- **The panel and the picture point at each other.** A row under the pointer
+  lights its line and prints the whole condition on it; a line under the
+  pointer tints its row.
+
+The geometry — ports, curves, pill lanes — is arithmetic in `screens-model.ts`
+and `map-model.ts`, tested without a browser in
+`__tests__/ui-screens-model.test.ts`.
+
 ## Where the graph stops
 
 A flow that does not reach everything it was asked about carries a

+ 99 - 69
ui/src/components/screens/ScreenEdge.svelte

@@ -1,68 +1,73 @@
 <script lang="ts">
   /**
-   * One transition on the Screens view — the Map's cubic, plus a label at the
-   * midpoint saying under what condition it happens. A pair with several
-   * transitions draws once and counts them; the tooltip and the side panel
-   * tell them apart. Dashed when every transition behind it rides a
+   * One transition on the Screens view — the curve the model chose for it
+   * (`trackedCurves`: the Map's cubic on a track of its own for a transition
+   * down or up the picture, an arch for one along a row) and, when the model
+   * placed one, a pill saying under what condition it happens. A pair with
+   * several transitions draws once and counts them; the tooltip and the side
+   * panel tell them apart. Dashed when every transition behind it rides a
    * synthesized hop (a helper's return value); accent-dashed when it points
    * back up the layering (Capture → Home).
+   *
+   * The path is drawn from the model's curve, not from the endpoints Svelte
+   * Flow measured, so the line on screen is the line the labels were placed
+   * on and the line the pointer is tested against. There is no hit path: the
+   * view finds the line nearest the pointer (`nearestEdge`), which in a fan
+   * of lines a few pixels apart is the only way to mean one of them.
+   *
+   * The pill is HTML in Svelte Flow's edge-label layer rather than SVG inside
+   * this edge's own group. The layer sits above every edge path, so a pill is
+   * never drawn under the next edge's stroke — the fate of a label that lives
+   * inside one edge among thirty.
    */
-  import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
+  import { BaseEdge, EdgeLabel, type EdgeProps } from '@xyflow/svelte';
   import type { MapEdgeLayout } from '../../lib/map-model';
-  import type { ScreenEdgeInfo } from '../../lib/screens-model';
+  import { pathOf, type Curve, type PillPlacement, type ScreenEdgeInfo } from '../../lib/screens-model';
 
-  let { sourceX, sourceY, targetX, targetY, data }: EdgeProps = $props();
+  let { data }: EdgeProps = $props();
 
   const d = $derived(
     data as unknown as {
       edge: MapEdgeLayout;
       info: ScreenEdgeInfo;
+      curve: Curve;
+      /** One of the selected screen's, or under the pointer. */
       hot: boolean;
+      /** Hot, but another of the selected screen's edges is the one in focus. */
+      soft: boolean;
+      /** Under the pointer, or its row in the panel is. */
+      focus: boolean;
       dimmed: boolean;
-      /** Show the label; only the selected screen's edges and the hovered one do. */
-      labelled: boolean;
-      /**
-       * Where along the curve the label sits, 0 = source end, 1 = target end.
-       * Close to the selected screen, where its lines are still apart, so a
-       * label sits beside the one line it belongs to.
-       */
-      labelAt: number;
+      /** Where the model put the label; null draws none. */
+      pill: PillPlacement | null;
+      /** The whole condition, when the panel's row is hovered; replaces the pill's words. */
+      full: string | null;
       onHover: (edge: MapEdgeLayout | null, event: MouseEvent | null) => void;
     }
   );
 
-  const midY = $derived((sourceY + targetY) / 2);
-  const path = $derived(`M${sourceX},${sourceY} C${sourceX},${midY} ${targetX},${midY} ${targetX},${targetY}`);
-
-  /** The point at `t` on the same cubic the path draws. */
-  const label = $derived.by(() => {
-    const t = d.labelAt;
-    const u = 1 - t;
-    const x = u * u * u * sourceX + 3 * u * u * t * sourceX + 3 * u * t * t * targetX + t * t * t * targetX;
-    const y = u * u * u * sourceY + 3 * u * u * t * midY + 3 * u * t * t * midY + t * t * t * targetY;
-    return { x, y };
-  });
-  /** IBM Plex Mono at 10.5px: ~6.3px per character, plus the pill's padding. */
-  const pillWidth = $derived(d.info.label.length * 6.3 + 12);
+  const path = $derived(pathOf(d.curve));
+  const classes = $derived(
+    `sedge${d.edge.back ? ' back' : ''}${d.info.synthesized ? ' synth' : ''}${d.hot ? ' hot' : ''}${
+      d.soft ? ' soft' : ''
+    }${d.focus ? ' focus' : ''}${d.dimmed ? ' dimmed' : ''}`
+  );
 </script>
 
-<BaseEdge
-  {path}
-  class={`sedge${d.edge.back ? ' back' : ''}${d.hot ? ' hot' : ''}${d.dimmed ? ' dimmed' : ''}${d.info.synthesized ? ' synth' : ''}`}
-  style={`stroke-width:${Math.min(3, d.edge.width)}px`}
-/>
-<path
-  class="hit"
-  d={path}
-  role="presentation"
-  onmousemove={(event) => d.onHover(d.edge, event)}
-  onmouseleave={() => d.onHover(null, null)}
-/>
-{#if d.info.label && d.labelled}
-  <g class="epill" class:hot={d.hot}>
-    <rect x={label.x - pillWidth / 2} y={label.y - 9} width={pillWidth} height={17} rx="2" />
-    <text x={label.x} y={label.y + 3.5} text-anchor="middle">{d.info.label}</text>
-  </g>
+<BaseEdge {path} class={classes} style={`stroke-width:${Math.min(3, d.edge.width)}px`} />
+{#if d.pill !== null}
+  <EdgeLabel
+    x={d.pill.x}
+    y={d.pill.y}
+    transparent
+    class="spill-anchor"
+    onmousemove={(event) => d.onHover(d.edge, event)}
+    onmouseleave={() => d.onHover(null, null)}
+  >
+    <span class="spill" class:hot={d.hot} class:focus={d.focus} class:full={d.full !== null}>
+      {d.full ?? d.pill.text}
+    </span>
+  </EdgeLabel>
 {/if}
 
 <style>
@@ -71,12 +76,6 @@
     stroke-opacity: 0.32;
     fill: none;
   }
-  :global(.svelte-flow__edge-path.sedge.hot) {
-    stroke-opacity: 0.95;
-  }
-  :global(.svelte-flow__edge-path.sedge.dimmed) {
-    stroke-opacity: 0.06;
-  }
   :global(.svelte-flow__edge-path.sedge.synth) {
     stroke-dasharray: 5 3;
   }
@@ -85,29 +84,60 @@
     stroke-opacity: 0.6;
     stroke-dasharray: 4 3;
   }
-  .hit {
-    stroke: transparent;
-    stroke-width: 12;
-    fill: none;
-    pointer-events: stroke;
-    cursor: crosshair;
+  :global(.svelte-flow__edge-path.sedge.hot) {
+    stroke-opacity: 0.95;
+  }
+  :global(.svelte-flow__edge-path.sedge.back.hot) {
+    stroke-opacity: 0.85;
   }
-  .epill {
-    pointer-events: none;
+  /* Another of the selected screen's lines is in focus: this one recedes,
+     without going, so the reader can still count them. */
+  :global(.svelte-flow__edge-path.sedge.hot.soft) {
+    stroke-opacity: 0.38;
   }
-  .epill rect {
-    fill: var(--paper);
-    stroke: var(--rule);
-    stroke-width: 1px;
+  :global(.svelte-flow__edge-path.sedge.focus) {
+    stroke-opacity: 1;
   }
-  .epill text {
+  :global(.svelte-flow__edge-path.sedge.dimmed) {
+    stroke-opacity: 0.06;
+  }
+  /* The label layer's own box: no padding of its own, so the pill's
+     rectangle is the one the lane arithmetic reserved. */
+  :global(.svelte-flow__edge-label.spill-anchor) {
+    padding: 0;
+    line-height: 0;
+    font-size: 0;
+  }
+  .spill {
+    display: inline-block;
+    box-sizing: border-box;
+    height: 17px;
+    padding: 0 5px;
+    border: 1px solid var(--rule);
+    background: var(--paper);
+    color: var(--ink-2);
     font: 400 10.5px var(--mono);
-    fill: var(--ink-2);
+    line-height: 15px;
+    white-space: nowrap;
+    cursor: crosshair;
+  }
+  .spill.hot {
+    border-color: var(--ink-3);
+    color: var(--ink);
   }
-  .epill.hot rect {
-    stroke: var(--ink-3);
+  .spill.focus {
+    border-color: var(--ink);
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
   }
-  .epill.hot text {
-    fill: var(--ink);
+  /* The whole condition, for the row under the pointer in the panel: it may
+     wrap, and it may cover its neighbours — it is on top, and transient. */
+  .spill.full {
+    height: auto;
+    max-width: 360px;
+    padding: 2px 6px;
+    line-height: 13px;
+    white-space: normal;
+    text-align: left;
+    overflow-wrap: anywhere;
   }
 </style>

+ 14 - 10
ui/src/components/screens/ScreenNode.svelte

@@ -5,8 +5,12 @@
    * store action after login) — draws dashed, so it reads as a trigger rather
    * than a place. The entry screen (`/`) carries a mark.
    *
-   * Hidden handles along the top and bottom, one per link, exactly as the
-   * Map's module box does: the layout decided the ports, this only draws them.
+   * Hidden handles along the top and bottom, one per port, exactly as the
+   * Map's module box does — except that here a side may hold both kinds. The
+   * layout routed every transition (`directional` ports): a return trip
+   * leaves the TOP of this box and arrives at the BOTTOM of the screen it
+   * returns to, so the line runs around the boxes instead of through them.
+   * This only draws the ports the layout decided.
    */
   import { Handle, Position, type NodeProps } from '@xyflow/svelte';
   import type { MapNodeLayout } from '../../lib/map-model';
@@ -31,12 +35,12 @@
   }
 </script>
 
-{#each layout.targetHandles as handle, i (handle)}
+{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
   <Handle
-    type="target"
-    id={`t:${handle}`}
+    type={port.type}
+    id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
     position={Position.Top}
-    style={portStyle(i, layout.targetHandles.length)}
+    style={portStyle(i, layout.ports.top.length)}
     isConnectable={false}
   />
 {/each}
@@ -61,12 +65,12 @@
   <span class="sub">{info.sub}</span>
 </button>
 
-{#each layout.sourceHandles as handle, i (handle)}
+{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
   <Handle
-    type="source"
-    id={`s:${handle}`}
+    type={port.type}
+    id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
     position={Position.Bottom}
-    style={portStyle(i, layout.sourceHandles.length)}
+    style={portStyle(i, layout.ports.bottom.length)}
     isConnectable={false}
   />
 {/each}

+ 146 - 22
ui/src/lib/map-model.ts

@@ -32,6 +32,14 @@
  * no declared edge behind it — is marked `back` and drawn only when a module it
  * touches is selected. Drawing it downward would be a lie about the direction
  * of the dependency; hiding it entirely would be a lie about its existence.
+ *
+ * The Screens view runs the same pipeline with three options the Map leaves at
+ * their defaults: its own layering (distance from the entry screen), a wider
+ * layer gap (its edges carry labels), and `directional` ports — a link that
+ * points up the layering leaves the TOP of its source and arrives at the
+ * BOTTOM of its target, so a return trip is drawn around the boxes instead of
+ * through them. In a screens graph a cycle is the normal case, not the
+ * exception the Map hides at rest.
  */
 
 import type { WireMapLink, WireMapModule, WireMapPayload } from './api';
@@ -43,6 +51,12 @@ export const NODE_GAP = 34;
 export const PADDING = 44;
 /** Least horizontal room a layer gets per module, so a sparse row still spreads. */
 const MIN_SLOT = 230;
+/**
+ * Room between two ports on one side of a box, when a view asks for it
+ * (`portPitch`). Fifteen lines leaving a 110px box are 7px apart and read as
+ * one; at 12px they are a fan a reader can follow back to its box.
+ */
+export const PORT_PITCH = 12;
 const MIN_NODE_WIDTH = 110;
 /**
  * IBM Plex Mono's real advance at 13px (0.6em), not the spec's 7.3 estimate.
@@ -111,6 +125,12 @@ export function moduleMetaLabel(module: WireMapModule, island = false): string {
   return `${symbols} · ${files}`;
 }
 
+/** One port on a box's edge: the link it belongs to, and which end of it this is. */
+export interface PortRef {
+  id: string;
+  type: 'source' | 'target';
+}
+
 export interface MapNodeLayout {
   id: string;
   module: WireMapModule;
@@ -129,10 +149,19 @@ export interface MapNodeLayout {
   y: number;
   width: number;
   height: number;
-  /** Link ids leaving this node, left to right — one hidden handle each. */
+  /** Link ids leaving from the BOTTOM of this node, left to right — one hidden handle each. */
   sourceHandles: string[];
-  /** Link ids arriving at this node, left to right. */
+  /** Link ids arriving at the TOP of this node, left to right. */
   targetHandles: string[];
+  /**
+   * Every port on the box, by side, left to right — what a node component
+   * draws its handles from. Under the Map's `layered` ports this is exactly
+   * `targetHandles` on top and `sourceHandles` below. Under `directional`
+   * ports a side mixes the two: an edge routed `up` leaves the top of its
+   * source and arrives at the bottom of its target, and a `level` edge leaves
+   * and arrives at the top, arching over the row.
+   */
+  ports: { top: PortRef[]; bottom: PortRef[] };
 }
 
 export interface MapEdgeLayout {
@@ -148,8 +177,16 @@ export interface MapEdgeLayout {
   back: boolean;
   /** Below the weight threshold — drawn only when a touching module is selected. */
   thin: boolean;
+  /**
+   * Which way the link runs through the layering: `down` to a lower layer,
+   * `up` to a higher one, `level` along its own. Under `directional` ports
+   * this decides the sides the edge uses and the curve it draws.
+   */
+  route: EdgeRoute;
 }
 
+export type EdgeRoute = 'down' | 'up' | 'level';
+
 export interface MapLayerLayout {
   index: number;
   y: number;
@@ -202,6 +239,22 @@ export interface MapLayoutOptions {
    * longest chain of screens above the login page.
    */
   layering?: (ids: string[], links: ReadonlyArray<{ source: string; target: string }>) => Map<string, number>;
+  /**
+   * Vertical room between two layers; {@link LAYER_GAP} unless a view says
+   * otherwise. The Screens view widens it because its edges carry labels, and
+   * a label needs a lane the Map's hairlines never did.
+   */
+  layerGap?: number;
+  /**
+   * Least distance between two ports on one side of a box; a box widens to
+   * keep it. 0 (the default) sizes a box by its text alone.
+   */
+  portPitch?: number;
+  /**
+   * `layered` (the default): every link leaves a bottom and arrives at a top,
+   * whichever way it points. `directional`: see {@link MapNodeLayout.ports}.
+   */
+  ports?: 'layered' | 'directional';
 }
 
 export function strokeWidthFor(count: number): number {
@@ -229,6 +282,9 @@ export function buildMapLayout(
   const depended = new Set(payload.links.map((l) => l.target));
   const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
   const minWeight = options.minWeight ?? (options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT);
+  const layerGap = options.layerGap ?? LAYER_GAP;
+  const portPitch = options.portPitch ?? 0;
+  const directional = options.ports === 'directional';
 
   const declaredLinks = links.filter((l) => l.declared > 0);
   const useDeclared =
@@ -301,12 +357,37 @@ export function buildMapLayout(
   }
 
   // --- placement -----------------------------------------------------------
+  // Which side of each box a link's two ports land on is settled by the
+  // layers alone, so it is known before any box has a width — and a view
+  // that asked for a port pitch needs it now: a hub with nineteen lines
+  // leaving its bottom edge is widened to hold them.
+  const routeOf = (link: { source: string; target: string }): EdgeRoute => {
+    const from = layer.get(link.source) ?? 0;
+    const to = layer.get(link.target) ?? 0;
+    return from > to ? 'down' : from < to ? 'up' : 'level';
+  };
+  const sidesOf = (route: EdgeRoute): { source: 'top' | 'bottom'; target: 'top' | 'bottom' } => {
+    if (!directional || route === 'down') return { source: 'bottom', target: 'top' };
+    if (route === 'up') return { source: 'top', target: 'bottom' };
+    return { source: 'top', target: 'top' };
+  };
+  const portCount = new Map<string, { top: number; bottom: number }>(
+    modules.map((m) => [m.id, { top: 0, bottom: 0 }])
+  );
+  for (const link of links) {
+    const sides = sidesOf(routeOf(link));
+    portCount.get(link.source)![sides.source] += 1;
+    portCount.get(link.target)![sides.target] += 1;
+  }
+
   const islands = new Set(modules.filter((m) => !depended.has(m.id)).map((m) => m.id));
   const widths = new Map(
     modules.map((m) => {
       const island = islands.has(m.id);
       const lines = options.sizing?.(m, island) ?? { label: m.id, meta: moduleMetaLabel(m, island) };
-      return [m.id, nodeWidth(lines.label, lines.meta)];
+      const count = portCount.get(m.id) ?? { top: 0, bottom: 0 };
+      const forPorts = (Math.max(count.top, count.bottom) + 1) * portPitch;
+      return [m.id, Math.max(nodeWidth(lines.label, lines.meta), forPorts)];
     })
   );
   const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
@@ -322,7 +403,7 @@ export function buildMapLayout(
     Math.min(contentWidth, Math.max(naturalSpans[i] ?? 0, row.length * MIN_SLOT))
   );
   const width = contentWidth + PADDING * 2;
-  const height = layerCount * (NODE_HEIGHT + LAYER_GAP) - LAYER_GAP + PADDING * 2;
+  const height = layerCount * (NODE_HEIGHT + layerGap) - layerGap + PADDING * 2;
 
   const nodesById = new Map<string, MapNodeLayout>();
   const byId = new Map(modules.map((m) => [m.id, m]));
@@ -333,7 +414,7 @@ export function buildMapLayout(
     // A single box centres in the content width instead of clinging to the
     // left edge — the common case for the entry point at the top.
     let x = PADDING + (contentWidth - span) / 2 + (row.length === 1 ? (span - sum) / 2 : 0);
-    const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP);
+    const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap);
     for (const id of row) {
       const w = widths.get(id) ?? MIN_NODE_WIDTH;
       const module = byId.get(id)!;
@@ -351,6 +432,7 @@ export function buildMapLayout(
         height: NODE_HEIGHT,
         sourceHandles: [],
         targetHandles: [],
+        ports: { top: [], bottom: [] },
       });
       x += w + gap;
     }
@@ -361,13 +443,14 @@ export function buildMapLayout(
   // that survives the filter exists in the code, and the map's job is to say
   // where it goes, not to pretend it is absent.
   const edges: MapEdgeLayout[] = [];
-  const outgoing = new Map<string, MapEdgeLayout[]>();
-  const incoming = new Map<string, MapEdgeLayout[]>();
+  // Every port, by box and side, with the x of the link's other end.
+  const sidePorts = new Map<string, { top: SidePort[]; bottom: SidePort[] }>();
   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 = routeOf(link);
     const edge: MapEdgeLayout = {
       id,
       source: link.source,
@@ -378,27 +461,40 @@ export function buildMapLayout(
       width: strokeWidthFor(link.count),
       back: from.layer <= to.layer,
       thin: link.count < minWeight,
+      route,
     };
     edges.push(edge);
-    (outgoing.get(link.source) ?? setDefault(outgoing, link.source)).push(edge);
-    (incoming.get(link.target) ?? setDefault(incoming, link.target)).push(edge);
+    const sides = sidesOf(route);
+    (sidePorts.get(link.source) ?? setDefault(sidePorts, link.source))[sides.source].push({
+      id,
+      type: 'source',
+      other: xOf(nodesById, link.target),
+    });
+    (sidePorts.get(link.target) ?? setDefault(sidePorts, link.target))[sides.target].push({
+      id,
+      type: 'target',
+      other: xOf(nodesById, link.source),
+    });
   }
   // Ports spread in the order the other end appears left-to-right, so bundles
   // between two layers stay untangled instead of crossing inside the gap.
-  for (const [id, list] of outgoing) {
-    list.sort((a, b) => xOf(nodesById, a.target) - xOf(nodesById, b.target) || a.id.localeCompare(b.id));
-    const node = nodesById.get(id);
-    if (node) node.sourceHandles = list.map((e) => e.id);
-  }
-  for (const [id, list] of incoming) {
-    list.sort((a, b) => xOf(nodesById, a.source) - xOf(nodesById, b.source) || a.id.localeCompare(b.id));
+  const byOther = (a: SidePort, b: SidePort) => a.other - b.other || a.id.localeCompare(b.id);
+  for (const [id, sides] of sidePorts) {
     const node = nodesById.get(id);
-    if (node) node.targetHandles = list.map((e) => e.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 layers: MapLayerLayout[] = rows.map((_, index) => ({
     index,
-    y: PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP) + NODE_HEIGHT / 2,
+    y: PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + layerGap) + NODE_HEIGHT / 2,
     label:
       layerCount === 1
         ? null
@@ -439,10 +535,38 @@ export function isEdgeVisible(edge: MapEdgeLayout, selected: string | null): boo
   return !edge.thin && !edge.back;
 }
 
-function setDefault(map: Map<string, MapEdgeLayout[]>, key: string): MapEdgeLayout[] {
-  const list: MapEdgeLayout[] = [];
-  map.set(key, list);
-  return list;
+/**
+ * Where a link's port sits on a box: `x = left + width x (i+1)/(n+1)` along
+ * the side that holds it, at the top or bottom edge. The same arithmetic the
+ * node components place their hidden handles with, so a view that needs the
+ * point before anything is rendered — to put a label on the curve — gets the
+ * one the browser will measure.
+ */
+export function portPoint(node: MapNodeLayout, id: string, type: 'source' | 'target'): { x: number; y: number } {
+  const top = node.ports.top.findIndex((p) => p.id === id && p.type === type);
+  if (top >= 0) return { x: node.x + (node.width * (top + 1)) / (node.ports.top.length + 1), y: node.y };
+  const bottom = node.ports.bottom.findIndex((p) => p.id === id && p.type === type);
+  if (bottom >= 0) {
+    return {
+      x: node.x + (node.width * (bottom + 1)) / (node.ports.bottom.length + 1),
+      y: node.y + node.height,
+    };
+  }
+  return { x: node.x + node.width / 2, y: type === 'source' ? node.y + node.height : node.y };
+}
+
+interface SidePort extends PortRef {
+  /** Centre x of the link's other end — the sort key along the side. */
+  other: number;
+}
+
+function setDefault(
+  map: Map<string, { top: SidePort[]; bottom: SidePort[] }>,
+  key: string
+): { top: SidePort[]; bottom: SidePort[] } {
+  const sides = { top: [], bottom: [] };
+  map.set(key, sides);
+  return sides;
 }
 
 function xOf(nodes: Map<string, MapNodeLayout>, id: string): number {

+ 627 - 50
ui/src/lib/screens-model.ts

@@ -2,25 +2,81 @@
  * The Screens view's model — the app's screens and the transitions between
  * them, laid out so that a screen sits above the screens it opens.
  *
- * The layout is the Map's (`buildMapLayout`): the same longest-path layering,
- * the same barycenter ordering, the same ports, the same determinism. A
- * screen graph is a module graph with different words — nodes with names,
- * weighted links that mostly point one way, a few cycles (Home ↔ Capture)
- * that become dashed back-edges rather than being straightened into a lie.
- * Reusing it means a reader who learned the Map reads this without learning
- * anything new, and means this file is mostly translation, not geometry.
+ * The layout is the Map's (`buildMapLayout`): the same barycenter ordering,
+ * the same ports, the same determinism. A screen graph is a module graph with
+ * different words — nodes with names, weighted links that mostly point one
+ * way — but it differs from a module graph in one thing that shapes the
+ * picture: it is full of cycles. Every screen returns to Home. So this file
+ * asks the layout for three things the Map leaves alone:
+ *
+ * - **layering by distance from the entry screen** (`entryLayering`), where
+ *   "one layer above what it depends on" would put the head of the longest
+ *   chain of screens above the login page;
+ * - **directional ports**, so a return trip leaves the top of its source and
+ *   arrives at the bottom of its target — drawn around the boxes, not through
+ *   them — and a transition between two screens on one row arches over it;
+ * - **room**: a wider layer gap, because the edges here carry labels, and a
+ *   port pitch, because a hub with nineteen lines leaving it needs to be wide
+ *   enough for a reader to follow one back.
  *
  * What is this file's own: which links share a pair (several transitions from
  * Home to Capture, each with its own condition, draw as ONE edge whose label
- * counts them), the words on that edge, and the two lists the side panel
- * shows for a selected screen.
+ * counts them), the words on that edge, where on the canvas those words sit
+ * (`placeLabels`), the curve every edge draws — each with its own height
+ * through the gap, so a hub's lines fan out instead of stacking
+ * (`trackedCurves`) — which line is under the pointer (`nearestEdge`), and
+ * the two lists the side panel shows for a selected screen.
  */
 
 import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
-import { buildMapLayout, linkId, type MapLayout } from './map-model';
+import {
+  buildMapLayout,
+  linkId,
+  portPoint,
+  PORT_PITCH,
+  type EdgeRoute,
+  type MapEdgeLayout,
+  type MapLayout,
+  type MapNodeLayout,
+} from './map-model';
+
+/* ------------------------------------------------------------- geometry -- */
+
+/**
+ * Vertical room between two rows of screens. The Map's 74px holds hairlines;
+ * this holds labels — five lanes of them (see {@link laneCount}) with their
+ * margins.
+ */
+export const SCREEN_LAYER_GAP = 116;
 
-/** The longest `when` a connector prints before an ellipsis; the tooltip has the rest. */
-const EDGE_LABEL_MAX = 30;
+/** The widest `level` arch in a fan rises this fraction of the layer gap above its row… */
+const LEVEL_RISE = 0.66;
+/** …and the narrowest this much less, so nested arches stay apart. */
+const LEVEL_NEST = 0.26;
+/** Points a curve is sampled at for hit-testing; at 116px tall, under a pixel off. */
+const HIT_SAMPLES = 24;
+
+/** IBM Plex Mono at 10.5px advances ~6.3px per character; the pill adds 6px each side. */
+export const PILL_CHAR_WIDTH = 6.3;
+export const PILL_PADDING = 12;
+export const PILL_HEIGHT = 17;
+/** From a box's edge to the centre of the first lane of pills beside it. */
+export const PILL_OFFSET = 13;
+/** From one lane to the next: a pill and 4px of paper. */
+export const LANE_STEP = PILL_HEIGHT + 4;
+/** Two pills on one lane keep this much paper between them. */
+const PILL_GAP_X = 4;
+/** The last lane keeps this much clear of the neighbouring row's boxes. */
+const BAND_MARGIN = 2;
+
+/**
+ * The longest label a pill prints before an ellipsis; the tooltip and the
+ * panel have the rest. Sized so the innermost clause of a typical guard
+ * (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole.
+ */
+export const EDGE_LABEL_MAX = 36;
+
+/* ---------------------------------------------------------------- model -- */
 
 export interface ScreenNodeInfo {
   id: string;
@@ -42,7 +98,7 @@ export interface ScreenEdgeInfo {
   to: string;
   /** Every transition between the pair — one connector, several stories. */
   links: WireScreenLink[];
-  /** The connector's short label: the condition, or how many transitions. */
+  /** The connector's short label: the innermost condition, or how many transitions. */
   label: string;
   synthesized: boolean;
 }
@@ -54,52 +110,100 @@ export interface ScreensModel {
   edges: Map<string, ScreenEdgeInfo>;
   /** Screens no chain of transitions reaches from the entry. */
   unreached: number;
+  /** The vertical room between rows the layout was built with. */
+  layerGap: number;
+  /** Every edge's curve, keyed by edge id — see {@link trackedCurves}. */
+  curves: Map<string, Curve>;
+  /** The same curves sampled for hit-testing — see {@link nearestEdge}. */
+  polylines: Map<string, Point[]>;
+}
+
+export interface Point {
+  x: number;
+  y: number;
 }
 
+/* ------------------------------------------------------------- layering -- */
+
 /**
  * Layer = distance from the entry screen: the entry on top, each row down one
- * more transition away. Origins (chrome, triggers outside any screen) count
- * as reachable seeds too, so what they open is placed below them. Whatever
- * nothing reaches sits in a band at the bottom, layered among itself by the
- * same rule from its own sources — a screen the graph cannot see anyone open
- * is a fact worth a place, not a crash.
+ * more transition away, measured over EVERY transition — the two-cycle break
+ * the Map performs for its own layering is irrelevant to a distance, so the
+ * links come in through the closure rather than through the argument the
+ * layout hands over.
+ *
+ * Origins (shared chrome, a store action after login) are not screens and
+ * have no distance of their own. Each hangs one row above the shallowest
+ * screen it opens, so what it opens is below it and what it opens is placed
+ * by the entry, not by the chrome: a top bar rendered on ten screens must not
+ * drag `/settings` up beside the home screen. An origin whose targets nothing
+ * else reaches seeds them from wherever it sits, so they are still placed.
+ *
+ * Whatever nothing reaches sits in a band at the bottom, layered among itself
+ * by the same rule from its own sources — a screen the graph cannot see
+ * anyone open is a fact worth a place, not a crash.
  */
-export function entryLayering(entry: string | null, seeds: readonly string[]) {
-  return (ids: string[], links: ReadonlyArray<{ source: string; target: string }>): Map<string, number> => {
+export function entryLayering(
+  entry: string | null,
+  origins: readonly string[],
+  links: ReadonlyArray<{ source: string; target: string }>
+) {
+  return (ids: string[]): Map<string, number> => {
+    const present = new Set(ids);
     const out = new Map<string, string[]>(ids.map((id) => [id, []]));
     const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
     for (const l of links) {
-      out.get(l.source)?.push(l.target);
+      if (!present.has(l.source) || !present.has(l.target) || l.source === l.target) continue;
+      out.get(l.source)!.push(l.target);
       indeg.set(l.target, (indeg.get(l.target) ?? 0) + 1);
     }
+    for (const list of out.values()) list.sort();
+
     const depth = new Map<string, number>();
-    const bfs = (starts: string[]) => {
-      let frontier = starts.filter((s) => !depth.has(s));
-      for (const s of frontier) depth.set(s, 0);
-      let d = 0;
-      while (frontier.length > 0) {
-        d++;
-        const next: string[] = [];
-        for (const id of frontier) {
-          for (const t of out.get(id) ?? []) {
-            if (depth.has(t)) continue;
-            depth.set(t, d);
-            next.push(t);
-          }
-        }
-        frontier = next;
+    // Multi-source BFS whose sources may start at different depths: buckets
+    // processed in ascending order, first assignment wins, so a node's depth
+    // is the least over every source — and a depth already set is never
+    // lowered by a later phase.
+    const walk = (starts: ReadonlyArray<[string, number]>): void => {
+      const buckets = new Map<number, string[]>();
+      const push = (id: string, d: number): void => {
+        if (depth.has(id)) return;
+        depth.set(id, d);
+        const list = buckets.get(d);
+        if (list) list.push(id);
+        else buckets.set(d, [id]);
+      };
+      let maxStart = 0;
+      for (const [id, d] of starts) {
+        push(id, d);
+        maxStart = Math.max(maxStart, d);
+      }
+      for (let d = 0; d <= ids.length + maxStart; d++) {
+        const list = buckets.get(d);
+        if (!list) continue;
+        for (const id of list) for (const t of out.get(id) ?? []) push(t, d + 1);
       }
     };
-    const roots = [entry, ...seeds].filter((s): s is string => s !== null && ids.includes(s));
-    bfs(roots);
+
+    // Phase 1: the screens, by distance from the entry.
+    if (entry !== null && present.has(entry)) walk([[entry, 0]]);
+    // Phase 2: each origin above its shallowest placed target; then whatever
+    // only the origins reach, from them.
+    const seeds: Array<[string, number]> = [];
+    for (const origin of [...origins].filter((o) => present.has(o)).sort()) {
+      const placed = (out.get(origin) ?? []).map((t) => depth.get(t)).filter((d): d is number => d !== undefined);
+      seeds.push([origin, placed.length > 0 ? Math.max(0, Math.min(...placed) - 1) : 0]);
+    }
+    walk(seeds);
+
     const reachedMax = Math.max(0, ...[...depth.values()]);
     // The unreached band: its own sources first, then whatever they open.
     const rest = ids.filter((id) => !depth.has(id));
     const restDepth = new Map<string, number>();
     if (rest.length > 0) {
+      const restSet = new Set(rest);
       const restSources = rest.filter((id) => (indeg.get(id) ?? 0) === 0);
-      const seedsRest = restSources.length > 0 ? restSources : [rest[0]!];
-      let frontier = seedsRest;
+      let frontier = restSources.length > 0 ? restSources : [rest[0]!];
       for (const s of frontier) restDepth.set(s, 0);
       let d = 0;
       while (frontier.length > 0) {
@@ -107,7 +211,7 @@ export function entryLayering(entry: string | null, seeds: readonly string[]) {
         const next: string[] = [];
         for (const id of frontier) {
           for (const t of out.get(id) ?? []) {
-            if (restDepth.has(t) || depth.has(t)) continue;
+            if (restDepth.has(t) || !restSet.has(t)) continue;
             restDepth.set(t, d);
             next.push(t);
           }
@@ -127,25 +231,79 @@ export function entryLayering(entry: string | null, seeds: readonly string[]) {
   };
 }
 
-/** What the connector says. Empty when unconditional and single. */
+/* --------------------------------------------------------------- labels -- */
+
+/**
+ * The top-level `&&` terms of a condition, in the order they were tested —
+ * the outermost guard first, the one decided at the navigation call last.
+ * Brackets and strings are respected; a condition joined by a top-level `||`
+ * (two transitions between one pair that merged) has no innermost term and
+ * comes back whole.
+ */
+export function clauses(when: string): string[] {
+  const out: string[] = [];
+  let depth = 0;
+  let quote: string | null = null;
+  let start = 0;
+  for (let i = 0; i < when.length; i++) {
+    const ch = when[i]!;
+    if (quote !== null) {
+      if (ch === '\\') i++;
+      else if (ch === quote) quote = null;
+      continue;
+    }
+    if (ch === "'" || ch === '"' || ch === '`') {
+      quote = ch;
+      continue;
+    }
+    if (ch === '(' || ch === '[' || ch === '{') {
+      depth++;
+      continue;
+    }
+    if (ch === ')' || ch === ']' || ch === '}') {
+      depth = Math.max(0, depth - 1);
+      continue;
+    }
+    if (depth !== 0) continue;
+    if (when.startsWith(' || ', i)) return [when.trim()];
+    if (when.startsWith(' && ', i)) {
+      out.push(when.slice(start, i).trim());
+      start = i + 4;
+      i += 3;
+    }
+  }
+  out.push(when.slice(start).trim());
+  return out.filter((c) => c.length > 0);
+}
+
+/**
+ * What the connector says. Empty when unconditional and single.
+ *
+ * A single transition is labelled with its innermost condition — the one
+ * checked right at the navigation call — with an ellipsis in front when outer
+ * guards precede it. The whole chain is often seventy characters and its
+ * first thirty are usually shared with a sibling (`!loading && !(!objectId
+ * …` on both arms of a fork); the last clause is the one that tells the two
+ * apart, and the full text is a hover away.
+ */
 export function edgeLabel(links: readonly WireScreenLink[]): string {
   if (links.length === 1) {
     const when = links[0]!.when;
     if (!when) return '';
-    return when.length > EDGE_LABEL_MAX ? `${when.slice(0, EDGE_LABEL_MAX - 1)}…` : when;
+    const parts = clauses(when);
+    const last = parts[parts.length - 1] ?? when;
+    const text = parts.length > 1 ? `…${last}` : last;
+    return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
   }
   const conditional = links.filter((l) => l.when).length;
   return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
 }
 
+/* ---------------------------------------------------------------- build -- */
+
 export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
   const nodes = new Map<string, ScreenNodeInfo>();
   const modules: WireMapModule[] = [];
-  const used = new Set<string>();
-  for (const link of payload.links) {
-    used.add(link.from);
-    used.add(link.to);
-  }
 
   for (const screen of payload.screens) {
     const info: ScreenNodeInfo = {
@@ -240,10 +398,16 @@ export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
         const info = nodes.get(m.id);
         return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
       },
-      layering: entryLayering(payload.entry, seeds),
+      layering: entryLayering(payload.entry, seeds, links),
+      layerGap: SCREEN_LAYER_GAP,
+      portPitch: PORT_PITCH,
+      ports: 'directional',
     }
   );
-  return { layout, nodes, edges, unreached };
+  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, unreached, layerGap: SCREEN_LAYER_GAP, curves, polylines };
 }
 
 function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
@@ -275,3 +439,416 @@ export function neighbourhood(
 export function viaText(link: WireScreenLink): string {
   return link.via.map((v) => v.name).join(' → ');
 }
+
+/** The layout edge a transition draws as, or null when it is a self-loop. */
+export function pairId(link: WireScreenLink): string | null {
+  return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
+}
+
+/* ---------------------------------------------------------------- curve -- */
+
+/** A cubic Bézier: the point it leaves, two controls, the point it reaches. */
+export interface Curve {
+  x0: number;
+  y0: number;
+  x1: number;
+  y1: number;
+  x2: number;
+  y2: number;
+  x3: number;
+  y3: number;
+}
+
+/**
+ * The curve an edge draws, from its source port to its target port.
+ *
+ * `down` and `up` are the Map's cubic: it leaves and arrives vertically, with
+ * both control points at one height — the midpoint unless a `track` says
+ * otherwise (see {@link trackedCurves}). `level` joins two boxes on one row
+ * from top to top, arching over the row — the only shape that touches neither
+ * box on the way; `track` is then how far the arch rises. The same arithmetic
+ * places the labels and answers the pointer, so a pill sits on the line the
+ * browser draws and the line under the cursor is the one that lights.
+ */
+export function screenCurve(
+  route: EdgeRoute,
+  sx: number,
+  sy: number,
+  tx: number,
+  ty: number,
+  layerGap = SCREEN_LAYER_GAP,
+  track?: number
+): Curve {
+  if (route === 'level') {
+    const rise = track ?? Math.round(layerGap * LEVEL_RISE);
+    return { x0: sx, y0: sy, x1: sx, y1: sy - rise, x2: tx, y2: ty - rise, x3: tx, y3: ty };
+  }
+  const midY = track ?? (sy + ty) / 2;
+  return { x0: sx, y0: sy, x1: sx, y1: midY, x2: tx, y2: midY, x3: tx, y3: ty };
+}
+
+/** The SVG path of a curve. */
+export function pathOf(c: Curve): string {
+  return `M${c.x0},${c.y0} C${c.x1},${c.y1} ${c.x2},${c.y2} ${c.x3},${c.y3}`;
+}
+
+/** The SVG path of {@link screenCurve}. */
+export function screenEdgePath(
+  route: EdgeRoute,
+  sx: number,
+  sy: number,
+  tx: number,
+  ty: number,
+  layerGap = SCREEN_LAYER_GAP
+): string {
+  return pathOf(screenCurve(route, sx, sy, tx, ty, layerGap));
+}
+
+/* --------------------------------------------------------------- tracks -- */
+
+/**
+ * Every edge's curve, each with its own height through the gap.
+ *
+ * Drawn through one midpoint, every line between two rows crosses that height
+ * at its middle, and a line to a screen far to the side is nearly horizontal
+ * there — so a hub's lines run stacked within a few pixels for hundreds, and
+ * no pointer can pick one. Instead each line in a fan takes a track of its
+ * own. A fan is the set of lines leaving one side of one box towards one
+ * side; a line belongs to the bigger of the two fans at its ends (the upper
+ * one on a tie). Within a fan the line whose far end is farthest out runs on
+ * the track nearest the fan's own row, the next one a track further out, and
+ * so on: nested, in the same order the ports along the box are, so no line in
+ * a fan crosses another. A line spanning several rows keeps its track inside
+ * the gap beside its fan and drops the rest of the way vertically. Level
+ * arches nest the same way — the wider arch rises higher.
+ */
+export function trackedCurves(layout: MapLayout, layerGap: number): Map<string, Curve> {
+  const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
+  const fanSize = (node: MapNodeLayout, side: 'top' | 'bottom'): number => node.ports[side].length;
+  interface Member {
+    edge: MapEdgeLayout;
+    s: Point;
+    t: Point;
+    /** How far out the far end sits from the fan's box; the nesting order. */
+    reach: number;
+    /** For a `down`/`up` edge: the fan is at the upper end. */
+    pivotUpper: boolean;
+    up: Point;
+    lo: Point;
+  }
+  const groups = new Map<string, Member[]>();
+  for (const edge of layout.edges) {
+    const from = nodes.get(edge.source);
+    const to = nodes.get(edge.target);
+    if (!from || !to) continue;
+    const s = portPoint(from, edge.id, 'source');
+    const t = portPoint(to, edge.id, 'target');
+    let pivot: MapNodeLayout;
+    let side: 'top' | 'bottom';
+    let other: Point;
+    let pivotUpper = true;
+    let up = s;
+    let lo = t;
+    if (edge.route === 'level') {
+      pivot = fanSize(to, 'top') > fanSize(from, 'top') ? to : from;
+      side = 'top';
+      other = pivot === from ? t : s;
+    } else {
+      const upperIsSource = edge.route === 'down';
+      const upper = upperIsSource ? from : to;
+      const lower = upperIsSource ? to : from;
+      up = upperIsSource ? s : t;
+      lo = upperIsSource ? t : s;
+      pivotUpper = fanSize(upper, 'bottom') >= fanSize(lower, 'top');
+      pivot = pivotUpper ? upper : lower;
+      side = pivotUpper ? 'bottom' : 'top';
+      other = pivotUpper ? lo : up;
+    }
+    const centre = pivot.x + pivot.width / 2;
+    const key = `${pivot.id}\u0000${side}\u0000${other.x < centre ? 'L' : 'R'}`;
+    const list = groups.get(key) ?? [];
+    list.push({ edge, s, t, reach: Math.abs(other.x - centre), pivotUpper, up, lo });
+    groups.set(key, list);
+  }
+
+  const curves = new Map<string, Curve>();
+  for (const members of groups.values()) {
+    members.sort((a, b) => b.reach - a.reach || a.edge.id.localeCompare(b.edge.id));
+    const n = members.length;
+    members.forEach((m, k) => {
+      const { edge, s, t } = m;
+      if (edge.route === 'level') {
+        const rise = Math.round(layerGap * (LEVEL_RISE - (LEVEL_NEST * k) / Math.max(1, n - 1)));
+        curves.set(edge.id, screenCurve('level', s.x, s.y, t.x, t.y, layerGap, rise));
+        return;
+      }
+      const f = (k + 1) / (n + 1);
+      const span = Math.min(m.lo.y - m.up.y, layerGap);
+      const track = m.pivotUpper ? m.up.y + span * f : m.lo.y - span * f;
+      curves.set(edge.id, screenCurve(edge.route, s.x, s.y, t.x, t.y, layerGap, track));
+    });
+  }
+  return curves;
+}
+
+/* ------------------------------------------------------------ pointing -- */
+
+/** The curve as `count` points from source to target, for distance tests. */
+export function samplePolyline(c: Curve, count = HIT_SAMPLES): Point[] {
+  const out: Point[] = [];
+  for (let i = 0; i < count; i++) out.push(pointAt(c, i / (count - 1)));
+  return out;
+}
+
+function distanceToSegment(p: Point, a: Point, b: Point): number {
+  const dx = b.x - a.x;
+  const dy = b.y - a.y;
+  const len2 = dx * dx + dy * dy;
+  const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
+  const x = a.x + t * dx - p.x;
+  const y = a.y + t * dy - p.y;
+  return Math.sqrt(x * x + y * y);
+}
+
+export function distanceToPolyline(p: Point, line: readonly Point[]): number {
+  let best = Infinity;
+  for (let i = 1; i < line.length; i++) best = Math.min(best, distanceToSegment(p, line[i - 1]!, line[i]!));
+  return best;
+}
+
+export interface EdgeHit {
+  id: string;
+  distance: number;
+}
+
+/**
+ * The edge nearest `point` within `reach`, among the ids given (every edge
+ * when null). This is what hovering means on the canvas: not "the topmost hit
+ * path under the pointer", which in a bundle of eight lines four pixels apart
+ * is whichever one the DOM drew last, but the line the pointer is closest to
+ * — so moving three pixels moves to the next line, predictably. Ties go to
+ * the smaller id, so two visits agree.
+ */
+export function nearestEdge(
+  model: ScreensModel,
+  point: Point,
+  among: ReadonlySet<string> | null,
+  reach: number
+): EdgeHit | null {
+  let best: EdgeHit | null = null;
+  for (const [id, line] of model.polylines) {
+    if (among !== null && !among.has(id)) continue;
+    const distance = distanceToPolyline(point, line);
+    if (distance > reach) continue;
+    if (best === null || distance < best.distance || (distance === best.distance && id < best.id)) {
+      best = { id, distance };
+    }
+  }
+  return best;
+}
+
+/** The point at `t` on the curve, 0 = source, 1 = target. */
+export function pointAt(c: Curve, t: number): { x: number; y: number } {
+  const u = 1 - t;
+  const a = u * u * u;
+  const b = 3 * u * u * t;
+  const d = 3 * u * t * t;
+  const e = t * t * t;
+  return { x: a * c.x0 + b * c.x1 + d * c.x2 + e * c.x3, y: a * c.y0 + b * c.y1 + d * c.y2 + e * c.y3 };
+}
+
+/**
+ * The parameter at which the curve passes height `y`, or null when it never
+ * does. A `down`/`up` curve is monotonic in y end to end; a `level` arch
+ * rises and falls, so it is searched on the half nearest `end`.
+ */
+export function tAtY(c: Curve, y: number, end: 'source' | 'target'): number | null {
+  const arch = c.y0 === c.y3;
+  let lo = arch && end === 'target' ? 0.5 : 0;
+  let hi = arch && end === 'source' ? 0.5 : 1;
+  const yLo = pointAt(c, lo).y;
+  const yHi = pointAt(c, hi).y;
+  if (y < Math.min(yLo, yHi) - 1e-6 || y > Math.max(yLo, yHi) + 1e-6) return null;
+  const rising = yHi > yLo;
+  for (let i = 0; i < 40; i++) {
+    const mid = (lo + hi) / 2;
+    if (pointAt(c, mid).y < y === rising) lo = mid;
+    else hi = mid;
+  }
+  return (lo + hi) / 2;
+}
+
+/* ---------------------------------------------------------------- pills -- */
+
+export interface PillPlacement {
+  edge: string;
+  text: string;
+  /** Centre of the pill, in canvas coordinates. */
+  x: number;
+  y: number;
+  /** Estimated from the text; what the lane arithmetic reserved. */
+  width: number;
+  lane: number;
+  /** The end of the edge the pill sits at. */
+  end: 'source' | 'target';
+}
+
+export interface PillLayout {
+  pills: Map<string, PillPlacement>;
+  /** Labels that found no lane; the panel says so. */
+  hidden: number;
+}
+
+export function pillWidth(text: string): number {
+  return text.length * PILL_CHAR_WIDTH + PILL_PADDING;
+}
+
+/** How many lanes of pills fit between two rows `layerGap` apart. */
+export function laneCount(layerGap: number): number {
+  return Math.max(1, Math.floor((layerGap - BAND_MARGIN - PILL_HEIGHT / 2 - PILL_OFFSET) / LANE_STEP) + 1);
+}
+
+/**
+ * The words on a pill: an arrow for which way the transition runs relative to
+ * the selected screen — `→` leaving it, `←` arriving — and the edge's label.
+ * Empty when the edge has nothing to say (a single, unconditional transition).
+ */
+export function pillText(info: ScreenEdgeInfo, edge: MapEdgeLayout, selected: string | null): string {
+  if (!info.label) return '';
+  const arriving = selected !== null && edge.target === selected && edge.source !== selected;
+  return `${arriving ? '←' : '→'} ${info.label}`;
+}
+
+interface Rect {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+}
+
+function intersects(a: Rect, b: Rect, gapX: number): boolean {
+  return a.x < b.x + b.w + gapX && b.x < a.x + a.w + gapX && a.y < b.y + b.h && b.y < a.y + a.h;
+}
+
+/**
+ * Lay a pill beside the box at `end` of the edge: the first lane whose pill
+ * would overlap nothing already placed, walking away from the box one lane
+ * at a time, each pill centred on its own line at that height. Null when no
+ * lane is free — or when `lanes` is 1 and that lane is taken.
+ */
+function layPill(
+  model: ScreensModel,
+  edge: MapEdgeLayout,
+  end: 'source' | 'target',
+  text: string,
+  nodes: Map<string, MapNodeLayout>,
+  lanes: number,
+  taken: Rect[],
+  bounds: { width: number; height: number }
+): { pill: PillPlacement; rect: Rect } | null {
+  const curve = model.curves.get(edge.id);
+  const box = nodes.get(end === 'source' ? edge.source : edge.target);
+  if (!curve || !box) return null;
+  const port = end === 'source' ? { x: curve.x0, y: curve.y0 } : { x: curve.x3, y: curve.y3 };
+  const above = port.y === box.y;
+  const width = pillWidth(text);
+  for (let lane = 0; lane < lanes; lane++) {
+    const off = PILL_OFFSET + lane * LANE_STEP;
+    const y = above ? port.y - off : port.y + off;
+    const t = tAtY(curve, y, end);
+    if (t === null) return null;
+    const x = pointAt(curve, t).x;
+    const rect = { x: x - width / 2, y: y - PILL_HEIGHT / 2, w: width, h: PILL_HEIGHT };
+    if (rect.y < 0 || rect.y + rect.h > bounds.height) return null;
+    if (taken.some((r) => intersects(r, rect, PILL_GAP_X))) continue;
+    return { pill: { edge: edge.id, text, x, y, width, lane, end }, rect };
+  }
+  return null;
+}
+
+/**
+ * Where the selected screen's labels go.
+ *
+ * Every pill sits at the FAR end of its line — beside the other screen, where
+ * the lines are apart — never beside the selected one, where fifteen of them
+ * share a box's width and no label can belong to one of them. Pills are laid
+ * left to right, each in the first free lane walking away from its box, and
+ * a pill that finds no lane is not drawn but counted, so the panel can say
+ * so. The boxes themselves are obstacles, so a lane in the margin above the
+ * top row cannot land a pill on a screen.
+ *
+ * A pure function of the model and the selection, so hovering never moves a
+ * pill: the pill for a hovered edge that is not the selected screen's is
+ * placed separately by {@link hoverPill}.
+ */
+export function placeLabels(model: ScreensModel, selected: string | null): PillLayout {
+  const pills = new Map<string, PillPlacement>();
+  if (selected === null) return { pills, hidden: 0 };
+  const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
+  const lanes = laneCount(model.layerGap);
+  const bounds = { width: model.layout.width, height: model.layout.height };
+  const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
+
+  const candidates = model.layout.edges
+    .filter((e) => e.source === selected || e.target === selected)
+    .map((edge) => {
+      const end: 'source' | 'target' = edge.source === selected ? 'target' : 'source';
+      const far = nodes.get(end === 'source' ? edge.source : edge.target);
+      const anchor = far ? portPoint(far, edge.id, end) : { x: 0, y: 0 };
+      return { edge, end, anchor };
+    })
+    .sort((a, b) => a.anchor.x - b.anchor.x || a.anchor.y - b.anchor.y || a.edge.id.localeCompare(b.edge.id));
+
+  let hidden = 0;
+  for (const { edge, end } of candidates) {
+    const info = model.edges.get(edge.id);
+    if (!info) continue;
+    const text = pillText(info, edge, selected);
+    if (!text) continue;
+    const laid = layPill(model, edge, end, text, nodes, lanes, taken, bounds);
+    if (laid === null) {
+      hidden++;
+      continue;
+    }
+    pills.set(edge.id, laid.pill);
+    taken.push(laid.rect);
+  }
+  return { pills, hidden };
+}
+
+/**
+ * The pill for a hovered edge that has no place in {@link placeLabels} —
+ * nothing is selected, or the edge has nothing to say at rest. It sits at the
+ * edge's target end (its source end when the selected screen is the target),
+ * in the first lane clear of the pills in `avoid` and of every box; when
+ * there is none it takes the first lane anyway, on top of whatever is there —
+ * it is transient, and the line under the pointer is the one the reader is
+ * asking about. `text` overrides the pill's words — the panel hovers a row
+ * with the whole condition, not the connector's short label.
+ */
+export function hoverPill(
+  model: ScreensModel,
+  edgeId: string,
+  selected: string | null,
+  text?: string,
+  avoid?: PillLayout
+): PillPlacement | null {
+  const edge = model.layout.edges.find((e) => e.id === edgeId);
+  const info = edge ? model.edges.get(edge.id) : undefined;
+  if (!edge || !info) return null;
+  const words = text ?? pillText(info, edge, selected);
+  if (!words) return null;
+  const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
+  const end: 'source' | 'target' = selected !== null && edge.target === selected && edge.source !== selected ? 'source' : 'target';
+  const bounds = { width: model.layout.width, height: model.layout.height };
+  const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
+  for (const pill of avoid?.pills.values() ?? []) {
+    if (pill.edge === edgeId) continue;
+    taken.push({ x: pill.x - pill.width / 2, y: pill.y - PILL_HEIGHT / 2, w: pill.width, h: PILL_HEIGHT });
+  }
+  return (
+    layPill(model, edge, end, words, nodes, laneCount(model.layerGap), taken, bounds)?.pill ??
+    layPill(model, edge, end, words, nodes, 1, [], bounds)?.pill ??
+    null
+  );
+}

+ 188 - 39
ui/src/views/ScreensView.svelte

@@ -8,9 +8,16 @@
   render/call chain to the screen a transition starts on, and branch guards
   read from the source. The canvas is the Map's machinery with different
   words (see `screens-model.ts`); the side panel is where the sentences are.
+
+  At rest the picture is boxes and lines. Selecting a screen labels its
+  transitions — each pill at the FAR end of its line, beside the other screen,
+  in a lane the model chose so that no two overlap — and lists them in the
+  panel; the panel and the picture point at each other, so a row under the
+  pointer lights its line and prints its whole condition on it. On the canvas
+  the pointer means the line NEAREST it, not the one drawn last under it.
 -->
 <script lang="ts">
-  import { SvelteFlow, Controls, type Node, type Edge } from '@xyflow/svelte';
+  import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
   import '@xyflow/svelte/dist/style.css';
   import ScreenNode from '../components/screens/ScreenNode.svelte';
   import ScreenEdge from '../components/screens/ScreenEdge.svelte';
@@ -19,14 +26,29 @@
   import { live } from '../lib/live.svelte';
   import { symbolHref, fileHref } from '../lib/navigation';
   import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
-  import { buildScreensModel, neighbourhood, viaText, type ScreensModel } from '../lib/screens-model';
+  import {
+    buildScreensModel,
+    hoverPill,
+    nearestEdge,
+    neighbourhood,
+    pairId,
+    placeLabels,
+    viaText,
+    type ScreensModel,
+  } from '../lib/screens-model';
 
   let payload = $state<WireScreensPayload | null>(null);
   let error = $state<string | null>(null);
   let loading = $state(true);
   let selected = $state<string | null>(null);
   let hovered = $state<{ edge: MapEdgeLayout; x: number; y: number } | null>(null);
+  /** The panel row under the pointer: its edge on the canvas, and the one transition it names. */
+  let panelHot = $state<{ edge: string; link: WireScreenLink } | null>(null);
   let stage = $state<HTMLDivElement | null>(null);
+  /** Svelte Flow's pan and zoom, for turning a pointer position into a point on the canvas. */
+  let viewport = $state<Viewport | undefined>(undefined);
+  /** How close, in screen pixels, the pointer must be to a line to mean it. */
+  const HOVER_REACH = 10;
 
   // The key stays open until the reader closes it; the choice survives a
   // reload but is per browser — a preference, not a fact about the project.
@@ -83,6 +105,18 @@
     return set;
   });
 
+  // The labels move with the selection and with nothing else: hovering a
+  // line or a row must not reflow the pills the reader is looking at.
+  const pills = $derived(model === null ? null : placeLabels(model, selected));
+  /** The edge in focus: under the pointer on the canvas, or its row in the panel. */
+  const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
+  /** A pill for the focused edge when the selection gave it none. */
+  const focusPill = $derived.by(() => {
+    if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
+    const full = panelHot?.edge === focusId ? fullText(panelHot.link) : undefined;
+    return hoverPill(model, focusId, selected, full, pills ?? undefined);
+  });
+
   const nodes = $derived.by<Node[]>(() => {
     if (model === null) return [];
     return model.layout.nodes.map((node) => ({
@@ -100,6 +134,7 @@
         onSelect: (id: string) => {
           selected = selected === id ? null : id;
           hovered = null;
+          panelHot = null;
         },
       },
     }));
@@ -107,31 +142,39 @@
 
   const edges = $derived.by<Edge[]>(() => {
     if (model === null) return [];
+    const focus = focusId;
     return model.layout.edges
       .filter((edge) => isEdgeVisible(edge, selected))
-      .map((edge) => ({
-        id: edge.id,
-        source: edge.source,
-        target: edge.target,
-        sourceHandle: edge.sourceHandle,
-        targetHandle: edge.targetHandle,
-        type: 'screen',
-        selectable: false,
-        deletable: false,
-        data: {
-          edge,
-          info: model.edges.get(edge.id)!,
-          hot: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
-          dimmed: selected !== null && edge.source !== selected && edge.target !== selected,
-          // A label only where it can be read as belonging to one line: the
-          // selected screen's own edges (near that screen) and the hovered
-          // edge (near its source). Unselected, the picture is lines and
-          // boxes; the conditions are one click away.
-          labelled: hovered?.edge.id === edge.id || (selected !== null && (edge.source === selected || edge.target === selected)),
-          labelAt: selected !== null && edge.target === selected && edge.source !== selected ? 0.72 : 0.28,
-          onHover: onEdgeHover,
-        },
-      }));
+      .map((edge) => {
+        const touches = selected !== null && (edge.source === selected || edge.target === selected);
+        const isFocus = focus === edge.id;
+        const hot = isFocus || touches;
+        return {
+          id: edge.id,
+          source: edge.source,
+          target: edge.target,
+          sourceHandle: edge.sourceHandle,
+          targetHandle: edge.targetHandle,
+          type: 'screen',
+          selectable: false,
+          deletable: false,
+          // The line in focus, and its pill, over the others; the selected
+          // screen's over the rest.
+          zIndex: isFocus ? 3 : hot ? 2 : 1,
+          data: {
+            edge,
+            info: model.edges.get(edge.id)!,
+            curve: model.curves.get(edge.id)!,
+            hot,
+            soft: hot && focus !== null && !isFocus,
+            focus: isFocus,
+            dimmed: selected !== null && !touches,
+            pill: pills?.pills.get(edge.id) ?? (isFocus ? focusPill : null),
+            full: panelHot?.edge === edge.id ? fullText(panelHot.link) : null,
+            onHover: onEdgeHover,
+          },
+        };
+      });
   });
 
   const selectedInfo = $derived(selected === null || model === null ? null : (model.nodes.get(selected) ?? null));
@@ -140,6 +183,11 @@
   );
   const hoveredInfo = $derived(hovered === null || model === null ? null : (model.edges.get(hovered.edge.id) ?? null));
 
+  const edgeById = $derived(
+    model === null ? new Map<string, MapEdgeLayout>() : new Map(model.layout.edges.map((e) => [e.id, e]))
+  );
+  const visibleIds = $derived(new Set(edges.map((e) => e.id)));
+
   function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
     if (edge === null || event === null || stage === null) {
       hovered = null;
@@ -153,6 +201,62 @@
     };
   }
 
+  /**
+   * The pointer on the canvas means the line nearest it. A pill speaks for
+   * its own line; over a box, the key or the tooltip there is no line.
+   */
+  function onStageMove(event: MouseEvent): void {
+    if (model === null || stage === null) return;
+    const target = event.target as Element | null;
+    if (target?.closest('.spill')) return;
+    if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
+      hovered = null;
+      return;
+    }
+    const view = viewport ?? readViewport();
+    if (!view) return;
+    const box = stage.getBoundingClientRect();
+    const point = {
+      x: (event.clientX - box.left - view.x) / view.zoom,
+      y: (event.clientY - box.top - view.y) / view.zoom,
+    };
+    const hit = nearestEdge(model, point, visibleIds, HOVER_REACH / view.zoom);
+    const edge = hit === null ? undefined : edgeById.get(hit.id);
+    if (!edge) {
+      hovered = null;
+      return;
+    }
+    hovered = {
+      edge,
+      x: Math.min(event.clientX - box.left + 14, box.width - 360),
+      y: event.clientY - box.top + 14,
+    };
+  }
+
+  /** The transform Svelte Flow applied, for the moment before the binding has a value. */
+  function readViewport(): Viewport | null {
+    const el = stage?.querySelector<HTMLElement>('.svelte-flow__viewport');
+    const m = el?.style.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)\s*scale\(([-\d.]+)\)/);
+    return m ? { x: Number(m[1]), y: Number(m[2]), zoom: Number(m[3]) } : null;
+  }
+
+  /** The row under the pointer: light its line, and say the whole condition on it. */
+  function onRowHover(link: WireScreenLink | null): void {
+    const edge = link === null ? null : pairId(link);
+    panelHot = link === null || edge === null ? null : { edge, link };
+  }
+
+  /** The words a panel row puts on its line: the arrow, and the whole condition. */
+  function fullText(link: WireScreenLink): string {
+    const arriving = selected !== null && link.to === selected && link.from !== selected;
+    return `${arriving ? '←' : '→'} ${link.when || 'always'}`;
+  }
+
+  function rowHot(link: WireScreenLink): boolean {
+    if (panelHot !== null) return panelHot.link.id === link.id;
+    return hovered !== null && pairId(link) === hovered.edge.id;
+  }
+
   function nameOf(id: string): string {
     return model?.nodes.get(id)?.label ?? id;
   }
@@ -165,7 +269,7 @@
 </script>
 
 <div class="screens">
-  <div class="stage" bind:this={stage}>
+  <div class="stage" bind:this={stage} role="presentation" onmousemove={onStageMove} onmouseleave={() => (hovered = null)}>
     {#if error !== null}
       <div class="state">
         <h2>The screens could not be read</h2>
@@ -191,8 +295,9 @@
         {edgeTypes}
         fitView
         {...FIT}
+        bind:viewport
         minZoom={0.2}
-        maxZoom={1.6}
+        maxZoom={3}
         nodesDraggable={false}
         nodesConnectable={false}
         elementsSelectable={false}
@@ -201,6 +306,7 @@
         onpaneclick={() => {
           selected = null;
           hovered = null;
+          panelHot = null;
         }}
       >
         <Controls position="bottom-right" showLock={false} />
@@ -225,11 +331,14 @@
             </div>
             <div class="lrow">
               <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
-              <span>Goes back up the picture (returning)</span>
+              <span>Goes back up the picture (returning) — leaves the top of its box, arrives at the bottom of the other</span>
             </div>
             <div class="lrow">
-              <span class="k-label mono">when x</span>
-              <span>Runs only under that condition (shown on the selected screen's lines); none = always</span>
+              <span class="k-label mono">→ …x</span>
+              <span>
+                The last condition checked before the transition, beside the screen at the other end of
+                the selected screen's line; ← when it arrives there. None = always
+              </span>
             </div>
             <div class="lrow">
               <span class="k-box mono">/path</span>
@@ -241,7 +350,7 @@
             </div>
             <div class="lrow">
               <span class="k-box k-origin mono">fn()</span>
-              <span>Not a screen: shared chrome, or a trigger no screen reaches</span>
+              <span>Not a screen: shared chrome, or a trigger no screen reaches — a row above what it opens</span>
             </div>
             <div class="lrow">
               <span class="k-box k-unreached mono">/path</span>
@@ -286,6 +395,12 @@
           </div>
           <button class="clear" onclick={() => (selected = null)}>clear</button>
         </div>
+        {#if pills !== null && pills.hidden > 0}
+          <p class="dim note">
+            {pills.hidden} condition{pills.hidden === 1 ? '' : 's'} not drawn on the picture for want of
+            room — hover a row below to see {pills.hidden === 1 ? 'it' : 'each'} on its line.
+          </p>
+        {/if}
 
         <h4>Opens from <span class="dim">{lists.opensFrom.length}</span></h4>
         {#if lists.opensFrom.length === 0}
@@ -294,7 +409,15 @@
           </p>
         {/if}
         {#each lists.opensFrom as link (link.id)}
-          <div class="row">
+          <div
+            class="row"
+            class:hot={rowHot(link)}
+            role="presentation"
+            onmouseenter={() => onRowHover(link)}
+            onmouseleave={() => onRowHover(null)}
+            onfocusin={() => onRowHover(link)}
+            onfocusout={() => onRowHover(null)}
+          >
             <button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
             {#if link.when}<div class="when">when {link.when}</div>{/if}
             {#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
@@ -309,7 +432,15 @@
         <h4>Goes to <span class="dim">{lists.goesTo.length}</span></h4>
         {#if lists.goesTo.length === 0}<p class="dim">No navigation leaves this screen.</p>{/if}
         {#each lists.goesTo as link (link.id)}
-          <div class="row">
+          <div
+            class="row"
+            class:hot={rowHot(link)}
+            role="presentation"
+            onmouseenter={() => onRowHover(link)}
+            onmouseleave={() => onRowHover(null)}
+            onfocusin={() => onRowHover(link)}
+            onfocusout={() => onRowHover(null)}
+          >
             <button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
             {#if link.when}<div class="when">when {link.when}</div>{/if}
             {#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
@@ -330,15 +461,16 @@
         </p>
         <p class="dim">
           <span class="mark">●</span> The entry screen is at the top; each row down is one more
-          transition away from it. An arrow's label is the condition under which that transition
-          happens, read from the code around the navigation call; hover it for the chain the call
-          travels through, click a screen for everything in and out of it.
+          transition away from it. Click a screen and each of its transitions is labelled at the far
+          end of its line — beside the screen it leads to or comes from — with the last condition
+          checked before it happens; hover the line, or its row here, for the whole chain and the
+          calls it travels through.
         </p>
         <p class="dim">
           Solid: the destination is written at the call. Dashed grey: it comes back from a
           helper's return value (inferred). Dashed accent: a transition back up the picture
-          (returning). Dashed box: a trigger that is not a screen — shared chrome, or code no
-          screen's render chain reaches.
+          (returning), drawn around the boxes rather than through them. Dashed box: a trigger that
+          is not a screen — shared chrome, or code no screen's render chain reaches.
         </p>
         {#if model.unreached > 0}
           <p class="dim">
@@ -385,6 +517,11 @@
     border: 0;
     pointer-events: none;
   }
+  /* The label layer covers the canvas; only the pills in it take the pointer,
+     never the empty paper between them — the lines underneath do. */
+  .stage :global(.svelte-flow__edge-labels) {
+    pointer-events: none;
+  }
   .stage :global(.svelte-flow__controls-button) {
     background: var(--paper);
     border: 0;
@@ -536,13 +673,25 @@
     padding: 1px 7px;
     cursor: pointer;
   }
+  .note {
+    margin: 0 0 6px;
+  }
   h4 {
     margin: 16px 0 6px;
     font: 600 12.5px var(--sans);
   }
+  /* A row is also a pointer at its line: hovering it lights the line and
+     prints the whole condition on it, and the line under the pointer on the
+     canvas tints its row here. Bled to the panel's edges so the tint reads
+     as a row, not a box inside one. */
   .row {
-    padding: 7px 0;
+    padding: 7px 8px;
+    margin: 0 -8px;
     border-top: 1px solid var(--rule-soft);
+    transition: background 90ms linear;
+  }
+  .row.hot {
+    background: var(--press);
   }
   .peer {
     display: block;