feat(steps): lay out screen pictures by region and render region captions
Adds region-based layout support for screens: steps now carry region information, and the server packs regions into dedicated bands with per-region captions. UI changes introduce RegionCaption and region-aware step rendering; StepsModel and related views (StepsView) consume region data, while the region-aware layout keeps anchor and region boundaries intact. Tests and docs updated to reflect region-driven organization and visualization of screen regions. This enables visualizing a screen’s picture as region-based columns rather than a single distance-driven row.
This commit is contained in:
@@ -14,6 +14,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### New Features
|
||||
|
||||
- **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps.
|
||||
|
||||
- **A dialog's buttons fire what they run.** `Alert.prompt('Add Folder', …, [{ onPress: (name) => createBackgroundFolder(name) }])` is two facts: the prompt is a call that leaves the index, and its button fires the handler. The handler's line now arrives from the dialog's own box — with the condition on it — instead of from the screen, so the confirm-then-act chains a mobile app is full of read as chains: the delete alert leads to the delete, which leads to the request it sends. The same holds for anything bound inside the arguments of a call that leaves the index. Nothing needs a re-index: it is read from the source at request time.
|
||||
|
||||
- **The Steps tab draws a handler in the order its code runs.** The picture of what an endpoint sets in motion put the lookup, the token signing, the 200 and the 401 side by side, because each is one step from the anchor — true, and not how the code reads. Now a handler, an endpoint or any function opens as the same picture laid out by *when* things happen: a line means **and then**, so the 200 sits below the token signing it is built from and the 401 branches off the check that chose it. Where the code forks — an `if`, a `switch`, a `try`, an early exit — the line says what has to hold, and an arm that answers the request, returns or throws simply has nothing leaving it. A call written inside another call's arguments happens first, so the token is signed before the reply that carries it. A helper is drawn where it is called (`via generateToken`), a body that repeats says so (`for each item of items`), and work registered to run later (`later · then`) or started at once (`together · Promise.all`) says that rather than pretending to be a sequence. A screen still opens as before — its handlers fire on events and have no order between them — and either reading is one click, or one `&view=order` / `&view=tree` in the link, away. Nothing to re-index: it is read from the source at request time, and where the conditions cannot be read the picture is a plain sequence rather than an invented structure.
|
||||
|
||||
- **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading.
|
||||
@@ -40,6 +44,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.
|
||||
|
||||
- **A handler called from under a binding says what it passes.** A press that runs `tryCatchSync(onClosePress)` drew a box for the wrapper and stopped — leaving the one thing a reader asks ("what is being wrapped?") unsaid, even though every other call-shaped site already prints its arguments. The panel and tooltip now say `tryCatchSync(onClosePress)` — the argument is the answer.
|
||||
|
||||
- **A step the walk stopped at keeps its whole name.** A boundary — another screen, or a cap the walk hit — ends its name with an ellipsis by design, but the box was not sized for it, so a longer name lost its last letters instead (`/scan-to-verif…` for `/scan-to-verify …`). The anchor's start mark clipped a long path the same way (`/sheets/forgot-passw…`). The box now makes room for both.
|
||||
|
||||
- **A screen that talks to native code keeps its own navigations.** In a React Native or Expo app, a `router.push` written inside a listener for a native event was credited to whichever screen had *started* that round trip, not to the screen the push is written on. In one app that moved seven transitions off the capture screen and onto the review screen it opens — leaving the review screen looking as though nothing in the app could reach it, stranded in the "no transition reaches this" band at the bottom of the Screens tab, and printing Swift conditions like `Thread.isMainThread` on a JavaScript navigation. A navigation now belongs to the screen whose file it is written in; an event arriving from native code, from an HTTP call or off a queue is no longer read backwards as if it were a caller.
|
||||
|
||||
- **A link written under a condition says so on the Screens tab.** A checkout stepper whose tabs are each enabled by their own prop, and a navbar whose admin links only render for an admin, both read as **always** — every transition written in markup was drawn with no condition at all, while the ones written as calls carried theirs. They are read the same way now: a store's checkout tabs say `step1` … `step4`, its navbar says `userInfo && userInfo.isAdmin` for the admin links and `!userInfo` for sign-in, and 59 of that store's 74 transitions carry the condition they actually run under, up from 20. A template language with no condition rules of its own still says nothing rather than guessing.
|
||||
|
||||
- **A link in markup no longer reads as a helper's return value.** `<Link to='/shipping'>` was labelled `return /shipping`, which in this picture means the destination came back from somewhere else and was inferred. It is written right there, so it now reads `link /shipping` — and an internal `<a href>` reads `a`. Only a destination that genuinely arrives from elsewhere still says `return`.
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
import { buildScreens } from '../src/ui-server/api/screens';
|
||||
import { buildSteps } from '../src/ui-server/api/steps';
|
||||
import {
|
||||
expoRouterResolver,
|
||||
routePathForFile,
|
||||
@@ -465,7 +466,12 @@ describe('expo-router: end-to-end', () => {
|
||||
);
|
||||
write(
|
||||
'src/services/post-login.ts',
|
||||
'export const resolvePostLoginRoute = async (): Promise<string> => {\n' +
|
||||
// The literal-union return type is the trap: its routes are string
|
||||
// literals too, BEFORE the ternary — the scan must skip the signature
|
||||
// or the annotation's guardless positions win.
|
||||
'export const resolvePostLoginRoute = async (): Promise<\n' +
|
||||
" '/welcome/' | '/'\n" +
|
||||
'> => {\n' +
|
||||
" return (await seen()) ? '/' : '/welcome/'\n" +
|
||||
'}\n' +
|
||||
'async function seen() { return true }\n' +
|
||||
@@ -531,6 +537,15 @@ describe('expo-router: end-to-end', () => {
|
||||
expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
|
||||
expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
|
||||
expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
|
||||
// Each return literal carries its own POSITION: the two arms of
|
||||
// `return (await seen()) ? '/' : '/welcome/'` share a line, and only the
|
||||
// column lets the guard reader say which arm an edge is — without it both
|
||||
// navigations read as `always`.
|
||||
const welcomeEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/welcome')!;
|
||||
const rootEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/')!;
|
||||
expect(rootEdge.line).toBe(welcomeEdge.line);
|
||||
expect(typeof rootEdge.column).toBe('number');
|
||||
expect(welcomeEdge.column!).toBeGreaterThan(rootEdge.column!);
|
||||
const finishLogin = cg.getNodesByName('finishLogin')[0]!;
|
||||
expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
|
||||
const apiPath = cg.getNodesByName('apiPath')[0]!;
|
||||
@@ -555,6 +570,168 @@ describe('expo-router: end-to-end', () => {
|
||||
expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
|
||||
expect(screens.dropped).toBe(0);
|
||||
|
||||
// The steps walk reads each arm's own condition off the literal's column:
|
||||
// where the app goes after login is a fork, not two `always`es.
|
||||
const steps = await buildSteps(cg, tmpDir, new URLSearchParams({ symbol: 'finishLogin' }));
|
||||
const stepByLabel = (label: string) => steps.steps.find((s) => s.label === label)!;
|
||||
const toRoot = steps.links.find((l) => l.to === stepByLabel('/').id)!;
|
||||
const toWelcome = steps.links.find((l) => l.to === stepByLabel('/welcome').id)!;
|
||||
expect(toRoot.when).toMatch(/await seen\(\)/);
|
||||
expect(toRoot.when).not.toMatch(/!/);
|
||||
expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
|
||||
|
||||
cg.close();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// The backward walk must not leave the app's own execution context
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A navigation written inside a component the graph can only reach BACKWARDS
|
||||
* through the native bridge belongs to the screen whose file it is written in
|
||||
* — not to whichever screen happened to start the round trip.
|
||||
*
|
||||
* The shape, from a real Expo app: `/capture` renders `ARCapturePage`, which
|
||||
* renders `memo(CaptureComponent)`; the `router.push` lives in an inline
|
||||
* listener inside `CaptureComponent`. Nothing points at `CaptureComponent`
|
||||
* except Swift emitters — the walk skips `file` nodes, and `memo(x)` leaves no
|
||||
* edge from the memo to the function — so before the guard the walk escaped
|
||||
* through `rn-event-channel`, came back down into `ReviewScreen` (which had
|
||||
* called the native module), and filed four of `/capture`'s navigations under
|
||||
* `/capture/review`, whose only remaining feed was itself. It also carried the
|
||||
* Swift guards home as conditions on a JavaScript navigation.
|
||||
*/
|
||||
describe('expo-router screens: attribution stops at the native bridge', () => {
|
||||
let tmpDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
function write(rel: string, content: string) {
|
||||
const full = path.join(tmpDir!, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
it('files the push on the screen whose file holds it, not on the screen that started the round trip', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-bridge-'));
|
||||
write(
|
||||
'package.json',
|
||||
JSON.stringify({
|
||||
name: 'app',
|
||||
dependencies: { expo: '52', 'expo-router': '4', react: '18', 'react-native': '0.76' },
|
||||
})
|
||||
);
|
||||
write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
|
||||
write('src/app/index.tsx', 'export default function Home() { return null }\n');
|
||||
|
||||
// The Swift side: a method the JS calls, which ends in the event emit.
|
||||
write(
|
||||
'ios/CaptureView.swift',
|
||||
`import Foundation
|
||||
@objc(CaptureView)
|
||||
class CaptureView: NSObject {
|
||||
@objc func startRetake() {
|
||||
CaptureEvents.shared.emitCaptureComplete()
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
// The ObjC bridging shim, without which the JS side never reaches Swift.
|
||||
write(
|
||||
'ios/CaptureView.m',
|
||||
`#import <React/RCTBridgeModule.h>
|
||||
@interface RCT_EXTERN_MODULE(CaptureView, NSObject)
|
||||
RCT_EXTERN_METHOD(startRetake)
|
||||
@end
|
||||
`
|
||||
);
|
||||
write(
|
||||
'ios/CaptureEvents.swift',
|
||||
`import Foundation
|
||||
class CaptureEvents: RCTEventEmitter {
|
||||
func emitCaptureComplete() {
|
||||
guard Thread.isMainThread else { return }
|
||||
sendEvent(withName: "onCaptureComplete", body: nil)
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// /capture — the push is written HERE, in an inline listener inside a
|
||||
// sibling of the route's own default export.
|
||||
write(
|
||||
'src/app/capture/index.tsx',
|
||||
`import { memo, useEffect } from 'react'
|
||||
import { router } from 'expo-router'
|
||||
const MemoizedCaptureComponent = memo(CaptureComponent)
|
||||
export default function ARCapturePage() {
|
||||
return <MemoizedCaptureComponent />
|
||||
}
|
||||
function CaptureComponent() {
|
||||
useEffect(() => {
|
||||
const sub = nativeEmitter.addListener('onCaptureComplete', (data) => {
|
||||
if (!isRetakeBatchActive) {
|
||||
router.push('/capture/review')
|
||||
}
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// /capture/review — calls into the native module, which is what makes the
|
||||
// Swift emitter backwards-reachable from this screen.
|
||||
write(
|
||||
'src/app/capture/review/index.tsx',
|
||||
`import { NativeModules } from 'react-native'
|
||||
const { CaptureView } = NativeModules
|
||||
export default function ReviewScreen() {
|
||||
function handleRetake() {
|
||||
CaptureView.startRetake()
|
||||
}
|
||||
return handleRetake
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
// The escape route the walk used to take really is in the graph.
|
||||
const capture = cg.getNodesByName('CaptureComponent').find((n) => n.kind !== 'route')!;
|
||||
const bridged = cg
|
||||
.getIncomingEdgesTo([capture.id], ['calls'])
|
||||
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'rn-event-channel');
|
||||
expect(bridged.length).toBeGreaterThan(0);
|
||||
// …and it really is a route back OUT to the other screen: without the
|
||||
// guard the walk runs handleRetake > startRetake > emitCaptureComplete >
|
||||
// CaptureComponent and lands the push on /capture/review.
|
||||
const startRetake = cg.getNodesByName('startRetake').find((n) => n.language === 'swift')!;
|
||||
expect(cg.getIncomingEdgesTo([startRetake.id], ['calls']).map((e) => cg.getNodesByIds([e.source]).get(e.source)?.name)).toContain(
|
||||
'handleRetake'
|
||||
);
|
||||
|
||||
const screens = await buildScreens(cg, tmpDir);
|
||||
const from = (path: string) => screens.screens.find((s) => s.path === path)!;
|
||||
const review = from('/capture/review');
|
||||
const links = screens.links.filter((l) => l.to === review.id);
|
||||
|
||||
// One transition into /capture/review, and it comes from /capture.
|
||||
expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']);
|
||||
// Written right there: no chain, and no Swift guard smuggled in.
|
||||
expect(links[0]!.via).toEqual([]);
|
||||
expect(links[0]!.when).toBe('!isRetakeBatchActive');
|
||||
expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
|
||||
// …and /capture/review is not left feeding only itself.
|
||||
expect(screens.links.some((l) => l.from === review.id && l.to === review.id)).toBe(false);
|
||||
|
||||
cg.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,33 @@ beforeAll(async () => {
|
||||
' }\n' +
|
||||
'}\n'
|
||||
);
|
||||
write(
|
||||
'src/api/remove-thing.ts',
|
||||
"import { client } from './client'\n" +
|
||||
'export async function removeThing(name: string) {\n' +
|
||||
" await client.post('/things/remove', { name })\n" +
|
||||
'}\n'
|
||||
);
|
||||
// The dialog-confirm-then-act pattern: the prompt is an effect box AND the
|
||||
// thing that fires the handler bound in its buttons.
|
||||
write(
|
||||
'src/app/confirm.tsx',
|
||||
"import { Alert, Button } from 'react-native'\n" +
|
||||
"import { removeThing } from '../api/remove-thing'\n" +
|
||||
'export default function ConfirmScreen() {\n' +
|
||||
' return (\n' +
|
||||
' <Button\n' +
|
||||
' title="remove"\n' +
|
||||
' onPress={() =>\n' +
|
||||
" Alert.prompt('Remove thing', 'Which one?', [\n" +
|
||||
" { text: 'Cancel' },\n" +
|
||||
" { text: 'OK', onPress: (name) => { if (name) removeThing(name) } },\n" +
|
||||
' ])\n' +
|
||||
' }\n' +
|
||||
' />\n' +
|
||||
' )\n' +
|
||||
'}\n'
|
||||
);
|
||||
cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
});
|
||||
@@ -302,3 +329,53 @@ describe('buildSteps', () => {
|
||||
await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('screen regions', () => {
|
||||
it('a screen names every step’s region: the screen body for its own code, inherited down the walk', async () => {
|
||||
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
|
||||
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
|
||||
const byLabel = Object.fromEntries(payload.steps.map((s) => [s.label, s]));
|
||||
// Every step of a screen's picture belongs somewhere.
|
||||
for (const s of payload.steps) if (!s.anchor) expect(s.region, s.label).toBeDefined();
|
||||
// A handler declared in the screen body belongs to the screen's own component…
|
||||
expect(byLabel['handleApprove']!.region!.label).toBe('ReviewScreen');
|
||||
// …and what it reaches inherits the region that got there first.
|
||||
expect(byLabel['finalizeCaptureSession']!.region!.id).toBe(byLabel['handleApprove']!.region!.id);
|
||||
expect(byLabel['setZipUri']!.region!.label).toBe('ReviewScreen');
|
||||
});
|
||||
|
||||
it('a step reached through a folded component belongs to that component — the fold’s first node', async () => {
|
||||
const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
|
||||
const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
|
||||
const handler = payload.steps.find((s) => s.label === 'handleOpen')!;
|
||||
const toHandler = payload.links.find((l) => l.to === handler.id)!;
|
||||
expect(handler.region!.label).toBe(toHandler.via[0]!.name);
|
||||
});
|
||||
|
||||
it('an anchor with a body carries no regions — its rows read in the code’s order', async () => {
|
||||
const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
|
||||
for (const s of payload.steps) expect(s.region).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fired from a dialog', () => {
|
||||
it('a handler bound inside a dialog’s buttons arrives from the dialog, not from the screen', async () => {
|
||||
const confirm = cg.getNodesByKind('route').find((r) => r.name === '/confirm')!;
|
||||
const payload = await buildSteps(cg, tmpDir, q({ anchor: confirm.id }));
|
||||
const prompt = payload.steps.find((s) => s.kind === 'effect' && s.label.startsWith('Alert.prompt'))!;
|
||||
const handler = payload.steps.find((s) => s.label === 'removeThing')!;
|
||||
const into = payload.links.filter((l) => l.to === handler.id);
|
||||
expect(into).toHaveLength(1);
|
||||
expect(into[0]!.from).toBe(prompt.id);
|
||||
expect(into[0]!.trigger?.of).toBe('Alert.prompt');
|
||||
// A handler CALLED from under a binding says what it passes, as every
|
||||
// call-shaped site does — the argument is what a wrapper wraps.
|
||||
expect(into[0]!.sites[0]!.args).toBe('name');
|
||||
// One step deeper than the prompt that fires it, in the prompt's region.
|
||||
expect(handler.depth).toBe(prompt.depth + 1);
|
||||
expect(handler.region!.id).toBe(prompt.region!.id);
|
||||
// …and what the handler does hangs on below.
|
||||
const post = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
|
||||
expect(payload.links.some((l) => l.from === handler.id && l.to === post.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,7 +504,9 @@ whole app; so is a native event that lands in a COMPONENT (the capture overlay t
|
||||
another screen's body — `cut: 'component'`). A bridge or event step needs evidence — a bridge resolver's edge or a synthesized channel's; a plain
|
||||
name-matched call across the families (`arr.flat()` landing on a Swift `flat`) is neither drawn nor walked. Effects
|
||||
are one box per (function, category), labelled by the first call and counting the rest (`client.post +1`), the calls
|
||||
listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step) also
|
||||
listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step, a
|
||||
handler CALLED from under a binding — a bound one passes nothing, but `tryCatchSync(onClosePress)`'s argument is the
|
||||
whole answer to what a wrapper wraps) also
|
||||
carries **what it passes** — `graph/branch-guards.ts`'s `callArgumentsForFile`, read from the same cached tree as the
|
||||
guards: string literals and names whole, an object as its keys (`{ email, password }`), arrays `[…]`, functions
|
||||
`() => …`, nested calls `f(…)`, Swift labels kept (`withName: "onZipComplete"`), ≤ 96 chars — printed on the panel's
|
||||
@@ -517,7 +519,15 @@ runs-later call (`useEffect`, `setTimeout`, `addListener('onZipComplete')`, `.th
|
||||
handleX = useCallback(…)`) is a boundary, its own story. A function called from under such a binding is a
|
||||
**handler step** even though nothing passed it as a value (`onPress={() => handleLogin(values)}` — the common
|
||||
case, and the Formik case), and every call-shaped link carries its trigger: a store action or an effect fired
|
||||
straight from a tap says so. The pill on a handler link says the event (`onPress · <Button>`,
|
||||
straight from a tap says so. **And when the binding sits in the ARGUMENTS of a call that itself became an effect
|
||||
step, the line arrives from that box, not from the step that owns the fold** — `Alert.prompt('Add Folder', …,
|
||||
[{ onPress: (name) => createBackgroundFolder(name) }])` is two facts, the prompt as a device box and the prompt's
|
||||
button firing the handler, and "the screen fires it" says nothing when the screen fires everything. The walk keeps
|
||||
each effect call's span per function (`firedSpans`); a site whose trigger NAMES the call (`onPress ·
|
||||
Alert.prompt(…)`) and whose position falls inside that span is rewired to it, innermost span first, one step
|
||||
deeper — so the confirm-then-act chains a mobile app is full of read as chains: the delete alert leads to the
|
||||
delete, which leads to the request it sends. An `onSubmit · useFormik(…)` names no effect and stays where it was.
|
||||
The pill on a handler link says the event (`onPress · <Button>`,
|
||||
`onSubmit · useFormik(…)`), not the conditions; the box's second line says it before the file; the panel prints
|
||||
`FIRES FROM onPress · <Button> in LoginButton` above the `via` chain, which is set in `--ink-2` at the
|
||||
condition's size — it is the answer to "where on the screen", not an afterthought. Caps, each announced: depth in steps (default 8, ≤ 14, `cut: 'depth'` on the step it stopped
|
||||
@@ -526,6 +536,35 @@ hubs (fan-in ≥ 40) and shared chrome (a component rendered by ≥ 5 parents
|
||||
attributes navigations rather than deciding what to walk into) are dead ends, counted in `truncated`. A step several
|
||||
events land on says `⇠ first +N` and lists them in the panel.
|
||||
|
||||
**Regions — a screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order
|
||||
between them, so rows-by-distance degenerate there: on the mobile app's `/home`, 89 of 120 steps sat one hop out — one
|
||||
28,000px row, every line a near-horizontal sweep. The walk already knows the missing structure: a step reached out of the
|
||||
anchor descends through the fold's chain, whose first node is the top-level component (or hook) of the screen's tree, so
|
||||
the server names it on the step (`WireStep.region` — the fold's first node; the screen's own component for a call written
|
||||
in the screen body; the first-reaching parent's region for everything deeper — first reach wins, as `first` does, so a
|
||||
shared store is one box in the region that got there first and every other region's way in is a link). Endpoints and
|
||||
functions carry none: their rows already read in the code's order, and `view=order` is untouched. The viewer
|
||||
(`steps-model.ts`'s `packRegions`) then lays each region out as its own small column — a box above what it sets in
|
||||
motion, a line wrapping past ~720px — and tiles the columns into bands under a width budget aimed at a readable aspect,
|
||||
in the order the walk met them: the screen's own source order, top of the screen to the left. **Within a region the
|
||||
rows come from the region's own links** (longest lead-to path, settled by relaxation as the order reading's rows are),
|
||||
never from distance to the anchor, which is flat inside a region: a handler and the store it calls are both one hop
|
||||
from the screen, and side by side their line was a level arch, hidden at rest — the store looked wired to nothing.
|
||||
Each region wears a caption (`RegionCaption.svelte` — its component's name over a hairline spanning its width)
|
||||
and the key explains it. **At rest the picture hides exactly two things** (`stepEdgeVisible`): the anchor's own fan —
|
||||
the anchor leads to everything *by definition*, `/home`'s 104 ways of saying so were the moiré, so one line into each
|
||||
region's first box stands in for it — and, as everywhere on the canvas, what points back up the layering. Every other
|
||||
lead-to draws, a line between two regions included: the empty state's prompt firing the same handler as the header's IS
|
||||
the picture, and an earlier cut that reserved cross-region lines for selection made a box that leads three places read
|
||||
as wired to nothing. The two hidings compose well: a shared step fed from below — the toast action every handler calls
|
||||
— stays quiet through the back rule alone, no hub threshold needed, and selection still brings a step's whole story
|
||||
out. A box nothing points at is then a fact, not an accident — the region runs it directly, on render or mount or from
|
||||
a binding written inline (`Alert.prompt` in an empty-state view, a store read during render, `Keyboard.addListener` in
|
||||
an effect) — and the key says so; selecting it lights its line from the anchor, with what fires it. Same boxes, same
|
||||
tracked curves (over a tighter in-region gap), same pills, pointer and panel. Result across the app's 52 screens: widest
|
||||
picture ~3,400px (was 28,452), at-rest lines on `/home` 80 of 190 — the region-local structure plus 11 lines between
|
||||
regions — with zero boxes that lead somewhere while drawing nothing.
|
||||
|
||||
**Servers (Express, NestJS, Fastify, Koa, Hono, FastAPI, Flask, Django, Spring, ASP.NET, Vapor, Gin).** The same picture over
|
||||
the same machinery; only the facts and the words change (`src/ui-server/api/route-roots.ts`, `effects.ts`,
|
||||
`docs/plans/2026-08-28-steps-and-screens-for-apis-and-web.md` §4). A route anchor's walk starts at the symbol the route runs —
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
|
||||
* in the product is load-bearing").
|
||||
*/
|
||||
export const EXTRACTION_VERSION = 25;
|
||||
export const EXTRACTION_VERSION = 26;
|
||||
|
||||
@@ -3137,7 +3137,7 @@ async function nixOptionPathEdges(queries: QueryBuilder, onYield: MaybeYield): P
|
||||
// own namespace (`attrsOf (submodule { options = ...; })`) — its internals
|
||||
// are not globally addressable, so the sentinel blocks registration below it
|
||||
// while still excluding the region from write candidates.
|
||||
const SUBMODULE = ' | ||||