diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e21797..f92c901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps. + +- **A dialog's buttons fire what they run.** `Alert.prompt('Add Folder', …, [{ onPress: (name) => createBackgroundFolder(name) }])` is two facts: the prompt is a call that leaves the index, and its button fires the handler. The handler's line now arrives from the dialog's own box — with the condition on it — instead of from the screen, so the confirm-then-act chains a mobile app is full of read as chains: the delete alert leads to the delete, which leads to the request it sends. The same holds for anything bound inside the arguments of a call that leaves the index. Nothing needs a re-index: it is read from the source at request time. + - **The Steps tab draws a handler in the order its code runs.** The picture of what an endpoint sets in motion put the lookup, the token signing, the 200 and the 401 side by side, because each is one step from the anchor — true, and not how the code reads. Now a handler, an endpoint or any function opens as the same picture laid out by *when* things happen: a line means **and then**, so the 200 sits below the token signing it is built from and the 401 branches off the check that chose it. Where the code forks — an `if`, a `switch`, a `try`, an early exit — the line says what has to hold, and an arm that answers the request, returns or throws simply has nothing leaving it. A call written inside another call's arguments happens first, so the token is signed before the reply that carries it. A helper is drawn where it is called (`via generateToken`), a body that repeats says so (`for each item of items`), and work registered to run later (`later · then`) or started at once (`together · Promise.all`) says that rather than pretending to be a sequence. A screen still opens as before — its handlers fire on events and have no order between them — and either reading is one click, or one `&view=order` / `&view=tree` in the link, away. Nothing to re-index: it is read from the source at request time, and where the conditions cannot be read the picture is a plain sequence rather than an invented structure. - **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; ``, an internal ``, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading. @@ -40,6 +44,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up. + +- **A handler called from under a binding says what it passes.** A press that runs `tryCatchSync(onClosePress)` drew a box for the wrapper and stopped — leaving the one thing a reader asks ("what is being wrapped?") unsaid, even though every other call-shaped site already prints its arguments. The panel and tooltip now say `tryCatchSync(onClosePress)` — the argument is the answer. + +- **A step the walk stopped at keeps its whole name.** A boundary — another screen, or a cap the walk hit — ends its name with an ellipsis by design, but the box was not sized for it, so a longer name lost its last letters instead (`/scan-to-verif…` for `/scan-to-verify …`). The anchor's start mark clipped a long path the same way (`/sheets/forgot-passw…`). The box now makes room for both. + +- **A screen that talks to native code keeps its own navigations.** In a React Native or Expo app, a `router.push` written inside a listener for a native event was credited to whichever screen had *started* that round trip, not to the screen the push is written on. In one app that moved seven transitions off the capture screen and onto the review screen it opens — leaving the review screen looking as though nothing in the app could reach it, stranded in the "no transition reaches this" band at the bottom of the Screens tab, and printing Swift conditions like `Thread.isMainThread` on a JavaScript navigation. A navigation now belongs to the screen whose file it is written in; an event arriving from native code, from an HTTP call or off a queue is no longer read backwards as if it were a caller. + - **A link written under a condition says so on the Screens tab.** A checkout stepper whose tabs are each enabled by their own prop, and a navbar whose admin links only render for an admin, both read as **always** — every transition written in markup was drawn with no condition at all, while the ones written as calls carried theirs. They are read the same way now: a store's checkout tabs say `step1` … `step4`, its navbar says `userInfo && userInfo.isAdmin` for the admin links and `!userInfo` for sign-in, and 59 of that store's 74 transitions carry the condition they actually run under, up from 20. A template language with no condition rules of its own still says nothing rather than guessing. - **A link in markup no longer reads as a helper's return value.** `` was labelled `return /shipping`, which in this picture means the destination came back from somewhere else and was inferred. It is written right there, so it now reads `link /shipping` — and an internal `` reads `a`. Only a destination that genuinely arrives from elsewhere still says `return`. diff --git a/__tests__/expo-router.test.ts b/__tests__/expo-router.test.ts index 939dc29..76efab4 100644 --- a/__tests__/expo-router.test.ts +++ b/__tests__/expo-router.test.ts @@ -5,6 +5,7 @@ import * as os from 'os'; import { CodeGraph } from '../src'; import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; import { buildScreens } from '../src/ui-server/api/screens'; +import { buildSteps } from '../src/ui-server/api/steps'; import { expoRouterResolver, routePathForFile, @@ -465,7 +466,12 @@ describe('expo-router: end-to-end', () => { ); write( 'src/services/post-login.ts', - 'export const resolvePostLoginRoute = async (): Promise => {\n' + + // The literal-union return type is the trap: its routes are string + // literals too, BEFORE the ternary — the scan must skip the signature + // or the annotation's guardless positions win. + 'export const resolvePostLoginRoute = async (): Promise<\n' + + " '/welcome/' | '/'\n" + + '> => {\n' + " return (await seen()) ? '/' : '/welcome/'\n" + '}\n' + 'async function seen() { return true }\n' + @@ -531,6 +537,15 @@ describe('expo-router: end-to-end', () => { expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true); expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return'); expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4'); + // Each return literal carries its own POSITION: the two arms of + // `return (await seen()) ? '/' : '/welcome/'` share a line, and only the + // column lets the guard reader say which arm an edge is — without it both + // navigations read as `always`. + const welcomeEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/welcome')!; + const rootEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/')!; + expect(rootEdge.line).toBe(welcomeEdge.line); + expect(typeof rootEdge.column).toBe('number'); + expect(welcomeEdge.column!).toBeGreaterThan(rootEdge.column!); const finishLogin = cg.getNodesByName('finishLogin')[0]!; expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true); const apiPath = cg.getNodesByName('apiPath')[0]!; @@ -555,6 +570,168 @@ describe('expo-router: end-to-end', () => { expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']); expect(screens.dropped).toBe(0); + // The steps walk reads each arm's own condition off the literal's column: + // where the app goes after login is a fork, not two `always`es. + const steps = await buildSteps(cg, tmpDir, new URLSearchParams({ symbol: 'finishLogin' })); + const stepByLabel = (label: string) => steps.steps.find((s) => s.label === label)!; + const toRoot = steps.links.find((l) => l.to === stepByLabel('/').id)!; + const toWelcome = steps.links.find((l) => l.to === stepByLabel('/welcome').id)!; + expect(toRoot.when).toMatch(/await seen\(\)/); + expect(toRoot.when).not.toMatch(/!/); + expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/); + + cg.close(); + }); +}); + +// ============================================================================= +// The backward walk must not leave the app's own execution context +// ============================================================================= + +/** + * A navigation written inside a component the graph can only reach BACKWARDS + * through the native bridge belongs to the screen whose file it is written in + * — not to whichever screen happened to start the round trip. + * + * The shape, from a real Expo app: `/capture` renders `ARCapturePage`, which + * renders `memo(CaptureComponent)`; the `router.push` lives in an inline + * listener inside `CaptureComponent`. Nothing points at `CaptureComponent` + * except Swift emitters — the walk skips `file` nodes, and `memo(x)` leaves no + * edge from the memo to the function — so before the guard the walk escaped + * through `rn-event-channel`, came back down into `ReviewScreen` (which had + * called the native module), and filed four of `/capture`'s navigations under + * `/capture/review`, whose only remaining feed was itself. It also carried the + * Swift guards home as conditions on a JavaScript navigation. + */ +describe('expo-router screens: attribution stops at the native bridge', () => { + let tmpDir: string | undefined; + + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + function write(rel: string, content: string) { + const full = path.join(tmpDir!, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + it('files the push on the screen whose file holds it, not on the screen that started the round trip', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-bridge-')); + write( + 'package.json', + JSON.stringify({ + name: 'app', + dependencies: { expo: '52', 'expo-router': '4', react: '18', 'react-native': '0.76' }, + }) + ); + write('src/app/_layout.tsx', 'export default function Layout() { return null }\n'); + write('src/app/index.tsx', 'export default function Home() { return null }\n'); + + // The Swift side: a method the JS calls, which ends in the event emit. + write( + 'ios/CaptureView.swift', + `import Foundation +@objc(CaptureView) +class CaptureView: NSObject { + @objc func startRetake() { + CaptureEvents.shared.emitCaptureComplete() + } +} +` + ); + // The ObjC bridging shim, without which the JS side never reaches Swift. + write( + 'ios/CaptureView.m', + `#import +@interface RCT_EXTERN_MODULE(CaptureView, NSObject) +RCT_EXTERN_METHOD(startRetake) +@end +` + ); + write( + 'ios/CaptureEvents.swift', + `import Foundation +class CaptureEvents: RCTEventEmitter { + func emitCaptureComplete() { + guard Thread.isMainThread else { return } + sendEvent(withName: "onCaptureComplete", body: nil) + } +} +` + ); + + // /capture — the push is written HERE, in an inline listener inside a + // sibling of the route's own default export. + write( + 'src/app/capture/index.tsx', + `import { memo, useEffect } from 'react' +import { router } from 'expo-router' +const MemoizedCaptureComponent = memo(CaptureComponent) +export default function ARCapturePage() { + return +} +function CaptureComponent() { + useEffect(() => { + const sub = nativeEmitter.addListener('onCaptureComplete', (data) => { + if (!isRetakeBatchActive) { + router.push('/capture/review') + } + }) + return () => sub.remove() + }, []) + return null +} +` + ); + + // /capture/review — calls into the native module, which is what makes the + // Swift emitter backwards-reachable from this screen. + write( + 'src/app/capture/review/index.tsx', + `import { NativeModules } from 'react-native' +const { CaptureView } = NativeModules +export default function ReviewScreen() { + function handleRetake() { + CaptureView.startRetake() + } + return handleRetake +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // The escape route the walk used to take really is in the graph. + const capture = cg.getNodesByName('CaptureComponent').find((n) => n.kind !== 'route')!; + const bridged = cg + .getIncomingEdgesTo([capture.id], ['calls']) + .filter((e) => (e.metadata as Record | undefined)?.synthesizedBy === 'rn-event-channel'); + expect(bridged.length).toBeGreaterThan(0); + // …and it really is a route back OUT to the other screen: without the + // guard the walk runs handleRetake > startRetake > emitCaptureComplete > + // CaptureComponent and lands the push on /capture/review. + const startRetake = cg.getNodesByName('startRetake').find((n) => n.language === 'swift')!; + expect(cg.getIncomingEdgesTo([startRetake.id], ['calls']).map((e) => cg.getNodesByIds([e.source]).get(e.source)?.name)).toContain( + 'handleRetake' + ); + + const screens = await buildScreens(cg, tmpDir); + const from = (path: string) => screens.screens.find((s) => s.path === path)!; + const review = from('/capture/review'); + const links = screens.links.filter((l) => l.to === review.id); + + // One transition into /capture/review, and it comes from /capture. + expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']); + // Written right there: no chain, and no Swift guard smuggled in. + expect(links[0]!.via).toEqual([]); + expect(links[0]!.when).toBe('!isRetakeBatchActive'); + expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx'); + // …and /capture/review is not left feeding only itself. + expect(screens.links.some((l) => l.from === review.id && l.to === review.id)).toBe(false); + cg.close(); }); }); diff --git a/__tests__/ui-steps-api.test.ts b/__tests__/ui-steps-api.test.ts index 26a5d7a..bf1de73 100644 --- a/__tests__/ui-steps-api.test.ts +++ b/__tests__/ui-steps-api.test.ts @@ -129,6 +129,33 @@ beforeAll(async () => { ' }\n' + '}\n' ); + write( + 'src/api/remove-thing.ts', + "import { client } from './client'\n" + + 'export async function removeThing(name: string) {\n' + + " await client.post('/things/remove', { name })\n" + + '}\n' + ); + // The dialog-confirm-then-act pattern: the prompt is an effect box AND the + // thing that fires the handler bound in its buttons. + write( + 'src/app/confirm.tsx', + "import { Alert, Button } from 'react-native'\n" + + "import { removeThing } from '../api/remove-thing'\n" + + 'export default function ConfirmScreen() {\n' + + ' return (\n' + + ' \n' + + " Alert.prompt('Remove thing', 'Which one?', [\n" + + " { text: 'Cancel' },\n" + + " { text: 'OK', onPress: (name) => { if (name) removeThing(name) } },\n" + + ' ])\n' + + ' }\n' + + ' />\n' + + ' )\n' + + '}\n' + ); cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); }); @@ -302,3 +329,53 @@ describe('buildSteps', () => { await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/); }); }); + +describe('screen regions', () => { + it('a screen names every step’s region: the screen body for its own code, inherited down the walk', async () => { + const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!; + const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id })); + const byLabel = Object.fromEntries(payload.steps.map((s) => [s.label, s])); + // Every step of a screen's picture belongs somewhere. + for (const s of payload.steps) if (!s.anchor) expect(s.region, s.label).toBeDefined(); + // A handler declared in the screen body belongs to the screen's own component… + expect(byLabel['handleApprove']!.region!.label).toBe('ReviewScreen'); + // …and what it reaches inherits the region that got there first. + expect(byLabel['finalizeCaptureSession']!.region!.id).toBe(byLabel['handleApprove']!.region!.id); + expect(byLabel['setZipUri']!.region!.label).toBe('ReviewScreen'); + }); + + it('a step reached through a folded component belongs to that component — the fold’s first node', async () => { + const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!; + const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id })); + const handler = payload.steps.find((s) => s.label === 'handleOpen')!; + const toHandler = payload.links.find((l) => l.to === handler.id)!; + expect(handler.region!.label).toBe(toHandler.via[0]!.name); + }); + + it('an anchor with a body carries no regions — its rows read in the code’s order', async () => { + const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' })); + for (const s of payload.steps) expect(s.region).toBeUndefined(); + }); +}); + +describe('fired from a dialog', () => { + it('a handler bound inside a dialog’s buttons arrives from the dialog, not from the screen', async () => { + const confirm = cg.getNodesByKind('route').find((r) => r.name === '/confirm')!; + const payload = await buildSteps(cg, tmpDir, q({ anchor: confirm.id })); + const prompt = payload.steps.find((s) => s.kind === 'effect' && s.label.startsWith('Alert.prompt'))!; + const handler = payload.steps.find((s) => s.label === 'removeThing')!; + const into = payload.links.filter((l) => l.to === handler.id); + expect(into).toHaveLength(1); + expect(into[0]!.from).toBe(prompt.id); + expect(into[0]!.trigger?.of).toBe('Alert.prompt'); + // A handler CALLED from under a binding says what it passes, as every + // call-shaped site does — the argument is what a wrapper wraps. + expect(into[0]!.sites[0]!.args).toBe('name'); + // One step deeper than the prompt that fires it, in the prompt's region. + expect(handler.depth).toBe(prompt.depth + 1); + expect(handler.region!.id).toBe(prompt.region!.id); + // …and what the handler does hangs on below. + const post = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!; + expect(payload.links.some((l) => l.from === handler.id && l.to === post.id)).toBe(true); + }); +}); diff --git a/__tests__/ui-steps-model.test.ts b/__tests__/ui-steps-model.test.ts index e661e4c..51d0f4c 100644 --- a/__tests__/ui-steps-model.test.ts +++ b/__tests__/ui-steps-model.test.ts @@ -4,7 +4,7 @@ * rule, and the panel's two lists. */ import { describe, it, expect } from 'vitest'; -import { buildStepsModel, countWords, kindWord, kindWords, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model'; +import { buildStepsModel, countWords, kindWord, kindWords, stepEdgeVisible, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model'; import { placeLabels } from '../ui/src/lib/screens-model'; import type { WireNodeRef, WireStep, WireStepLink, WireStepsPayload } from '../ui/src/lib/wire'; @@ -150,3 +150,84 @@ describe('row order', () => { expect(row).toEqual([a.id, b.id, c.id, d.id]); }); }); + +describe('a screen laid out by region', () => { + const A = { id: 'component:PanelA', label: 'PanelA' }; + const B = { id: 'component:PanelB', label: 'PanelB' }; + const anchor = step('/', 'screen', 0, { anchor: true }); + const a1 = step('tapSave', 'trigger', 1, { order: 0, region: A }); + const a2 = step('tapUndo', 'trigger', 1, { order: 1, region: A }); + const a3 = step('saveThing', 'store', 2, { order: 0, region: A, node: ref('saveThing', 'src/things.storage.ts') }); + const b1 = step('tapShare', 'trigger', 1, { order: 2, region: B, node: ref('tapShare', 'src/b.tsx') }); + const links = [ + link(anchor, a1), + link(anchor, a2), + link(anchor, b1), + link(a1, a3, { kind: 'store' }), + link(a1, b1), + // Another region's way into a shared store — a lead-to line like any other. + link(b1, a3, { kind: 'store' }), + ]; + const model = buildStepsModel(payload([anchor, a1, a2, b1, a3], links)); + const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!; + const between = (id: string, zone: { x: number; width: number }) => { + const n = at(id); + return n.x >= zone.x && n.x + n.width <= zone.x + zone.width; + }; + const edge = (from: string, to: string) => model.layout.edges.find((e) => e.source === from && e.target === to)!; + + it('names the regions in the order the walk met them, each holding its own boxes', () => { + expect(model.regions!.map((z) => z.label)).toEqual(['PanelA', 'PanelB']); + const [zoneA, zoneB] = model.regions!; + expect(between(a1.id, zoneA!)).toBe(true); + expect(between(a2.id, zoneA!)).toBe(true); + expect(between(a3.id, zoneA!)).toBe(true); + expect(between(b1.id, zoneB!)).toBe(true); + // Side by side, not overlapping: the second region starts past the first. + expect(zoneB!.x).toBeGreaterThanOrEqual(zoneA!.x + zoneA!.width); + }); + + it('keeps a step above what it sets in motion, inside its region', () => { + expect(at(anchor.id).y).toBeLessThan(at(a1.id).y); + expect(at(a1.id).y).toBe(at(a2.id).y); + expect(at(a3.id).y).toBeGreaterThan(at(a1.id).y); + }); + + it('at rest hides only the screen’s own fan and what points back up; every other lead-to draws', () => { + expect(model.regionEntries).toEqual(new Set([a1.id, b1.id])); + // One line from the screen into each region stands in for its whole fan. + expect(stepEdgeVisible(model, edge(anchor.id, a1.id), null)).toBe(true); + expect(stepEdgeVisible(model, edge(anchor.id, a2.id), null)).toBe(false); + expect(stepEdgeVisible(model, edge(anchor.id, b1.id), null)).toBe(true); + // A region's internal line, and another region's way into a shared step. + expect(stepEdgeVisible(model, edge(a1.id, a3.id), null)).toBe(true); + expect(stepEdgeVisible(model, edge(b1.id, a3.id), null)).toBe(true); + // Two boxes on one row point sideways — back-ish, a click away as everywhere. + expect(stepEdgeVisible(model, edge(a1.id, b1.id), null)).toBe(false); + // Selecting a step brings out everything that touches it, and only that. + expect(stepEdgeVisible(model, edge(a1.id, b1.id), a1.id)).toBe(true); + expect(stepEdgeVisible(model, edge(anchor.id, a2.id), a1.id)).toBe(false); + }); + + it('stacks a handler above the store it calls, even when both are one hop from the screen', () => { + // Anchor distance is flat inside a region: both of these are depth 1, and + // side by side their link was a level arch, hidden at rest — the store + // floated. The region's own links order its rows instead. + const C = { id: 'component:PanelC', label: 'PanelC' }; + const root = step('/', 'screen', 0, { anchor: true }); + const h = step('tapCopy', 'trigger', 1, { order: 0, region: C }); + const s = step('copyThing', 'store', 1, { order: 1, region: C, node: ref('copyThing', 'src/c.storage.ts') }); + const m = buildStepsModel(payload([root, h, s], [link(root, h), link(root, s), link(h, s, { kind: 'store' })])); + const y = (id: string) => m.layout.nodes.find((n) => n.id === id)!.y; + expect(y(s.id)).toBeGreaterThan(y(h.id)); + const e = m.layout.edges.find((x) => x.source === h.id && x.target === s.id)!; + expect(e.route).toBe('down'); + expect(stepEdgeVisible(m, e, null)).toBe(true); + }); + + it('a payload without regions keeps the rows, and the Map’s at-rest rule', () => { + const plain = buildStepsModel(payload([step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1)], [link(step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1))])); + expect(plain.regions).toBeNull(); + expect(plain.regionEntries).toBeNull(); + }); +}); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index 2a09f7a..1e261c0 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -504,7 +504,9 @@ whole app; so is a native event that lands in a COMPONENT (the capture overlay t another screen's body — `cut: 'component'`). A bridge or event step needs evidence — a bridge resolver's edge or a synthesized channel's; a plain name-matched call across the families (`arr.flat()` landing on a Swift `flat`) is neither drawn nor walked. Effects are one box per (function, category), labelled by the first call and counting the rest (`client.post +1`), the calls -listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step) also +listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step, a +handler CALLED from under a binding — a bound one passes nothing, but `tryCatchSync(onClosePress)`'s argument is the +whole answer to what a wrapper wraps) also carries **what it passes** — `graph/branch-guards.ts`'s `callArgumentsForFile`, read from the same cached tree as the guards: string literals and names whole, an object as its keys (`{ email, password }`), arrays `[…]`, functions `() => …`, nested calls `f(…)`, Swift labels kept (`withName: "onZipComplete"`), ≤ 96 chars — printed on the panel's @@ -517,7 +519,15 @@ runs-later call (`useEffect`, `setTimeout`, `addListener('onZipComplete')`, `.th handleX = useCallback(…)`) is a boundary, its own story. A function called from under such a binding is a **handler step** even though nothing passed it as a value (`onPress={() => handleLogin(values)}` — the common case, and the Formik case), and every call-shaped link carries its trigger: a store action or an effect fired -straight from a tap says so. The pill on a handler link says the event (`onPress ·