feat(steps): the rail — a handler read top to bottom, forks and all

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
Colby McHenry
2026-08-29 13:35:17 -05:00
co-authored by Claude Opus 5
parent b02e192ffa
commit 9acab0020f
14 changed files with 1187 additions and 343 deletions
+132
View File
@@ -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> = {}): 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> = {}): 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([]);
});
});
+5 -4
View File
@@ -58,7 +58,7 @@ function shape(block: WireBlock, indent = ''): string[] {
out.push(...shape(arm.body, `${indent} `)); out.push(...shape(arm.body, `${indent} `));
} }
} else if (item.kind === 'block') { } 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} `)); out.push(...shape(item.body, `${indent} `));
} else out.push(`${indent}cut ${item.why}`); } else out.push(`${indent}cut ${item.why}`);
} }
@@ -157,8 +157,9 @@ describe('buildProgram', () => {
at('d', [g('kind: default', { form: 'case', branch })]), 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([ expect(shape(p!.root)).toEqual([
"switch kind === 'a'", 'switch kind',
" arm kind === 'a'", " arm kind === 'a'",
' a', ' a',
" arm kind === 'b'", " arm kind === 'b'",
@@ -224,14 +225,14 @@ describe('buildProgram', () => {
const p = program({ const p = program({
root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })], 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', () => { it('puts calls started together in one block', () => {
const p = program({ const p = program({
root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')], 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', () => { it('closes a fork when the code leaves it', () => {
+33 -10
View File
@@ -39,6 +39,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
export interface WireArm { export interface WireArm {
/** This arm's own condition, in the words the rest of the view uses. */ /** This arm's own condition, in the words the rest of the view uses. */
when: string; 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. */ /** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
ends: WireArmEnd | null; ends: WireArmEnd | null;
body: WireBlock; body: WireBlock;
@@ -61,7 +63,7 @@ export type WireItem =
* after this function returns (`later`), or calls started together * after this function returns (`later`), or calls started together
* (`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. */ /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
| { kind: 'cut'; why: 'folded' | 'depth' }; | { 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++) { for (let i = keep; i < guards.length; i++) {
const g = guards[i]!; const g = guards[i]!;
const fork: Extract<WireItem, { kind: 'fork' }> = { 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<WireItem, { kind: 'fork' }> = { 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 // 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 // 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. // 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<WireItem, { kind: 'block' }> = { const block: Extract<WireItem, { kind: 'block' }> = {
kind: 'block', kind: 'block',
block: 'inline', block: 'inline',
label: via ? `via ${via.name}` : 'via a helper',
...(via ? { via } : {}), ...(via ? { via } : {}),
...(site.within ? { within: site.within } : {}), ...(site.within ? { within: site.within } : {}),
body: [], body: [],
@@ -265,18 +269,18 @@ function place(block: WireBlock, item: WireItem, site: ProgramSite): void {
return; return;
} }
const last = block[block.length - 1]; 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); last.body.push(item);
return; 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. */ /** The run a site belongs to — registered to run later, started together — or null for plain sequence. */
function runFor(site: ProgramSite): { block: 'later' | 'together'; label: string } | null { function runFor(site: ProgramSite): { block: 'later' | 'together'; by?: string } | null {
const fires = site.trigger; const fires = site.trigger;
if (fires && fires.kind === 'callback' && LATER_OF.test(fires.name)) return { block: 'later', label: `later · ${fires.name}` }; 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', label: 'together' }; if (site.within && TOGETHER.test(site.within)) return { block: 'together', by: site.within };
return null; return null;
} }
@@ -285,7 +289,7 @@ function armFor(fork: Extract<WireItem, { kind: 'fork' }>, g: BranchGuard): Wire
const when = guardLabel([g]); const when = guardLabel([g]);
const found = fork.arms.find((a) => a.when === when); const found = fork.arms.find((a) => a.when === when);
if (found) return found; 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); fork.arms.push(arm);
return arm; return arm;
} }
@@ -317,6 +321,9 @@ function formOf(g: BranchGuard): 'if' | 'switch' | 'ternary' | 'try' {
function seal(input: ProgramInput, block: WireBlock): void { function seal(input: ProgramInput, block: WireBlock): void {
for (const item of block) { for (const item of block) {
if (item.kind === 'fork') { if (item.kind === 'fork') {
// A switch's head is the thing being decided on, which only its arms
// together say: every case was written `<subject> === <value>`.
if (item.form === 'switch') item.on = subjectOf(item.arms.map((a) => a.when));
for (const arm of item.arms) { for (const arm of item.arms) {
seal(input, arm.body); seal(input, arm.body);
if (repliesLast(input, arm.body)) arm.ends = 'reply'; 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. */ /** Whether the last thing a block does is answer the request. */
function repliesLast(input: ProgramInput, block: WireBlock): boolean { function repliesLast(input: ProgramInput, block: WireBlock): boolean {
const last = block[block.length - 1]; const last = block[block.length - 1];
+1 -1
View File
@@ -177,7 +177,7 @@
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)} {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
<ScreensView /> <ScreensView />
{:else if route.view === 'steps'} {:else if route.view === 'steps'}
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} /> <StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} reading={route.reading} />
{:else if route.view === 'dead'} {:else if route.view === 'dead'}
<DeadCodeView exported={route.exported} /> <DeadCodeView exported={route.exported} />
{:else if route.view === 'unknown'} {:else if route.view === 'unknown'}
+184
View File
@@ -0,0 +1,184 @@
<script lang="ts">
/**
* A run of the rail: the items of one block, top to bottom, on a hairline.
*
* Recursive, because the code is: a fork is a row of arm columns, each arm a
* block of its own; a helper drawn where it is called, a loop's body, work
* that runs later and calls started together are bracketed blocks with a
* label in `--ink-3`. No layout engine and no measuring — the browser lays a
* column of boxes out, which is all a rail is.
*/
import StepBox from './StepBox.svelte';
import Self from './RailBlock.svelte';
import type { RailItem } from '../../lib/program-model';
import type { ProjectKind } from '../../lib/steps-model';
import type { WordToken } from '../../lib/conditions';
interface Props {
items: RailItem[];
project: ProjectKind;
selected: string | null;
/** Steps not on the selected step's line, dimmed; null = nothing is selected. */
lit: Set<string> | null;
onSelect: (id: string) => void;
onStart: (id: string) => void;
/** Whether a step may become the next anchor — false for an effect. */
canStart: (id: string) => boolean;
}
let { items, project, selected, lit, onSelect, onStart, canStart }: Props = $props();
</script>
{#snippet words(tokens: WordToken[])}
{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b>{:else}{t.text}{/if}{/each}
{/snippet}
<div class="run">
{#each items as item, i (i)}
{#if item.kind === 'step'}
<div class="line">
{#if item.within}<span class="note">inside {item.within}(…)</span>{/if}
{#if item.info}
<StepBox
info={item.info}
{project}
selected={selected === item.id}
dimmed={lit !== null && !lit.has(item.id)}
note={item.again ? 'It happens here too; what it does is read above.' : ''}
onSelect={() => onSelect(item.id)}
onStart={canStart(item.id) ? () => onStart(item.id) : undefined}
/>
{:else}
<span class="note">a step the picture left out</span>
{/if}
{#if item.again}<span class="note">as above</span>{/if}
</div>
{#if item.body.length > 0}
<div class="nested">
<Self items={item.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
</div>
{/if}
{:else if item.kind === 'fork'}
<div class="fork">
<div class="cond mono">{@render words(item.words)}</div>
<div class="arms">
{#each item.arms as arm, a (a)}
<div class="arm">
<div class="armh mono">{@render words(arm.words)}</div>
{#if arm.body.length > 0}
<Self items={arm.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
{/if}
{#if arm.ends}<div class="ends">{arm.ends}</div>{/if}
</div>
{/each}
</div>
</div>
{:else if item.kind === 'group'}
<div class="group" class:again={item.again}>
<div class="label">
<span>{item.label}</span>{#if item.within}<span class="note">&nbsp;· inside {item.within}(…)</span>{/if}{#if item.again}<span class="note">&nbsp;· read above</span>{/if}
</div>
{#if item.body.length > 0}
<Self items={item.body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
{/if}
</div>
{:else}
<div class="line"><span class="note">{item.text}</span></div>
{/if}
{/each}
</div>
<style>
.run {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
/* The rail: a hairline down the left of every run but the outermost. */
.line {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
max-width: 100%;
min-width: 0;
}
.nested,
.group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding-left: 12px;
border-left: 1px solid var(--rule-soft);
max-width: 100%;
min-width: 0;
}
.group.again {
border-left-style: dashed;
}
.label {
font: 400 11px var(--sans);
color: var(--ink-3);
}
.note {
font: 400 11px var(--sans);
color: var(--ink-3);
}
.fork {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
max-width: 100%;
min-width: 0;
}
.cond {
font-size: 12px;
line-height: 16px;
padding: 2px 7px;
border: 1px solid var(--rule-soft);
background: var(--paper-2);
color: var(--ink-2);
max-width: 640px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arms {
display: flex;
align-items: flex-start;
gap: 18px;
min-width: 0;
}
.arm {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 8px 0 0 12px;
border-left: 1px solid var(--rule-soft);
border-top: 1px solid var(--rule-soft);
min-width: 0;
}
.armh {
font-size: 11.5px;
line-height: 15px;
color: var(--ink-2);
max-width: 520px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ends {
font: 400 11px var(--sans);
color: var(--ink-3);
border-top: 1px solid var(--rule-faint);
padding-top: 4px;
align-self: stretch;
}
.kw {
font-weight: 600;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
/**
* One step's box — the same box on the canvas and on the rail.
*
* It is the Screens view's screen box with a kind: a screen is drawn exactly
* as there; a handler is a plain box; a native call or a native event carries
* an accent rule on its left, where the language changes under the code — and
* so does an endpoint the code crosses to (`⇢ POST /api/users`) or a job, an
* event, a message arriving; a store action sits on `--paper-2`; a call that
* leaves the index is dashed, a place the graph cannot follow into. The
* anchor carries the entry mark. A step the walk was cut at ends its name
* with an ellipsis, and its tooltip says which cap.
*
* A click selects the step; a double-click starts the picture there (the
* panel's *Start here →*). On the canvas the box is sized by the layout; on
* the rail it sizes to its own words.
*/
import type { MapNodeLayout } from '../../lib/map-model';
import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
interface Props {
info: StepNodeInfo;
project: ProjectKind;
selected: boolean;
dimmed: boolean;
/** The canvas sizes its boxes; the rail lets them size to content. */
size?: Pick<MapNodeLayout, 'width' | 'height'> | null;
/** Said before the tooltip's own words — the rail says where the call is written. */
note?: string;
onSelect: (id: string) => void;
/** Re-anchor the picture on this step — a double-click; absent for a step with no symbol. */
onStart?: (id: string) => void;
}
let { info, project, selected, dimmed, size = null, note = '', onSelect, onStart }: Props = $props();
const step = $derived(info.step);
const cutNote = $derived.by(() => {
switch (step.cut) {
case 'depth':
return ' More happens past the depth of this picture — start here to see it.';
case 'fan-out':
return ' It reaches more than the walk follows from one node.';
case 'folded':
return ' The walk folded as much plumbing as it allows from one step.';
case 'steps':
return ' The picture reached its size limit here.';
case 'screen':
return ` Another ${kindWord('screen', project, step)} — a chapter of its own. Start here to see what happens on it.`;
case 'component':
return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
default:
return '';
}
});
</script>
<button
class={`snode k-${step.kind}`}
class:sel={selected}
class:dimmed
class:anchor={step.anchor}
class:rail={size === null}
style={size === null ? undefined : `width:${size.width}px;height:${size.height}px`}
onclick={() => onSelect(info.id)}
ondblclickcapture={(e) => {
// The flow canvas zooms on a double-click that reaches its pane; a
// double-click on a box is a navigation, not a zoom — stop it here,
// at the target, before it bubbles. The pane's own double-click keeps zooming.
e.stopPropagation();
onStart?.(info.id);
}}
aria-pressed={selected}
title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, project, step)}. ${info.sub}.${note ? ` ${note}` : ''}${cutNote}${onStart && !step.anchor ? ' Double-click to start here.' : ''}`}
>
<span class="name"
>{#if step.anchor}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}{#if step.cut !== null}<span
class="more"
aria-hidden="true"> …</span
>{/if}</span
>
<span class="sub">{info.sub}</span>
</button>
<style>
.snode {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
box-sizing: border-box;
padding: 0 9px;
border: 1px solid var(--ink);
border-radius: 0;
background: var(--paper);
text-align: left;
cursor: pointer;
font: inherit;
color: var(--ink);
transition: background 90ms linear;
}
/* On the rail a box sizes to its words, and wears its padding itself. */
.snode.rail {
align-items: flex-start;
padding: 5px 9px;
max-width: 100%;
}
.snode:hover,
.snode.sel {
border-width: 2px;
padding: 0 8px;
}
.snode.rail:hover,
.snode.rail.sel {
padding: 4px 8px;
}
.snode:hover,
.snode.sel {
background: var(--press);
}
.snode.dimmed {
border-color: var(--ink-4);
color: var(--ink-4);
}
.snode.dimmed .sub {
color: var(--ink-4);
}
/* The language changes under the code: a rule where it does. */
.snode.k-bridge,
.snode.k-event {
border-left: 3px solid var(--accent);
padding-left: 7px;
}
.snode.k-bridge:hover,
.snode.k-bridge.sel,
.snode.k-event:hover,
.snode.k-event.sel {
border-left-width: 3px;
padding-left: 7px;
}
.snode.k-bridge.dimmed,
.snode.k-event.dimmed {
border-left-color: var(--accent-line);
}
.snode.k-store {
background: var(--paper-2);
}
.snode.k-store:hover,
.snode.k-store.sel {
background: var(--press);
}
/* Outside the index: a place the graph cannot follow into. */
.snode.k-effect {
border-style: dashed;
border-color: var(--ink-3);
}
.snode:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.name {
font: 500 13px var(--mono);
line-height: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.mark {
color: var(--accent);
margin-right: 5px;
font-size: 9px;
vertical-align: 1px;
}
.more {
color: var(--ink-3);
}
.sub {
font: 400 11px var(--sans);
line-height: 13px;
color: var(--ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
</style>
+14 -151
View File
@@ -1,24 +1,13 @@
<script lang="ts"> <script lang="ts">
/** /**
* One step on the Steps view. The box is the Screens view's screen box with * One step on the Steps view's canvas: the shared box ({@link StepBox}) with
* a kind: a screen is drawn exactly as there; a handler is a plain box; a * hidden handles along its top and bottom, one per port the layout decided
* native call or a native event carries an accent rule on its left, where * (`directional` ports), exactly as the screen box has.
* the language changes under the code — and so does an endpoint the code
* crosses to (`⇢ POST /api/users`) or a job, an event, a message arriving;
* a store action sits on `--paper-2`;
* a call that leaves the index is dashed, like a trigger no screen reaches
* on the Screens view — a place the graph cannot follow into. The anchor
* carries the entry mark. A step the walk was cut at ends its name with an
* ellipsis, and its tooltip says which cap.
*
* Hidden handles along the top and bottom, one per port the layout decided
* (`directional` ports), exactly as the screen box. A click selects the step;
* a double-click starts the picture there (the panel's *Start here →*) — an
* endpoint or another screen reached as a boundary opens as its own chapter.
*/ */
import { Handle, Position, type NodeProps } from '@xyflow/svelte'; import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import StepBox from './StepBox.svelte';
import type { MapNodeLayout } from '../../lib/map-model'; import type { MapNodeLayout } from '../../lib/map-model';
import { kindWord, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model'; import type { ProjectKind, StepNodeInfo } from '../../lib/steps-model';
let { data }: NodeProps = $props(); let { data }: NodeProps = $props();
@@ -35,27 +24,6 @@
} }
); );
const layout = $derived(node.layout); const layout = $derived(node.layout);
const info = $derived(node.info);
const step = $derived(info.step);
const cutNote = $derived.by(() => {
switch (step.cut) {
case 'depth':
return ' More happens past the depth of this picture — start here to see it.';
case 'fan-out':
return ' It reaches more than the walk follows from one node.';
case 'folded':
return ' The walk folded as much plumbing as it allows from one step.';
case 'steps':
return ' The picture reached its size limit here.';
case 'screen':
return ` Another ${kindWord('screen', node.project, step)} — a chapter of its own. Start here to see what happens on it.`;
case 'component':
return ' The event lands in a component of another screen — a picture of its own. Start here to see it.';
default:
return '';
}
});
function portStyle(index: number, total: number): string { function portStyle(index: number, total: number): string {
return `left:${((index + 1) / (total + 1)) * 100}%`; return `left:${((index + 1) / (total + 1)) * 100}%`;
@@ -72,31 +40,15 @@
/> />
{/each} {/each}
<button <StepBox
class={`snode k-${step.kind}`} info={node.info}
class:sel={node.selected} project={node.project}
class:dimmed={node.dimmed} selected={node.selected}
class:anchor={step.anchor} dimmed={node.dimmed}
style={`width:${layout.width}px;height:${layout.height}px`} size={layout}
onclick={() => node.onSelect(info.id)} onSelect={node.onSelect}
ondblclickcapture={(e) => { onStart={node.onStart}
// The flow canvas zooms on a double-click that reaches its pane; a />
// double-click on a box is a navigation, not a zoom — stop it here,
// at the target, before it bubbles. The pane's own double-click keeps zooming.
e.stopPropagation();
node.onStart?.(info.id);
}}
aria-pressed={node.selected}
title={`${info.label} — ${step.anchor ? 'where this picture starts; ' : ''}${kindWord(step.kind, node.project, step)}. ${info.sub}.${cutNote}${node.onStart && !step.anchor ? ' Double-click to start here.' : ''}`}
>
<span class="name"
>{#if step.anchor}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}{#if step.cut !== null}<span
class="more"
aria-hidden="true"> …</span
>{/if}</span
>
<span class="sub">{info.sub}</span>
</button>
{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)} {#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
<Handle <Handle
@@ -107,92 +59,3 @@
isConnectable={false} isConnectable={false}
/> />
{/each} {/each}
<style>
.snode {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
box-sizing: border-box;
padding: 0 9px;
border: 1px solid var(--ink);
border-radius: 0;
background: var(--paper);
text-align: left;
cursor: pointer;
font: inherit;
color: var(--ink);
transition: background 90ms linear;
}
.snode:hover,
.snode.sel {
border-width: 2px;
padding: 0 8px;
background: var(--press);
}
.snode.dimmed {
border-color: var(--ink-4);
color: var(--ink-4);
}
.snode.dimmed .sub {
color: var(--ink-4);
}
/* The language changes under the code: a rule where it does. */
.snode.k-bridge,
.snode.k-event {
border-left: 3px solid var(--accent);
padding-left: 7px;
}
.snode.k-bridge:hover,
.snode.k-bridge.sel,
.snode.k-event:hover,
.snode.k-event.sel {
border-left-width: 3px;
padding-left: 7px;
}
.snode.k-bridge.dimmed,
.snode.k-event.dimmed {
border-left-color: var(--accent-line);
}
.snode.k-store {
background: var(--paper-2);
}
.snode.k-store:hover,
.snode.k-store.sel {
background: var(--press);
}
/* Outside the index: a place the graph cannot follow into. */
.snode.k-effect {
border-style: dashed;
border-color: var(--ink-3);
}
.snode:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.name {
font: 500 13px var(--mono);
line-height: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mark {
color: var(--accent);
margin-right: 5px;
font-size: 9px;
vertical-align: 1px;
}
.more {
color: var(--ink-3);
}
.sub {
font: 400 11px var(--sans);
line-height: 13px;
color: var(--ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<script lang="ts">
/**
* The Steps view's key.
*
* The same rows for both readings, worded for the one on screen: the kinds of
* box are the same either way, and only the last rows differ — the canvas
* explains its lines and pills, the rail its forks and terminals. On the
* canvas it floats over the picture, bottom left; on the rail it is the last
* thing in the document, because a rail scrolls and an overlay would sit on
* top of the code it is explaining.
*/
import { kindWord, type ProjectKind } from '../../lib/steps-model';
interface Props {
project: ProjectKind;
/** The reading on screen: the rail's rows, or the canvas's. */
order: boolean;
/** In the flow of a scrolling rail rather than floating over a canvas. */
flow: boolean;
open: boolean;
onToggle: (open: boolean) => void;
}
let { project, order, flow, open, onToggle }: Props = $props();
</script>
<div class="legend" class:open class:flow>
<button class="legend-h" onclick={() => onToggle(!open)} aria-expanded={open}>
Key <span class="dim">{open ? '▾' : '▸'}</span>
</button>
{#if open}
<div class="legend-body">
<div class="lrow">
<span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
<span>{order ? 'Where the picture starts; below it, what it does in the code’s own order' : 'Where the picture starts; each row down is one more step away'}</span>
</div>
{#if project === 'api'}
<div class="lrow">
<span class="k-box mono">POST /x</span>
<span>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</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>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 (⇠)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A data call — a function in a store or state file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">db</span>
<span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
</div>
{:else if project === 'web'}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
</div>
{:else}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
</div>
{/if}
{#if order}
<div class="lrow">
<span class="k-label mono">WHEN x</span>
<span>A fork — an <span class="mono">if</span>, a <span class="mono">switch</span>, a <span class="mono">try</span> or an early exit — with its arms side by side under the condition</span>
</div>
<div class="lrow">
<span class="k-label">answers here</span>
<span>The arm stops there: it answers the request, returns or throws, and nothing below it runs</span>
</div>
<div class="lrow">
<span class="k-label">via x</span>
<span>A helper drawn where it is called; <span class="mono">later</span> runs after this returns, <span class="mono">together</span> starts at once, <span class="mono">for each</span> repeats</span>
</div>
<div class="lrow">
<span class="k-label mono">name …</span>
<span>Not entered: another {kindWord('screen', project)} (a chapter of its own), or a cap the walk hit — start there to see on</span>
</div>
{:else}
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>Leads to — the plumbing between the two is folded into the line</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-synth" /></svg>
<span>Established by a synthesized hop (an event channel, a callback, a helper's return value)</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
<span>Goes back up the picture — leaves the top of its box, arrives at the bottom of the other</span>
</div>
<div class="lrow">
<span class="k-label mono">→ …x</span>
<span>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</span>
</div>
<div class="lrow">
<span class="k-label mono">name …</span>
<span>Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on</span>
</div>
{/if}
</div>
{/if}
</div>
<style>
.legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
max-width: 400px;
border: 1px solid var(--rule);
background: var(--paper);
font-size: 11.5px;
color: var(--ink-2);
}
.legend-h {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 5px 10px;
text-align: left;
color: var(--ink);
font: 600 12px var(--sans);
cursor: pointer;
}
.legend-body {
padding: 2px 10px 8px;
border-top: 1px solid var(--rule-soft);
}
.lrow {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
.lrow > :first-child {
flex: 0 0 44px;
display: inline-flex;
justify-content: center;
}
.k-line {
stroke: var(--ink);
stroke-opacity: 0.6;
stroke-width: 1.5;
fill: none;
}
.k-line.k-synth {
stroke-dasharray: 5 3;
}
.k-line.k-back {
stroke: var(--accent);
stroke-opacity: 0.8;
stroke-dasharray: 4 3;
}
.k-label {
font-size: 10.5px;
color: var(--ink-3);
}
.k-box {
box-sizing: border-box;
padding: 1px 5px;
border: 1px solid var(--ink);
font-size: 10.5px;
color: var(--ink);
line-height: 14px;
}
.k-box.k-cross {
border-left: 3px solid var(--accent);
}
.k-box.k-store {
background: var(--paper-2);
}
.k-box.k-effect {
border-style: dashed;
border-color: var(--ink-3);
}
.k-anchor .mark {
font-size: 8px;
margin-right: 3px;
vertical-align: 1px;
}
/* On the rail the key is the document's last block, not an overlay. */
.legend.flow {
position: static;
margin: 28px 0 0;
max-width: 520px;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
/**
* The Steps view read in the code's ORDER: the anchor at the top, then its
* body top to bottom — the calls in the order they are written, a fork where
* the code forks, its arms side by side, an arm that answers or leaves ending
* there. It is the same walk the canvas draws, folded by
* `api/program.ts` and worded by `program-model.ts`; a click selects a step
* and fills the same panel, a double-click starts the picture there.
*/
import StepBox from './StepBox.svelte';
import RailBlock from './RailBlock.svelte';
import type { Snippet } from 'svelte';
import type { RailItem } from '../../lib/program-model';
import { triggerWords, type ProjectKind, type StepNodeInfo } from '../../lib/steps-model';
interface Props {
anchor: StepNodeInfo;
items: RailItem[];
project: ProjectKind;
selected: string | null;
lit: Set<string> | null;
/** Items the reading could not place — a recursion or a cap it hit. */
truncated: number;
onSelect: (id: string) => void;
onStart: (id: string) => void;
canStart: (id: string) => boolean;
/** The key, last in the document — a rail scrolls, so it cannot float over it. */
children?: Snippet;
}
let { anchor, items, project, selected, lit, truncated, onSelect, onStart, canStart, children }: Props = $props();
</script>
<div class="rail">
<div class="head">
<StepBox
info={anchor}
{project}
selected={selected === anchor.id}
dimmed={false}
onSelect={() => onSelect(anchor.id)}
/>
{#if anchor.step.trigger}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(anchor.step.trigger)}</div>
{/if}
</div>
{#if items.length === 0}
<p class="empty">Nothing in the index happens in this symbol's body — the picture has no order to read.</p>
{:else}
<div class="body">
<RailBlock {items} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
</div>
{/if}
{#if truncated > 0}
<p class="empty">
{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.
</p>
{/if}
{@render children?.()}
</div>
<style>
.rail {
height: 100%;
overflow: auto;
padding: 20px 24px 64px;
box-sizing: border-box;
background: var(--paper);
}
.head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
padding-bottom: 12px;
}
.body {
padding-left: 12px;
border-left: 1px solid var(--rule-soft);
}
.fires {
font: 400 11.5px var(--sans);
color: var(--ink-2);
}
.kw {
font: 600 11.5px var(--mono);
}
.empty {
font: 400 12px var(--sans);
color: var(--ink-3);
max-width: 60ch;
margin: 16px 0 0;
}
</style>
+7
View File
@@ -63,6 +63,12 @@ export interface StepsHrefOptions {
depth?: number; depth?: number;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */ /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through?: boolean; 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); else if (opts.symbol) params.set('symbol', opts.symbol);
if (opts.depth) params.set('depth', String(opts.depth)); if (opts.depth) params.set('depth', String(opts.depth));
if (opts.through) params.set('through', '1'); if (opts.through) params.set('through', '1');
if (opts.view) params.set('view', opts.view);
return `#/steps${query(params)}`; return `#/steps${query(params)}`;
}, },
+183
View File
@@ -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<WireItem, { kind: 'fork' }>['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<string, WireStep>, project: ProjectKind): RailItem[] {
return items.map((item) => one(item, steps, project));
}
function one(item: WireItem, steps: Map<string, WireStep>, 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<WireItem, { kind: 'block' }>): 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';
}
}
+5 -1
View File
@@ -12,7 +12,7 @@
* #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>) * #/flow flow strip (?from=&to= | ?symbols= | ?t=<trail>)
* #/entry entry points (where a flow starts) * #/entry entry points (where a flow starts)
* #/screens screens (the app's screens and transitions) * #/screens screens (the app's screens and transitions)
* #/steps steps (?anchor=<id> | ?symbol=<name>: what happens from there) * #/steps steps (?anchor=<id> | ?symbol=<name>: what happens from there; ?view=order|tree)
* #/dead dead code (?exported=1 widens the claim) * #/dead dead code (?exported=1 widens the claim)
* *
* Node ids are opaque engine strings shaped `<kind>:<hash>` or * Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -87,6 +87,8 @@ export type Route =
symbol: string | null; symbol: string | null;
depth: number | null; depth: number | null;
through: boolean; through: boolean;
/** Which reading the URL asked for; null takes the answer's own default. */
reading: 'order' | 'tree' | null;
} }
| { | {
view: 'dead'; 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" // The anchor travels in the URL, so "what happens on the review screen"
// is a link that reopens as the same picture. // is a link that reopens as the same picture.
const depth = Number.parseInt(params.get('depth') ?? '', 10); const depth = Number.parseInt(params.get('depth') ?? '', 10);
const reading = params.get('view');
route = { route = {
view: 'steps', view: 'steps',
anchor: params.get('anchor'), anchor: params.get('anchor'),
symbol: params.get('symbol'), symbol: params.get('symbol'),
depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null, depth: Number.isFinite(depth) && depth >= 1 && depth <= 14 ? depth : null,
through: params.get('through') === '1', through: params.get('through') === '1',
reading: reading === 'order' || reading === 'tree' ? reading : null,
}; };
} else if (head === 'dead' && rest.length === 0) { } else if (head === 'dead' && rest.length === 0) {
// The widening travels in the URL like the map's shape does: a link to // The widening travels in the URL like the map's shape does: a link to
+3 -1
View File
@@ -814,6 +814,8 @@ export type WireArmEnd = 'reply' | 'return' | 'throw' | 'exit';
export interface WireArm { export interface WireArm {
/** This arm's own condition, in the words the rest of the view uses. */ /** This arm's own condition, in the words the rest of the view uses. */
when: string; 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. */ /** How it leaves: it answers the request, returns, or throws. Null = it runs on. */
ends: WireArmEnd | null; ends: WireArmEnd | null;
body: WireBlock; body: WireBlock;
@@ -836,7 +838,7 @@ export type WireItem =
* after this function returns (`later`), or calls started together * after this function returns (`later`), or calls started together
* (`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. */ /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
| { kind: 'cut'; why: 'folded' | 'depth' }; | { kind: 'cut'; why: 'folded' | 'depth' };
+124 -175
View File
@@ -17,6 +17,8 @@
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte'; import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css'; import '@xyflow/svelte/dist/style.css';
import StepNode from '../components/steps/StepNode.svelte'; 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 ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte'; import KindGlyph from '../components/KindGlyph.svelte';
import { import {
@@ -44,6 +46,7 @@
triggerWords, triggerWords,
type StepsModel, type StepsModel,
} from '../lib/steps-model'; } from '../lib/steps-model';
import { buildRailModel } from '../lib/program-model';
interface Props { interface Props {
anchor: string | null; anchor: string | null;
@@ -51,8 +54,14 @@
depth: number | null; depth: number | null;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */ /** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through: boolean; 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<WireStepsPayload | null>(null); let payload = $state<WireStepsPayload | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -185,6 +194,36 @@
const model = $derived<StepsModel | null>(payload === null ? null : buildStepsModel(payload)); const model = $derived<StepsModel | null>(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<string>([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(() => { const neighbours = $derived.by(() => {
if (model === null || selected === null) return null; if (model === null || selected === null) return null;
const set = new Set<string>([selected]); const set = new Set<string>([selected]);
@@ -284,14 +323,16 @@
const visibleIds = $derived(new Set(edges.map((e) => e.id))); 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. */ /** 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 { function rewrite(changes: { depth?: number; through?: boolean; view?: 'order' | 'tree' }): string {
const opts = { return stepsHref({
anchor: anchor ?? undefined, anchor: anchor ?? undefined,
symbol: anchor === null ? (symbol ?? undefined) : undefined, symbol: anchor === null ? (symbol ?? undefined) : undefined,
depth: changes.depth ?? depth ?? undefined, depth: changes.depth ?? depth ?? undefined,
through: changes.through ?? through, through: changes.through ?? through,
}; // The reading travels in the URL once it has been chosen, so a link to
return stepsHref(opts); // "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 { function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
@@ -308,7 +349,7 @@
} }
function onStageMove(event: MouseEvent): void { 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; const target = event.target as Element | null;
if (target?.closest('.spill')) return; if (target?.closest('.spill')) return;
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) { if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
@@ -450,6 +491,37 @@
</div> </div>
{:else if loading && payload === null} {:else if loading && payload === null}
<div class="state"><p class="dim">Walking from the anchor…</p></div> <div class="state"><p class="dim">Walking from the anchor…</p></div>
{:else if model !== null && payload !== null && readAs === 'order'}
{#if railReadable}
<StepsRail
anchor={model.nodes.get(payload.anchor.id) ?? [...model.nodes.values()][0]!}
items={rail}
project={payload.project}
{selected}
lit={litOnRail}
truncated={payload.program?.truncated ?? 0}
onSelect={selectOnRail}
onStart={(id) => startHere(id)}
{canStart}
>
<StepsKey
project={payload.project}
order={true}
flow={true}
open={legendOpen}
onToggle={(next) => (legendOpen = next)}
/>
</StepsRail>
{:else}
<div class="state">
<h2>This has no body to read in order</h2>
<p>
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.
</p>
<p><a class="pick" href={rewrite({ view: 'tree' })}>What it sets in motion →</a></p>
</div>
{/if}
{:else if model !== null && payload !== null} {:else if model !== null && payload !== null}
<SvelteFlow <SvelteFlow
{nodes} {nodes}
@@ -475,91 +547,6 @@
<Controls position="bottom-right" showLock={false} /> <Controls position="bottom-right" showLock={false} />
</SvelteFlow> </SvelteFlow>
<div class="legend" class:open={legendOpen}>
<button class="legend-h" onclick={() => (legendOpen = !legendOpen)} aria-expanded={legendOpen}>
Key <span class="dim">{legendOpen ? '▾' : '▸'}</span>
</button>
{#if legendOpen}
<div class="legend-body">
<div class="lrow">
<span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
<span>Where the picture starts; each row down is one more step away</span>
</div>
{#if payload.project === 'api'}
<div class="lrow">
<span class="k-box mono">POST /x</span>
<span>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</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>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 (⇠)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A data call — a function in a store or state file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">db</span>
<span>A call that leaves the index: the database, the response, a queue, email, payments, a cache, auth, the network</span>
</div>
{:else if payload.project === 'web'}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A page, an endpoint, or a handler — a function an event, a request or a page load fires; its line says which</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses to the server (⇢ a request, a server action) or comes back from it (⇠ a push, a stream)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, the database, the response, storage, a queue, email</span>
</div>
{:else}
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
<span>The code crosses into native (⇢ a bridge call) or comes back from it (⇠ an event)</span>
</div>
<div class="lrow">
<span class="k-box k-store mono">set</span>
<span>A store action — a function in a store file</span>
</div>
<div class="lrow">
<span class="k-box k-effect mono">api</span>
<span>A call that leaves the index: the network, storage, the device, telemetry</span>
</div>
{/if}
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>Leads to — the plumbing between the two is folded into the line</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-synth" /></svg>
<span>Established by a synthesized hop (an event channel, a callback, a helper's return value)</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
<span>Goes back up the picture — leaves the top of its box, arrives at the bottom of the other</span>
</div>
<div class="lrow">
<span class="k-label mono">→ …x</span>
<span>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</span>
</div>
<div class="lrow">
<span class="k-label mono">name …</span>
<span>Not entered: another screen (a chapter of its own), or a cap the walk hit — start there to see on</span>
</div>
</div>
{/if}
</div>
{#if hovered !== null && hoveredInfo !== null} {#if hovered !== null && hoveredInfo !== null}
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}> <div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
@@ -579,6 +566,10 @@
</div> </div>
{/if} {/if}
{/if} {/if}
{#if payload !== null && model !== null && readAs === 'tree'}
<StepsKey project={payload.project} order={false} flow={false} open={legendOpen} onToggle={(next) => (legendOpen = next)} />
{/if}
</div> </div>
{#if payload !== null && model !== null} {#if payload !== null && model !== null}
@@ -755,6 +746,11 @@
{/each} {/each}
</p> </p>
{/if} {/if}
<p class="reading">
Read as:
<a class="tab" class:on={readAs === 'order'} href={rewrite({ view: 'order' })}>in order</a>
<a class="tab" class:on={readAs === 'tree'} href={rewrite({ view: 'tree' })}>what it sets in motion</a>
</p>
<p> <p>
<b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth <b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
<select <select
@@ -783,12 +779,22 @@
{/if} {/if}
{/each} {/each}
</p> </p>
<p class="dim"> {#if readAs === 'order'}
<span class="mark">●</span> The anchor is at the top; each row down is one more step away from <p class="dim">
it. Click a step and each of its links is labelled at the far end of its line with the last <span class="mark">●</span> The anchor is at the top, then its body in the code's own order: the
condition checked before it happens; hover the line, or its row here, for the whole chain and the calls as they are written, a fork where the code forks with its arms side by side, a helper drawn
plumbing it travels through. A step is the next anchor, and any link opens as a Flow strip. where it is called, and an arm that answers, returns or throws ending there. A call written inside
</p> another call's arguments comes first — the token is signed before the reply that carries it. Click
a step for its sites and conditions; a step is the next anchor.
</p>
{:else}
<p class="dim">
<span class="mark">●</span> The anchor is at the top; each row down is one more step away from
it. Click a step and each of its links is labelled at the far end of its line with the last
condition checked before it happens; hover the line, or its row here, for the whole chain and the
plumbing it travels through. A step is the next anchor, and any link opens as a Flow strip.
</p>
{/if}
{#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0} {#if payload.truncated.steps > 0 || payload.truncated.hubs > 0 || payload.truncated.chrome > 0}
<p class="dim"> <p class="dim">
Not drawn: Not drawn:
@@ -888,84 +894,6 @@
.pick:hover { .pick:hover {
background: var(--press); background: var(--press);
} }
.legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
max-width: 400px;
border: 1px solid var(--rule);
background: var(--paper);
font-size: 11.5px;
color: var(--ink-2);
}
.legend-h {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 5px 10px;
text-align: left;
color: var(--ink);
font: 600 12px var(--sans);
cursor: pointer;
}
.legend-body {
padding: 2px 10px 8px;
border-top: 1px solid var(--rule-soft);
}
.lrow {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
.lrow > :first-child {
flex: 0 0 44px;
display: inline-flex;
justify-content: center;
}
.k-line {
stroke: var(--ink);
stroke-opacity: 0.6;
stroke-width: 1.5;
fill: none;
}
.k-line.k-synth {
stroke-dasharray: 5 3;
}
.k-line.k-back {
stroke: var(--accent);
stroke-opacity: 0.8;
stroke-dasharray: 4 3;
}
.k-label {
font-size: 10.5px;
color: var(--ink-3);
}
.k-box {
box-sizing: border-box;
padding: 1px 5px;
border: 1px solid var(--ink);
font-size: 10.5px;
color: var(--ink);
line-height: 14px;
}
.k-box.k-cross {
border-left: 3px solid var(--accent);
}
.k-box.k-store {
background: var(--paper-2);
}
.k-box.k-effect {
border-style: dashed;
border-color: var(--ink-3);
}
.k-anchor .mark {
font-size: 8px;
margin-right: 3px;
vertical-align: 1px;
}
.tip { .tip {
position: absolute; position: absolute;
z-index: 5; z-index: 5;
@@ -1048,6 +976,27 @@
margin: 0; margin: 0;
accent-color: var(--accent); accent-color: var(--accent);
} }
.reading {
display: flex;
align-items: baseline;
gap: 8px;
color: var(--ink-3);
}
.tab {
color: var(--ink-2);
text-decoration: none;
border-bottom: 1px solid var(--rule-soft);
padding-bottom: 1px;
}
.tab:hover {
color: var(--ink);
border-bottom-color: var(--ink-3);
}
.tab.on {
color: var(--ink);
font-weight: 600;
border-bottom-color: var(--accent);
}
.depth { .depth {
font: inherit; font: inherit;
font-size: 12px; font-size: 12px;