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:
co-authored by
Claude Opus 5
parent
b02e192ffa
commit
9acab0020f
+124
-175
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user