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
+124 -175
View File
@@ -17,6 +17,8 @@
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import StepNode from '../components/steps/StepNode.svelte';
import StepsRail from '../components/steps/StepsRail.svelte';
import StepsKey from '../components/steps/StepsKey.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
import {
@@ -44,6 +46,7 @@
triggerWords,
type StepsModel,
} from '../lib/steps-model';
import { buildRailModel } from '../lib/program-model';
interface Props {
anchor: string | null;
@@ -51,8 +54,14 @@
depth: number | null;
/** Enter the screens the walk reaches, instead of drawing them as boundaries. */
through: boolean;
/**
* Which reading the URL asked for — the code's `order` or the `tree` of
* what the anchor sets in motion. Null takes the answer's own default: the
* order for a handler or an endpoint, the tree for a screen.
*/
reading: 'order' | 'tree' | null;
}
let { anchor, symbol, depth, through }: Props = $props();
let { anchor, symbol, depth, through, reading }: Props = $props();
let payload = $state<WireStepsPayload | null>(null);
let error = $state<string | null>(null);
@@ -185,6 +194,36 @@
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(() => {
if (model === null || selected === null) return null;
const set = new Set<string>([selected]);
@@ -284,14 +323,16 @@
const visibleIds = $derived(new Set(edges.map((e) => e.id)));
/** The same picture with one setting changed: the anchor as the URL asked for it, the rest kept. */
function rewrite(changes: { depth?: number; through?: boolean }): string {
const opts = {
function rewrite(changes: { depth?: number; through?: boolean; view?: 'order' | 'tree' }): string {
return stepsHref({
anchor: anchor ?? undefined,
symbol: anchor === null ? (symbol ?? undefined) : undefined,
depth: changes.depth ?? depth ?? undefined,
through: changes.through ?? through,
};
return stepsHref(opts);
// The reading travels in the URL once it has been chosen, so a link to
// "the login endpoint in the code's order" reopens as that.
view: changes.view ?? reading ?? undefined,
});
}
function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
@@ -308,7 +349,7 @@
}
function onStageMove(event: MouseEvent): void {
if (model === null || stage === null) return;
if (model === null || stage === null || readAs === 'order') return;
const target = event.target as Element | null;
if (target?.closest('.spill')) return;
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
@@ -450,6 +491,37 @@
</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}
<SvelteFlow
{nodes}
@@ -475,91 +547,6 @@
<Controls position="bottom-right" showLock={false} />
</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}
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
@@ -579,6 +566,10 @@
</div>
{/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>
{#if payload !== null && model !== null}
@@ -755,6 +746,11 @@
{/each}
</p>
{/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>
<b>{payload.steps.length}</b> steps · <b>{payload.links.length}</b> links · depth
<select
@@ -783,12 +779,22 @@
{/if}
{/each}
</p>
<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 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.
</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}
<p class="dim">
Not drawn:
@@ -888,84 +894,6 @@
.pick:hover {
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 {
position: absolute;
z-index: 5;
@@ -1048,6 +976,27 @@
margin: 0;
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 {
font: inherit;
font-size: 12px;