feat(steps): the order reading is the canvas, not a rail

The first cut drew the code's order as a nested document — a column of boxes,
forks as rows of arm columns. Wrong picture: hard to read, and it threw away
the thing that made the tree legible. The ask was the canvas back, with the
timing fixed: the 200 comes after the token is signed, so it should branch out
of it.

So the order reading is now the SAME canvas, the same boxes, the same pills,
hover and panel — only the graph changes. `ui/src/lib/program-model.ts` walks
the server's block tree carrying a set of tails (the steps a next step would
follow) and emits one edge per "and then": proshop's login draws the anchor,
`User.findOne`, then the fork — `jwt.sign` under one arm with the `200` a row
below it, the `401` under the other. A row down is one more thing that has
already happened; an arm that answers, returns or throws has nothing leaving
it; a helper, a loop, `later` and `together` ride on the line into what they
hold. Rows are settled by relaxation, because a step reached twice can make
the graph cyclic.

A line means "and then" here and "leads to" in the tree, so the key says which.
The fork conditions are drawn at rest rather than only for a selected box —
`placeLabels` takes an `atRest` flag — because on this picture they are the
content, and two ways to one step merge as one condition (`WHEN userExists OR
NOT user`), not as two rendered labels stuck together.

`StepsRail.svelte` and `RailBlock.svelte` are gone; `StepBox.svelte` stays as
the box both readings draw.

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 14:16:01 -05:00
co-authored by Claude Opus 5
parent 75686502e3
commit 209a07e881
11 changed files with 529 additions and 674 deletions
-216
View File
@@ -1,216 +0,0 @@
<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'}
{@const guard = item.arms.length <= 2 && item.arms[0]?.body.length === 0 && item.arms[0]?.ends !== null}
{#if guard}
<!--
An early exit is a fork with nothing on one side: `if (!user) return`.
A reader takes it as a guard, not as a branch — one line saying where
the code leaves, and everything below it running when it did not — so
it is drawn as one, and the rail does not step right for it.
-->
<div class="guard">
<span class="cond mono">{@render words(item.words)}</span>
<span class="ends inline">{item.arms[0]!.ends}</span>
</div>
{#if item.arms[1] && item.arms[1].body.length > 0}
<Self items={item.arms[1].body} {project} {selected} {lit} {onSelect} {onStart} {canStart} />
{/if}
{#if item.arms[1]?.ends}<div class="ends">{item.arms[1].ends}</div>{/if}
{:else}
<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>
{/if}
{: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;
}
/* An early exit: the condition and where it leaves, on one line. */
.guard {
display: flex;
align-items: baseline;
gap: 8px;
max-width: 100%;
min-width: 0;
}
.ends.inline {
border-top: 0;
padding-top: 0;
align-self: auto;
white-space: nowrap;
}
.kw {
font-weight: 600;
}
</style>
+9 -5
View File
@@ -87,16 +87,20 @@
{/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>
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>And then — the step at the other end happens after this one; the plumbing between them is folded into the line</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>
<span class="k-label mono">WHEN x</span>
<span>Where the code forks — an <span class="mono">if</span>, a <span class="mono">switch</span>, a <span class="mono">try</span>, an early exit: what has to hold for the step at the other end. No label = it happens either way</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>
<span>Written inside 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">
<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">
<span class="k-label mono">name …</span>
-94
View File
@@ -1,94 +0,0 @@
<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>
+272 -159
View File
@@ -1,175 +1,58 @@
/**
* The Steps view's second reading, as the rail draws it.
* The Steps picture in the code's ORDER — the same canvas, laid out by when
* things happen rather than by how far they are from the anchor.
*
* 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 tree's rows are distance: on proshop's login `User.findOne`, `jwt.sign`,
* `200` and `401` are each one step out of the handler, so they land in one
* row. True, and not the flow — the token is signed while the reply is being
* built, so the `200` comes AFTER it, and the `401` is the other side of the
* same `if`. This model draws that:
*
* 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.
* ```
* POST /api/users/login
* |
* User.findOne
* +-------------+--------------+
* WHEN user AND (await ...) WHEN NOT (...)
* | |
* jwt.sign 401
* |
* 200
* ```
*
* **A line here means "then", not "calls"** — that is the whole difference from
* the other reading, and the key says so. It comes from the block tree the
* server folds out of the walk (`api/program.ts`): items in source order, forks
* where the code forks, an arm that answers or leaves ending there. Walking that
* tree with a set of *tails* — the steps a next step would follow — gives one
* edge per "and then", carrying the arm's condition where the code branched.
*
* Everything else is the canvas's: the same boxes, the same layout engine, the
* same pills, hover and panel. Only the graph changes.
*/
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';
import { conditionTokens, joinTokens, type WordToken } from './conditions';
import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
import { samplePolyline, trackedCurves, EDGE_LABEL_MAX, SCREEN_LAYER_GAP, type Point } from './screens-model';
import { stepLabel, stepSub, type StepEdgeInfo, type StepNodeInfo, type StepsModel } from './steps-model';
import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire';
/* ----------------------------------------------------------------- words -- */
/** 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).
* What has to hold for an arm to be the one taken, in the words the rest of the
* view uses. Said in FULL on the line, because a line on a canvas has no head
* above it to refer back to: `WHEN user AND (await …)`, `WHEN NOT (…)`.
*/
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);
export function whenTokens(when: string): WordToken[] {
return conditionTokens(when);
}
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 {
/** The words on a run that is not plain sequence — said on the line into it. */
export function runWords(item: Extract<WireItem, { kind: 'block' }>): string {
switch (item.block) {
case 'inline':
return item.via ? `via ${item.via.name}` : 'via a helper';
@@ -182,3 +65,233 @@ export function groupLabel(item: Extract<WireItem, { kind: 'block' }>): string {
return item.by ? `together · ${item.by}` : 'together';
}
}
/* ----------------------------------------------------------------- graph -- */
/** One "and then": the step it follows, the step that happens, and what had to hold. */
export interface OrderEdge {
from: string;
to: string;
/** The conditions on the way — the arms of the forks crossed, joined by ` && `. */
when: string;
/** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */
runs: string[];
}
/** Where a next step would follow from, and under what. */
interface Tail {
id: string;
when: string[];
runs: string[];
}
export interface OrderGraph {
edges: OrderEdge[];
/** How many things happen before each step: its row. */
depth: Map<string, number>;
}
/**
* The block tree as a graph of what happens next. `anchor` is where the reading
* starts, so the first thing in the body follows it.
*/
export function orderGraph(program: NonNullable<WireStepsPayload['program']>, anchor: string): OrderGraph {
const edges: OrderEdge[] = [];
const seen = new Set<string>([anchor]);
const at = new Map<string, OrderEdge>();
const join = (from: string, to: string, tail: Tail): void => {
if (from === to) return;
const key = `${from} ${to}`;
const when = tail.when.filter((w, i) => w && tail.when.indexOf(w) === i).join(' && ');
const found = at.get(key);
if (found) {
// Two ways to the same step: the picture keeps both conditions, the way
// a link with several sites does.
if (when !== found.when) found.when = !when || !found.when ? '' : `${found.when} || ${when}`;
for (const r of tail.runs) if (!found.runs.includes(r)) found.runs.push(r);
return;
}
const edge: OrderEdge = { from, to, when, runs: [...tail.runs] };
at.set(key, edge);
edges.push(edge);
};
const flow = (block: WireBlock, incoming: readonly Tail[], runs: readonly string[]): Tail[] => {
let tails: Tail[] = [...incoming];
for (const item of block) {
if (item.kind === 'step') {
seen.add(item.step);
for (const t of tails) join(t.id, item.step, { ...t, runs: [...t.runs, ...runs] });
// What the step itself sets in motion happens inside it, so the next
// thing in the block follows THAT, not the box.
let inner: Tail[] = [{ id: item.step, when: [], runs: [] }];
if (item.body && item.body.length > 0) inner = flow(item.body, inner, []);
tails = inner;
} else if (item.kind === 'fork') {
const out: Tail[] = [];
for (const arm of item.arms) {
// The arm's condition as the SOURCE has it: the words are made once,
// at the end, or two ways of arriving would each carry their own WHEN.
const entry = tails.map((t) => ({ id: t.id, when: [...t.when, arm.when], runs: [...t.runs, ...runs] }));
// An arm that answers, returns or throws does not rejoin — nothing
// leaves the last box in it, which is what says so on a canvas.
const armTails = flow(arm.body, entry, []);
if (arm.ends === null) out.push(...armTails);
}
// An `if` with no `else` runs on either way; a fork with both sides
// covered runs on only through the arms that did not end.
if (item.arms.length < 2) out.push(...tails.map((t) => ({ ...t, runs: [...t.runs, ...runs] })));
tails = out;
} else if (item.kind === 'block') {
const label = runWords(item);
const inner = flow(item.body, tails, [...runs, label]);
// A helper that answers on one path still returns on another: the code
// after the call follows the call, not nothing. And what comes after
// the call is not inside it — a tail that fell through the block drops
// the block's own words on the way out.
tails = (inner.length > 0 ? inner : tails).map((t) => (t.runs.includes(label) ? { ...t, runs: t.runs.filter((r) => r !== label) } : t));
}
}
return tails;
};
flow(program.root, [{ id: anchor, when: [], runs: [] }], []);
// Nothing the reading holds may float: a step the walk drew but the fold
// could not place follows the anchor, unconditionally.
for (const id of seen) {
if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] });
}
return { edges, depth: rows(anchor, seen, edges) };
}
/**
* The row each step sits on: the longest run of "and then" from the anchor to
* it, so a step never draws above something that has to happen first. Settled
* by relaxation rather than a topological sort, because a step reached twice
* (`session.add` before and after a check) can make the graph cyclic.
*/
function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): Map<string, number> {
const depth = new Map<string, number>();
for (const id of nodes) depth.set(id, 0);
depth.set(anchor, 0);
for (let pass = 0; pass < nodes.size; pass++) {
let moved = false;
for (const e of edges) {
const next = (depth.get(e.from) ?? 0) + 1;
if (next > (depth.get(e.to) ?? 0)) {
depth.set(e.to, next);
moved = true;
}
}
if (!moved) break;
}
return depth;
}
/* ----------------------------------------------------------------- build -- */
/** Points a curve is sampled at for hit-testing (as the other reading's). */
const HIT_SAMPLES = 24;
/**
* The same picture as `buildStepsModel`, laid out in the code's order. Null
* when the anchor has no body to read — the view then offers the tree.
*/
export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
if (!payload.program) return null;
const anchorStep = payload.steps.find((s) => s.anchor);
if (!anchorStep) return null;
const graph = orderGraph(payload.program, anchorStep.id);
const nodes = new Map<string, StepNodeInfo>();
const modules: WireMapModule[] = [];
const counts: StepsModel['counts'] = { anchor: 0, screen: 0, trigger: 0, bridge: 0, event: 0, store: 0, effect: 0 };
const degree = new Map<string, number>();
for (const e of graph.edges) {
degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
}
const byId = new Map(payload.steps.map((s) => [s.id, s]));
for (const id of [anchorStep.id, ...graph.depth.keys()]) {
if (nodes.has(id)) continue;
const step: WireStep | undefined = byId.get(id);
if (!step) continue;
counts[step.kind]++;
const info: StepNodeInfo = { id, step, label: stepLabel(step), sub: stepSub(step, payload.project) };
nodes.set(id, info);
modules.push({
id,
label: info.label,
files: 1,
symbols: degree.get(id) ?? 0,
languages: [],
test: false,
generated: 0,
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
});
}
const links: WireMapLink[] = [];
const edges = new Map<string, StepEdgeInfo>();
for (const e of graph.edges) {
if (!nodes.has(e.from) || !nodes.has(e.to)) continue;
const key = linkId({ source: e.from, target: e.to });
if (edges.has(key)) continue;
links.push({ source: e.from, target: e.to, count: 1, declared: 1, byKind: [{ kind: 'calls', count: 1 }], topPairs: [] });
// The panel and the tooltip still read the walk's own links — the sites,
// the `via` chain, what fires it — for the step the line arrives at.
const behind = payload.links.filter((l) => l.to === e.to);
edges.set(key, {
id: key,
from: e.from,
to: e.to,
links: behind,
label: lineWords(e),
synthesized: behind.length > 0 && behind.every((l) => l.synthesized),
kind: behind[0]?.kind ?? 'calls',
});
}
// Row 0 is the bottom, so the anchor — nothing happens before it — is on top.
const deepest = Math.max(0, ...graph.depth.values());
const layering = (ids: string[]): Map<string, number> =>
new Map(ids.map((id) => [id, deepest - (graph.depth.get(id) ?? deepest)]));
const layout: MapLayout = buildMapLayout(
{ modules, links },
{
includeTests: true,
minWeight: 0,
sizing: (m) => {
const info = nodes.get(m.id);
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
},
layering,
order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
layerGap: SCREEN_LAYER_GAP,
portPitch: PORT_PITCH,
ports: 'directional',
}
);
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
const polylines = new Map<string, Point[]>();
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
}
/**
* What a line says: the whole condition the step at its end runs under — this
* picture's lines ARE its conditions, so they are not shortened to the last
* clause the way the other reading's are — else the run it happens inside
* (`via generateToken`), and nothing at all when the code simply goes on.
*/
export function lineWords(e: OrderEdge): string {
if (!e.when) return e.runs.length > 0 ? e.runs[e.runs.length - 1]! : '';
const text = joinTokens(whenTokens(e.when));
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}` : text;
}
+8 -4
View File
@@ -757,18 +757,22 @@ function layPill(
* pill: the pill for a hovered edge that is not the selected screen's is
* placed separately by {@link hoverPill}.
*/
export function placeLabels(model: Picture, selected: string | null): PillLayout {
export function placeLabels(model: Picture, selected: string | null, atRest = false): PillLayout {
const pills = new Map<string, PillPlacement>();
if (selected === null) return { pills, hidden: 0 };
if (selected === null && !atRest) return { pills, hidden: 0 };
const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
const lanes = laneCount(model.layerGap);
const bounds = { width: model.layout.width, height: model.layout.height };
const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
// At rest, only the selected screen's lines are labelled — a picture with a
// label on every line is unreadable, and the reader has asked about one box.
// A picture whose labels ARE its content says so (`atRest`): the Steps view
// in the code's order, where the conditions on the lines are the flow.
const candidates = model.layout.edges
.filter((e) => e.source === selected || e.target === selected)
.filter((e) => atRest || e.source === selected || e.target === selected)
.map((edge) => {
const end: 'source' | 'target' = edge.source === selected ? 'target' : 'source';
const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target';
const far = nodes.get(end === 'source' ? edge.source : edge.target);
const anchor = far ? portPoint(far, edge.id, end) : { x: 0, y: 0 };
return { edge, end, anchor };
+39 -67
View File
@@ -17,7 +17,6 @@
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';
@@ -46,7 +45,7 @@
triggerWords,
type StepsModel,
} from '../lib/steps-model';
import { buildRailModel } from '../lib/program-model';
import { buildOrderModel } from '../lib/program-model';
interface Props {
anchor: string | null;
@@ -192,37 +191,24 @@
return () => controller.abort();
});
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;
}
/**
* The picture. Both readings are the same canvas over the same boxes; what
* differs is the graph — in the code's order a line means "and then" and the
* rows are how much has already happened, in the tree it means "leads to" and
* the rows are distance from the anchor.
*/
const model = $derived<StepsModel | null>(
payload === null ? null : (readAs === 'order' ? buildOrderModel(payload) : null) ?? buildStepsModel(payload)
);
/** The order can be asked for and have nothing to read: the view then says so. */
const orderReadable = $derived(payload?.program != null);
const neighbours = $derived.by(() => {
if (model === null || selected === null) return null;
@@ -234,7 +220,9 @@
return set;
});
const pills = $derived(model === null ? null : placeLabels(model, selected));
// In the code's order the conditions ON the lines are the picture: they are
// drawn at rest, not only for the step the reader selected.
const pills = $derived(model === null ? null : placeLabels(model, selected, readAs === 'order'));
const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
const focusPill = $derived.by(() => {
if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
@@ -349,7 +337,7 @@
}
function onStageMove(event: MouseEvent): void {
if (model === null || stage === null || readAs === 'order') return;
if (model === null || stage === null) return;
const target = event.target as Element | null;
if (target?.closest('.spill')) return;
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
@@ -491,37 +479,15 @@
</div>
{:else if loading && payload === null}
<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 && readAs === 'order' && !orderReadable}
<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>
{:else if model !== null && payload !== null}
<SvelteFlow
{nodes}
@@ -567,8 +533,14 @@
{/if}
{/if}
{#if payload !== null && model !== null && readAs === 'tree'}
<StepsKey project={payload.project} order={false} flow={false} open={legendOpen} onToggle={(next) => (legendOpen = next)} />
{#if payload !== null && model !== null && (readAs === 'tree' || orderReadable)}
<StepsKey
project={payload.project}
order={readAs === 'order'}
flow={false}
open={legendOpen}
onToggle={(next) => (legendOpen = next)}
/>
{/if}
</div>
@@ -781,11 +753,11 @@
</p>
{#if readAs === 'order'}
<p class="dim">
<span class="mark"></span> The anchor is at the top, then its body in the code's own order: the
calls as they are written, a fork where the code forks with its arms side by side, a helper drawn
where it is called, and an arm that answers, returns or throws ending there. A call written inside
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.
<span class="mark"></span> The anchor is at the top, and each row down is what happens next: a line
means <b>and then</b>, and where the code forks the line says what has to hold. A call written inside
another call's arguments happens first — the token is signed before the reply that carries it — and an
arm that answers, returns or throws simply has nothing leaving it. Click a step for its sites and the
whole condition; a step is the next anchor.
</p>
{:else}
<p class="dim">