From 9acab0020f6935afd4bfc200691aa93afc5574a6 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 29 Aug 2026 13:35:17 -0500 Subject: [PATCH] =?UTF-8?q?feat(steps):=20the=20rail=20=E2=80=94=20a=20han?= =?UTF-8?q?dler=20read=20top=20to=20bottom,=20forks=20and=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading the walk records now has a picture. `#/steps?…&view=order` draws the anchor, then its body: a box per step in the order the code writes them, a fork where the code forks with its arms side by side under the condition, a helper drawn where it is called, and an arm that answers, returns or throws ending there — so proshop's login reads *look the user up · if the password matches, sign a token inside the reply and answer 200 · otherwise 401*, which is what the code says and what a row of four boxes could not. - `program-model.ts` decides the words: the fork carries the decision once and its arms say only which side they are (WHEN / WHEN NOT), except a `switch`, whose arms each have a case to say, and a `try`, which says `on error` once. - `StepBox.svelte` is the box both readings draw — the canvas wraps it in handles, the rail lets it size to its words. Same look, same click, same double-click-to-start-here. - `StepsKey.svelte` is the key, floating over the canvas as before and last in the document on the rail, which scrolls and cannot have things sitting on it. - The reading travels in the URL (`view=order` / `view=tree`) and the summary offers both; without one, the answer's own default decides — the code's order for a handler or an endpoint, the tree for a screen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC --- __tests__/ui-program-model.test.ts | 132 ++++++++++ __tests__/ui-steps-program.test.ts | 9 +- src/ui-server/api/program.ts | 43 +++- ui/src/App.svelte | 2 +- ui/src/components/steps/RailBlock.svelte | 184 ++++++++++++++ ui/src/components/steps/StepBox.svelte | 186 ++++++++++++++ ui/src/components/steps/StepNode.svelte | 165 ++----------- ui/src/components/steps/StepsKey.svelte | 216 ++++++++++++++++ ui/src/components/steps/StepsRail.svelte | 94 +++++++ ui/src/lib/navigation.ts | 7 + ui/src/lib/program-model.ts | 183 ++++++++++++++ ui/src/lib/router.svelte.ts | 6 +- ui/src/lib/wire.ts | 4 +- ui/src/views/StepsView.svelte | 299 ++++++++++------------- 14 files changed, 1187 insertions(+), 343 deletions(-) create mode 100644 __tests__/ui-program-model.test.ts create mode 100644 ui/src/components/steps/RailBlock.svelte create mode 100644 ui/src/components/steps/StepBox.svelte create mode 100644 ui/src/components/steps/StepsKey.svelte create mode 100644 ui/src/components/steps/StepsRail.svelte create mode 100644 ui/src/lib/program-model.ts diff --git a/__tests__/ui-program-model.test.ts b/__tests__/ui-program-model.test.ts new file mode 100644 index 0000000..7fb5067 --- /dev/null +++ b/__tests__/ui-program-model.test.ts @@ -0,0 +1,132 @@ +/** + * What the rail SAYS. The block tree the server sends is turned into rows of + * boxes and words here (`ui/src/lib/program-model.ts`); this pins the words — + * which is the part a reader actually meets. + */ + +import { describe, it, expect } from 'vitest'; +import { joinTokens } from '../ui/src/lib/conditions'; +import { armWords, buildRailModel, endWords, groupLabel } from '../ui/src/lib/program-model'; +import type { WireArm, WireBlock, WireItem, WireStep, WireStepsPayload } from '../ui/src/lib/wire'; + +const step = (id: string, over: Partial = {}): WireStep => ({ + id, + kind: 'effect', + anchor: false, + node: null, + label: id, + sub: `response · handler`, + depth: 1, + cut: null, + ...over, +}); + +function payload(steps: WireStep[], root: WireBlock): WireStepsPayload { + return { + anchor: { id: 'a', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false }, + ambiguous: [], + project: 'api', + steps, + links: [], + program: { root, truncated: 0 }, + defaultView: 'order', + depth: 8, + limit: 120, + through: false, + truncated: { steps: 0, hubs: 0, chrome: 0 }, + index: { lastIndexedAt: null, edges: 0, files: 0 }, + timing: { elapsedMs: 1 }, + }; +} + +const arm = (when: string, over: Partial = {}): WireArm => ({ when, ends: null, body: [], ...over }); + +describe('the rail’s words', () => { + it('says the decision once, and each arm only which side it is', () => { + const on = 'user && (await user.matchPassword(password))'; + const fork: WireItem = { + kind: 'fork', + form: 'if', + on, + arms: [arm(on, { ends: 'reply', body: [{ kind: 'step', step: '200' }] }), arm(`!(${on})`, { not: true, ends: 'reply', body: [{ kind: 'step', step: '401' }] })], + }; + const model = buildRailModel(payload([step('200'), step('401')], [fork])); + expect(model).toHaveLength(1); + const rail = model[0]!; + if (rail.kind !== 'fork') throw new Error('expected a fork'); + expect(joinTokens(rail.words)).toBe('user AND (await user.matchPassword(password))'); + expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(['WHEN', 'WHEN NOT']); + expect(rail.arms.map((a) => a.ends)).toEqual(['answers here', 'answers here']); + }); + + it('keeps a disjunction whole rather than reading it as two ways of arriving', () => { + // `!image || unlimitedCollection` is ONE condition, and the parentheses + // `guardLabel` puts round it are what stop the OR from splitting it. + const on = '(!image || unlimitedCollection)'; + const model = buildRailModel(payload([], [{ kind: 'fork', form: 'if', on, arms: [arm(on)] }])); + const rail = model[0]!; + if (rail.kind !== 'fork') throw new Error('expected a fork'); + expect(joinTokens(rail.words)).toBe('(!image || unlimitedCollection)'); + }); + + it('gives a switch’s arms their own conditions', () => { + const fork: WireItem = { + kind: 'fork', + form: 'switch', + on: 'kind', + arms: [arm("kind === 'a'"), arm('kind: default')], + }; + const model = buildRailModel(payload([], [fork])); + const rail = model[0]!; + if (rail.kind !== 'fork') throw new Error('expected a fork'); + expect(joinTokens(rail.words)).toBe('kind'); + expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(["WHEN kind === 'a'", 'WHEN kind: default']); + }); + + it('lets a try say `on error` once', () => { + expect(armWords('try', 'on error', arm('on error'))).toEqual([]); + const model = buildRailModel(payload([], [{ kind: 'fork', form: 'try', on: 'on error', arms: [arm('on error')] }])); + const rail = model[0]!; + if (rail.kind !== 'fork') throw new Error('expected a fork'); + expect(joinTokens(rail.words)).toBe('on error'); + }); + + it('says how each arm leaves', () => { + expect(endWords('reply')).toBe('answers here'); + expect(endWords('return')).toBe('returns here'); + expect(endWords('throw')).toBe('throws here'); + expect(endWords('exit')).toBe('leaves here'); + }); + + it('names each kind of bracketed run', () => { + const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false }; + expect(groupLabel({ kind: 'block', block: 'inline', via, body: [] })).toBe('via generateToken'); + expect(groupLabel({ kind: 'block', block: 'inline', body: [] })).toBe('via a helper'); + expect(groupLabel({ kind: 'block', block: 'later', by: 'then', body: [] })).toBe('later · then'); + expect(groupLabel({ kind: 'block', block: 'loop', by: 'item of items', body: [] })).toBe('for each item of items'); + expect(groupLabel({ kind: 'block', block: 'together', by: 'Promise.all', body: [] })).toBe('together · Promise.all'); + }); + + it('carries a box’s two lines and where the call is written', () => { + const model = buildRailModel( + payload([step('200', { label: '200', sub: 'response · authUser' })], [{ kind: 'step', step: '200', within: 'res.json' }]) + ); + const rail = model[0]!; + if (rail.kind !== 'step') throw new Error('expected a step'); + expect(rail.info?.label).toBe('200'); + expect(rail.info?.sub).toBe('response · authUser'); + expect(rail.within).toBe('res.json'); + }); + + it('says where the reading stopped', () => { + const model = buildRailModel(payload([], [{ kind: 'cut', why: 'folded' }, { kind: 'cut', why: 'depth' }])); + expect(model.map((i) => (i.kind === 'cut' ? i.text : ''))).toEqual([ + 'reads back into itself — the rest is the same code again', + 'as deep as this reading goes — start at a step below to read on', + ]); + }); + + it('is empty when there is no body to read', () => { + expect(buildRailModel({ ...payload([], []), program: null })).toEqual([]); + }); +}); diff --git a/__tests__/ui-steps-program.test.ts b/__tests__/ui-steps-program.test.ts index 30fd089..b8c35cb 100644 --- a/__tests__/ui-steps-program.test.ts +++ b/__tests__/ui-steps-program.test.ts @@ -58,7 +58,7 @@ function shape(block: WireBlock, indent = ''): string[] { out.push(...shape(arm.body, `${indent} `)); } } else if (item.kind === 'block') { - out.push(`${indent}${item.block} ${item.label}${item.again ? ' (again)' : ''}`); + out.push(`${indent}${item.block}${item.via ? ` via ${item.via.name}` : item.by ? ` ${item.by}` : ''}${item.again ? ' (again)' : ''}`); out.push(...shape(item.body, `${indent} `)); } else out.push(`${indent}cut ${item.why}`); } @@ -157,8 +157,9 @@ describe('buildProgram', () => { at('d', [g('kind: default', { form: 'case', branch })]), ], }); + // The head says what is being decided on; each arm its own case. expect(shape(p!.root)).toEqual([ - "switch kind === 'a'", + 'switch kind', " arm kind === 'a'", ' a', " arm kind === 'b'", @@ -224,14 +225,14 @@ describe('buildProgram', () => { const p = program({ root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })], }); - expect(shape(p!.root)).toEqual(['now', 'later later · then', ' afterwards']); + expect(shape(p!.root)).toEqual(['now', 'later then', ' afterwards']); }); it('puts calls started together in one block', () => { const p = program({ root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')], }); - expect(shape(p!.root)).toEqual(['together together', ' a inside Promise.all', ' b inside Promise.all', 'c']); + expect(shape(p!.root)).toEqual(['together Promise.all', ' a inside Promise.all', ' b inside Promise.all', 'c']); }); it('closes a fork when the code leaves it', () => { diff --git a/src/ui-server/api/program.ts b/src/ui-server/api/program.ts index a3beeae..d90181f 100644 --- a/src/ui-server/api/program.ts +++ b/src/ui-server/api/program.ts @@ -39,6 +39,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit'; export interface WireArm { /** This arm's own condition, in the words the rest of the view uses. */ when: string; + /** The arm taken when the fork's condition does NOT hold — the `else` side. */ + not?: true; /** How it leaves: it answers the request, returns, or throws. Null = it runs on. */ ends: WireArmEnd | null; body: WireBlock; @@ -61,7 +63,7 @@ export type WireItem = * after this function returns (`later`), or calls started together * (`together`). */ - | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true } + | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true } /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */ | { kind: 'cut'; why: 'folded' | 'depth' }; @@ -175,7 +177,10 @@ function blockFor(input: ProgramInput, fn: string, path: readonly string[], stat } for (let i = keep; i < guards.length; i++) { const g = guards[i]!; - const fork: Extract = { kind: 'fork', on: g.text, form: formOf(g), arms: [] }; + // The decision as a reader says it, not as the guard stores it: a + // disjunction keeps the parentheses that stop `a || b` from reading as + // two ways of arriving (`guardLabel` is the one place that decides). + const fork: Extract = { kind: 'fork', on: guardLabel([{ ...g, negated: false }]), form: formOf(g), arms: [] }; // An early exit is a fork whose OTHER arm left before this site could // run: `if (!product) throw` — the throw is written first, so it is the // first arm, and it is empty because nothing in the picture happens there. @@ -206,7 +211,6 @@ function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[] const block: Extract = { kind: 'block', block: 'inline', - label: via ? `via ${via.name}` : 'via a helper', ...(via ? { via } : {}), ...(site.within ? { within: site.within } : {}), body: [], @@ -265,18 +269,18 @@ function place(block: WireBlock, item: WireItem, site: ProgramSite): void { return; } const last = block[block.length - 1]; - if (last && last.kind === 'block' && last.block === run.block && last.label === run.label) { + if (last && last.kind === 'block' && last.block === run.block && last.by === run.by) { last.body.push(item); return; } - block.push({ kind: 'block', block: run.block, label: run.label, body: [item] }); + block.push({ kind: 'block', block: run.block, ...(run.by ? { by: run.by } : {}), body: [item] }); } -/** The run a site belongs to — `later · then`, `together` — or null for plain sequence. */ -function runFor(site: ProgramSite): { block: 'later' | 'together'; label: string } | null { +/** The run a site belongs to — registered to run later, started together — or null for plain sequence. */ +function runFor(site: ProgramSite): { block: 'later' | 'together'; by?: string } | null { const fires = site.trigger; - if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', label: `later · ${fires.name}` }; - if (site.within && TOGETHER.test(site.within)) return { block: 'together', label: 'together' }; + if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', by: fires.name }; + if (site.within && TOGETHER.test(site.within)) return { block: 'together', by: site.within }; return null; } @@ -285,7 +289,7 @@ function armFor(fork: Extract, g: BranchGuard): Wire const when = guardLabel([g]); const found = fork.arms.find((a) => a.when === when); if (found) return found; - const arm: WireArm = { when, ends: g.armExit ?? null, body: [] }; + const arm: WireArm = { when, ...(g.negated ? { not: true as const } : {}), ends: g.armExit ?? null, body: [] }; fork.arms.push(arm); return arm; } @@ -317,6 +321,9 @@ function formOf(g: BranchGuard): 'if' | 'switch' | 'ternary' | 'try' { function seal(input: ProgramInput, block: WireBlock): void { for (const item of block) { if (item.kind === 'fork') { + // A switch's head is the thing being decided on, which only its arms + // together say: every case was written ` === `. + if (item.form === 'switch') item.on = subjectOf(item.arms.map((a) => a.when)); for (const arm of item.arms) { seal(input, arm.body); if (repliesLast(input, arm.body)) arm.ends = 'reply'; @@ -326,6 +333,22 @@ function seal(input: ProgramInput, block: WireBlock): void { } } +/** + * What every arm of a switch is deciding on: the longest start they share, cut + * at a word. '' when they share nothing — then the head says nothing and each + * arm says its own condition, which is never wrong. + */ +function subjectOf(arms: readonly string[]): string { + if (arms.length < 2) return ''; + let common = arms[0]!; + for (const arm of arms.slice(1)) { + let i = 0; + while (i < common.length && i < arm.length && common[i] === arm[i]) i++; + common = common.slice(0, i); + } + return /^[\w$.?[\]'"]+/.exec(common.trim())?.[0] ?? ''; +} + /** Whether the last thing a block does is answer the request. */ function repliesLast(input: ProgramInput, block: WireBlock): boolean { const last = block[block.length - 1]; diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 74d17cb..080e3d1 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -177,7 +177,7 @@ {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)} {:else if route.view === 'steps'} - + {:else if route.view === 'dead'} {:else if route.view === 'unknown'} diff --git a/ui/src/components/steps/RailBlock.svelte b/ui/src/components/steps/RailBlock.svelte new file mode 100644 index 0000000..ae58e34 --- /dev/null +++ b/ui/src/components/steps/RailBlock.svelte @@ -0,0 +1,184 @@ + + +{#snippet words(tokens: WordToken[])} + {#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each} +{/snippet} + +
+ {#each items as item, i (i)} + {#if item.kind === 'step'} +
+ {#if item.within}inside {item.within}(…){/if} + {#if item.info} + onSelect(item.id)} + onStart={canStart(item.id) ? () => onStart(item.id) : undefined} + /> + {:else} + a step the picture left out + {/if} + {#if item.again}as above{/if} +
+ {#if item.body.length > 0} +
+ +
+ {/if} + {:else if item.kind === 'fork'} +
+
{@render words(item.words)}
+
+ {#each item.arms as arm, a (a)} +
+
{@render words(arm.words)}
+ {#if arm.body.length > 0} + + {/if} + {#if arm.ends}
{arm.ends}
{/if} +
+ {/each} +
+
+ {:else if item.kind === 'group'} +
+
+ {item.label}{#if item.within} · inside {item.within}(…){/if}{#if item.again} · read above{/if} +
+ {#if item.body.length > 0} + + {/if} +
+ {:else} +
{item.text}
+ {/if} + {/each} +
+ + diff --git a/ui/src/components/steps/StepBox.svelte b/ui/src/components/steps/StepBox.svelte new file mode 100644 index 0000000..d088dde --- /dev/null +++ b/ui/src/components/steps/StepBox.svelte @@ -0,0 +1,186 @@ + + + + + diff --git a/ui/src/components/steps/StepNode.svelte b/ui/src/components/steps/StepNode.svelte index ec58b48..7c69837 100644 --- a/ui/src/components/steps/StepNode.svelte +++ b/ui/src/components/steps/StepNode.svelte @@ -1,24 +1,13 @@ + +
+ + {#if open} +
+
+ start + {order ? 'Where the picture starts; below it, what it does in the code’s own order' : 'Where the picture starts; each row down is one more step away'} +
+ {#if project === 'api'} +
+ POST /x + An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which +
+
+ ⇢ fn + The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠) +
+
+ set + A data call — a function in a store or state file +
+
+ db + A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network +
+ {:else if project === 'web'} +
+ /path + A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which +
+
+ ⇢ fn + The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream) +
+
+ set + A store action — a function in a store file +
+
+ api + A call that leaves the index: the network, the database, the response, storage, a queue, email +
+ {:else} +
+ /path + A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event +
+
+ ⇢ fn + The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event) +
+
+ set + A store action — a function in a store file +
+
+ api + A call that leaves the index: the network, storage, the device, telemetry +
+ {/if} + {#if order} +
+ WHEN x + A fork — an if, a switch, a try or an early exit — with its arms side by side under the condition +
+
+ answers here + The arm stops there: it answers the request, returns or throws, and nothing below it runs +
+
+ via x + A helper drawn where it is called; later runs after this returns, together starts at once, for each repeats +
+
+ name … + Not entered: another {kindWord('screen', project)} (a chapter of its own), or a cap the walk hit — start there to see on +
+ {:else} +
+ + Leads to — the plumbing between the two is folded into the line +
+
+ + Established by a synthesized hop (an event channel, a callback, a helper's return value) +
+
+ + Goes back up the picture — leaves the top of its box, arrives at the bottom of the other +
+
+ → …x + The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always +
+
+ name … + Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on +
+ {/if} +
+ {/if} +
+ + diff --git a/ui/src/components/steps/StepsRail.svelte b/ui/src/components/steps/StepsRail.svelte new file mode 100644 index 0000000..d62ddcf --- /dev/null +++ b/ui/src/components/steps/StepsRail.svelte @@ -0,0 +1,94 @@ + + +
+
+ onSelect(anchor.id)} + /> + {#if anchor.step.trigger} +
FIRES FROM {triggerWords(anchor.step.trigger)}
+ {/if} +
+ {#if items.length === 0} +

Nothing in the index happens in this symbol's body — the picture has no order to read.

+ {:else} +
+ +
+ {/if} + {#if truncated > 0} +

+ {truncated} place{truncated === 1 ? '' : 's'} the reading stopped: code it had already read, or as deep as it goes. + Start at a step to read on from there. +

+ {/if} + {@render children?.()} +
+ + diff --git a/ui/src/lib/navigation.ts b/ui/src/lib/navigation.ts index 021f5c5..615a3fd 100644 --- a/ui/src/lib/navigation.ts +++ b/ui/src/lib/navigation.ts @@ -63,6 +63,12 @@ export interface StepsHrefOptions { depth?: number; /** Enter the screens the walk reaches, instead of drawing them as boundaries. */ through?: boolean; + /** + * Which reading: the code's `order` — the anchor's body as a rail — or the + * `tree` of what it sets in motion. Absent takes the answer's own default: + * the order for a handler or an endpoint, the tree for a screen. + */ + view?: 'order' | 'tree'; } /** @@ -155,6 +161,7 @@ export const hashNavigation: NavigationDriver = { else if (opts.symbol) params.set('symbol', opts.symbol); if (opts.depth) params.set('depth', String(opts.depth)); if (opts.through) params.set('through', '1'); + if (opts.view) params.set('view', opts.view); return `#/steps${query(params)}`; }, diff --git a/ui/src/lib/program-model.ts b/ui/src/lib/program-model.ts new file mode 100644 index 0000000..1f3679c --- /dev/null +++ b/ui/src/lib/program-model.ts @@ -0,0 +1,183 @@ +/** + * The Steps view's second reading, as the rail draws it. + * + * The server answers with the anchor's body folded into blocks and forks + * (`api/program.ts`); this turns that into what a reader sees — the boxes of + * the picture in the code's order, the conditions as words, and one line for + * every place the reading has to be honest about not being plain sequence + * (work registered to run later, calls started together, a helper already read + * above). Nothing here is geometry: the rail is a column of boxes with a + * hairline down its left, and a fork is a row of columns, so the browser lays + * it out and this file only decides what each thing SAYS. + * + * The words are the ones the rest of the view uses: `steps-model.ts` for a + * box's two lines and the vocabulary a project is read in, `conditions.ts` for + * WHEN / AND / OR / NOT. A fork carries its condition once, on its head; an + * arm then says only which side it is — *when* and *when not* — except in a + * `switch`, where each arm has a condition of its own to say. + */ + +import { conditionTokens, whenTokens, type WordToken } from './conditions'; +import { stepLabel, stepSub, type ProjectKind, type StepNodeInfo } from './steps-model'; +import type { WireArm, WireArmEnd, WireBlock, WireItem, WireNodeRef, WireStep, WireStepsPayload } from './wire'; + +/** The construct a fork came from. */ +export type ForkForm = Extract['form']; + +/** A step of the picture, where the code writes it. */ +export interface RailStep { + kind: 'step'; + id: string; + /** The link it arrived on — the panel's rows for this site. */ + link: string | null; + /** The box's words; null when the step is not in the picture (a cap removed it). */ + info: StepNodeInfo | null; + /** The call this one is written inside the arguments of — `res.json`. */ + within: string | null; + /** What it does, when the walk read on into it. */ + body: RailItem[]; + /** It happens here too, and was read above. */ + again: boolean; +} + +export interface RailArm { + /** WHEN / WHEN NOT, or a case's own condition. */ + words: WordToken[]; + /** What the arm's last line says when it stops there: `answers`, `returns`, `throws`, `leaves`. */ + ends: string | null; + body: RailItem[]; +} + +export interface RailFork { + kind: 'fork'; + /** The decision, in the conditions vocabulary. */ + words: WordToken[]; + /** The word for the construct: `if`, `switch`, `try`. */ + form: string; + arms: RailArm[]; +} + +/** A run that is not plain sequence, bracketed and labelled. */ +export interface RailGroup { + kind: 'group'; + block: 'inline' | 'loop' | 'later' | 'together'; + /** `via generateToken`, `for each item of items`, `later · then`, `together`. */ + label: string; + /** The helper drawn here, for its link to the symbol view. */ + via: WireNodeRef | null; + within: string | null; + again: boolean; + body: RailItem[]; +} + +export interface RailCut { + kind: 'cut'; + text: string; +} + +export type RailItem = RailStep | RailFork | RailGroup | RailCut; + +/** + * The rail for a payload: its anchor's body in the code's order, or an empty + * list when the server had nothing to read (a screen, an unreadable file). + */ +export function buildRailModel(payload: WireStepsPayload): RailItem[] { + if (!payload.program) return []; + const steps = new Map(payload.steps.map((s) => [s.id, s])); + return block(payload.program.root, steps, payload.project); +} + +function block(items: WireBlock, steps: Map, project: ProjectKind): RailItem[] { + return items.map((item) => one(item, steps, project)); +} + +function one(item: WireItem, steps: Map, project: ProjectKind): RailItem { + switch (item.kind) { + case 'step': { + const step = steps.get(item.step); + return { + kind: 'step', + id: item.step, + link: item.link ?? null, + info: step ? { id: step.id, step, label: stepLabel(step), sub: stepSub(step, project) } : null, + within: item.within ?? null, + body: item.body ? block(item.body, steps, project) : [], + again: item.again === true, + }; + } + case 'fork': + return { + kind: 'fork', + // The head is the decision itself, said once; the arms say only which + // side of it they are, so the head carries no WHEN of its own. + words: whenTokens(item.on), + form: item.form === 'switch' ? 'switch' : item.form === 'try' ? 'try' : 'if', + arms: item.arms.map((arm) => ({ + words: armWords(item.form, item.on, arm), + ends: arm.ends === null ? null : endWords(arm.ends), + body: block(arm.body, steps, project), + })), + }; + case 'block': + return { + kind: 'group', + block: item.block, + label: groupLabel(item), + via: item.via ?? null, + within: item.within ?? null, + again: item.again === true, + body: block(item.body, steps, project), + }; + default: + return { + kind: 'cut', + text: + item.why === 'folded' + ? 'reads back into itself — the rest is the same code again' + : 'as deep as this reading goes — start at a step below to read on', + }; + } +} + +/** + * What an arm says. The fork already carries the condition, so the two sides of + * an `if` say only which side they are; a `switch` arm has a condition of its + * own, and so does an arm the reading could not match to the head. + */ +export function armWords(form: ForkForm, on: string, arm: WireArm): WordToken[] { + // A case has a condition of its own to say; the one arm of a `try` is the + // head (`on error`) and says nothing twice. + if (form === 'switch') return conditionTokens(arm.when); + if (form === 'try') return []; + if (arm.not === true) return [{ kw: true, text: 'WHEN' }, { kw: true, text: 'NOT' }]; + if (arm.when === on) return [{ kw: true, text: 'WHEN' }]; + return conditionTokens(arm.when); +} + +/** How an arm leaves, as a reader says it. */ +export function endWords(end: WireArmEnd): string { + switch (end) { + case 'reply': + return 'answers here'; + case 'return': + return 'returns here'; + case 'throw': + return 'throws here'; + default: + return 'leaves here'; + } +} + +/** The words on a bracketed run. */ +export function groupLabel(item: Extract): string { + switch (item.block) { + case 'inline': + return item.via ? `via ${item.via.name}` : 'via a helper'; + case 'loop': + return item.by ? `for each ${item.by}` : 'for each'; + case 'later': + return item.by ? `later · ${item.by}` : 'later'; + default: + return item.by ? `together · ${item.by}` : 'together'; + } +} diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts index a5e1120..3fb83bf 100644 --- a/ui/src/lib/router.svelte.ts +++ b/ui/src/lib/router.svelte.ts @@ -12,7 +12,7 @@ * #/flow flow strip (?from=&to= | ?symbols= | ?t=) * #/entry entry points (where a flow starts) * #/screens screens (the app's screens and transitions) - * #/steps steps (?anchor= | ?symbol=: what happens from there) + * #/steps steps (?anchor= | ?symbol=: what happens from there; ?view=order|tree) * #/dead dead code (?exported=1 widens the claim) * * Node ids are opaque engine strings shaped `:` or @@ -87,6 +87,8 @@ export type Route = symbol: string | null; depth: number | null; through: boolean; + /** Which reading the URL asked for; null takes the answer's own default. */ + reading: 'order' | 'tree' | null; } | { view: 'dead'; @@ -156,12 +158,14 @@ export function parseHash(hash: string): RouterLocation { // The anchor travels in the URL, so "what happens on the review screen" // is a link that reopens as the same picture. const depth = Number.parseInt(params.get('depth') ?? '', 10); + const reading = params.get('view'); route = { view: 'steps', anchor: params.get('anchor'), symbol: params.get('symbol'), depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null, through: params.get('through') === '1', + reading: reading === 'order' || reading === 'tree' ? reading : null, }; } else if (head === 'dead' && rest.length === 0) { // The widening travels in the URL like the map's shape does: a link to diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts index 3cf3a3d..f6f03ca 100644 --- a/ui/src/lib/wire.ts +++ b/ui/src/lib/wire.ts @@ -814,6 +814,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit'; export interface WireArm { /** This arm's own condition, in the words the rest of the view uses. */ when: string; + /** The arm taken when the fork's condition does NOT hold — the `else` side. */ + not?: true; /** How it leaves: it answers the request, returns, or throws. Null = it runs on. */ ends: WireArmEnd | null; body: WireBlock; @@ -836,7 +838,7 @@ export type WireItem = * after this function returns (`later`), or calls started together * (`together`). */ - | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; label: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true } + | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true } /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */ | { kind: 'cut'; why: 'folded' | 'depth' }; diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte index ba226bf..e1cb026 100644 --- a/ui/src/views/StepsView.svelte +++ b/ui/src/views/StepsView.svelte @@ -17,6 +17,8 @@ import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte'; import '@xyflow/svelte/dist/style.css'; import StepNode from '../components/steps/StepNode.svelte'; + import StepsRail from '../components/steps/StepsRail.svelte'; + import StepsKey from '../components/steps/StepsKey.svelte'; import ScreenEdge from '../components/screens/ScreenEdge.svelte'; import KindGlyph from '../components/KindGlyph.svelte'; import { @@ -44,6 +46,7 @@ triggerWords, type StepsModel, } from '../lib/steps-model'; + import { buildRailModel } from '../lib/program-model'; interface Props { anchor: string | null; @@ -51,8 +54,14 @@ depth: number | null; /** Enter the screens the walk reaches, instead of drawing them as boundaries. */ through: boolean; + /** + * Which reading the URL asked for — the code's `order` or the `tree` of + * what the anchor sets in motion. Null takes the answer's own default: the + * order for a handler or an endpoint, the tree for a screen. + */ + reading: 'order' | 'tree' | null; } - let { anchor, symbol, depth, through }: Props = $props(); + let { anchor, symbol, depth, through, reading }: Props = $props(); let payload = $state(null); let error = $state(null); @@ -185,6 +194,36 @@ const model = $derived(payload === null ? null : buildStepsModel(payload)); + /** + * Which reading is on screen. The URL wins; otherwise the answer's own + * default — the code's order for a handler, an endpoint or any function, the + * tree for a screen, where handlers fire on events and have nothing to order. + */ + const readAs = $derived<'order' | 'tree'>(reading ?? payload?.defaultView ?? 'tree'); + const rail = $derived(payload === null || readAs !== 'order' ? [] : buildRailModel(payload)); + /** The rail can be asked for and have nothing to show: say so rather than drawing an empty page. */ + const railReadable = $derived(payload?.program != null); + /** The steps on the selected step's own lines — everything else on the rail is dimmed. */ + const litOnRail = $derived.by(() => { + if (payload === null || selected === null) return null; + const set = new Set([selected]); + for (const l of payload.links) { + if (l.from === selected) set.add(l.to); + if (l.to === selected) set.add(l.from); + } + return set; + }); + /** A step with a symbol behind it can become the next anchor. */ + function canStart(id: string): boolean { + const step = payload?.steps.find((s) => s.id === id); + return !!step?.node && !step.anchor; + } + function selectOnRail(id: string): void { + selected = selected === id ? null : id; + hovered = null; + panelHot = null; + } + const neighbours = $derived.by(() => { if (model === null || selected === null) return null; const set = new Set([selected]); @@ -284,14 +323,16 @@ const visibleIds = $derived(new Set(edges.map((e) => e.id))); /** The same picture with one setting changed: the anchor as the URL asked for it, the rest kept. */ - function rewrite(changes: { depth?: number; through?: boolean }): string { - const opts = { + function rewrite(changes: { depth?: number; through?: boolean; view?: 'order' | 'tree' }): string { + return stepsHref({ anchor: anchor ?? undefined, symbol: anchor === null ? (symbol ?? undefined) : undefined, depth: changes.depth ?? depth ?? undefined, through: changes.through ?? through, - }; - return stepsHref(opts); + // The reading travels in the URL once it has been chosen, so a link to + // "the login endpoint in the code's order" reopens as that. + view: changes.view ?? reading ?? undefined, + }); } function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void { @@ -308,7 +349,7 @@ } function onStageMove(event: MouseEvent): void { - if (model === null || stage === null) return; + if (model === null || stage === null || readAs === 'order') return; const target = event.target as Element | null; if (target?.closest('.spill')) return; if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) { @@ -450,6 +491,37 @@ {:else if loading && payload === null}

Walking from the anchor…

+ {:else if model !== null && payload !== null && readAs === 'order'} + {#if railReadable} + startHere(id)} + {canStart} + > + (legendOpen = next)} + /> + + {:else} +
+

This has no body to read in order

+

+ Nothing the picture holds is written inside this symbol — a screen renders handlers that fire on + events, and they have no order between them. Read it as what it sets in motion instead. +

+

What it sets in motion →

+
+ {/if} {:else if model !== null && payload !== null} -
- - {#if legendOpen} -
-
- start - Where the picture starts; each row down is one more step away -
- {#if payload.project === 'api'} -
- POST /x - An endpoint — its verb and path — or a handler: a function a request, a job, an event or a schedule fires; its line says which -
-
- ⇢ fn - The code crosses a tier: a call into another service or a job put on a queue (⇢), or a job, an event, a message arriving (⇠) -
-
- set - A data call — a function in a store or state file -
-
- db - A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network -
- {:else if payload.project === 'web'} -
- /path - A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which -
-
- ⇢ fn - The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream) -
-
- set - A store action — a function in a store file -
-
- api - A call that leaves the index: the network, the database, the response, storage, a queue, email -
- {:else} -
- /path - A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event -
-
- ⇢ fn - The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event) -
-
- set - A store action — a function in a store file -
-
- api - A call that leaves the index: the network, storage, the device, telemetry -
- {/if} -
- - Leads to — the plumbing between the two is folded into the line -
-
- - Established by a synthesized hop (an event channel, a callback, a helper's return value) -
-
- - Goes back up the picture — leaves the top of its box, arrives at the bottom of the other -
-
- → …x - The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always -
-
- name … - Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on -
-
- {/if} -
{#if hovered !== null && hoveredInfo !== null}
@@ -579,6 +566,10 @@
{/if} {/if} + + {#if payload !== null && model !== null && readAs === 'tree'} + (legendOpen = next)} /> + {/if} {#if payload !== null && model !== null} @@ -755,6 +746,11 @@ {/each}

{/if} +

+ Read as: + in order + what it sets in motion +

{payload.steps.length} steps · {payload.links.length} links · depth