feat(steps): render fork decisions as points with per-arm edges and captions

Adds full support for decisions at forks in both the code graph and the UI. Key changes introduce a decision model for forks (innermost guard decisions), propagate decision data through the server and wire layer, and render decisions in the UI as distinct points with labeled arms. New components (ForkPoint and DecisionCaption) visualize the decision and its arms, while utilities (armWords, forkLabel) generate arm captions. The order reading (canvas) now shows decisions as points, and arms are drawn as separate edges (yes/no/case), with labels and captions displayed under the deciding box. Tests, typings, and docs updated to reflect the new decision visualization and behavior, including selection reach and resting-label semantics. This lays the groundwork for clearer visualization of conditional navigation and guarded branches on the order canvas.
This commit is contained in:
Colby McHenry
2026-08-31 17:10:13 -05:00
parent 882ea143e8
commit 3298db1292
14 changed files with 1006 additions and 104 deletions
@@ -0,0 +1,47 @@
<script lang="ts">
/**
* A decision made INSIDE a box, said under it — the tree reading's answer to
* a fork.
*
* In the code's order a fork sits BETWEEN steps and draws as a point of its
* own ({@link ForkPoint}). In the tree the decision is written inside a box
* and its arms leave that box, so the box is the decision: the condition
* goes here, once, under it, and each line out says only which way it is.
* Text only, taking no pointer, so hovering a line through it still works.
*/
import type { NodeProps } from '@xyflow/svelte';
import { joinTokens, whenTokens } from '../../lib/conditions';
let { data }: NodeProps = $props();
const caption = $derived(data as unknown as { label: string; width: number; dimmed: boolean });
// The label arrives already worded and asking; the tokens are re-read here
// only so the joins we add (NOT, AND, OR) set a little bolder, as they do
// everywhere else conditions are shown.
const tokens = $derived(whenTokens(caption.label.replace(/\?$/, '')));
const plain = $derived(tokens.length === 0 || joinTokens(tokens) !== caption.label.replace(/\?$/, ''));
</script>
<div class="dcap" class:dimmed={caption.dimmed} style={`width:${caption.width}px`} title={caption.label}>
{#if plain}{caption.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b
>{:else}{t.text}{/if}{/each}?{/if}
</div>
<style>
.dcap {
box-sizing: border-box;
font: 400 10.5px/14px var(--mono);
color: var(--ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
pointer-events: none;
user-select: none;
}
.dcap.dimmed {
color: var(--ink-4);
}
.kw {
font-weight: 600;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
/**
* A decision on the order reading's canvas — the point where a fork's arms
* diverge. The condition is said ONCE, here, and each line out answers it
* (`yes`, `no`, a case's value): two lines that each carried the whole
* predicate, one of them negated, never said they were the same choice.
* It is not a step — it takes no click and the panel has nothing to list —
* so it draws quieter than a box: one centred line, asking.
*/
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import type { MapNodeLayout } from '../../lib/map-model';
import type { StepForkInfo } from '../../lib/steps-model';
import { joinTokens, whenTokens } from '../../lib/conditions';
import { EDGE_LABEL_MAX } from '../../lib/screens-model';
let { data }: NodeProps = $props();
const node = $derived(data as unknown as { layout: MapNodeLayout; fork: StepForkInfo; dimmed: boolean });
const layout = $derived(node.layout);
const tokens = $derived(whenTokens(node.fork.on));
// A condition past the box's cap is drawn as the capped plain label the box
// was sized for, or the ellipsis eats the question mark.
const plain = $derived(tokens.length === 0 || joinTokens(tokens).length > EDGE_LABEL_MAX);
function portStyle(index: number, total: number): string {
return `left:${((index + 1) / (total + 1)) * 100}%`;
}
</script>
{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
<Handle
type={port.type}
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
position={Position.Top}
style={portStyle(i, layout.ports.top.length)}
isConnectable={false}
/>
{/each}
<div
class="fpoint"
class:dimmed={node.dimmed}
style={`width:${layout.width}px;height:${layout.height}px`}
title={node.fork.on
? `${node.fork.on} the code forks here; each line out is one arm.`
: 'The code forks here; each line out is one arm.'}
>
<span class="q"
>{#if plain}{node.fork.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw"
>{t.text}</b
>{:else}{t.text}{/if}{/each}?{/if}</span
>
</div>
{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
<Handle
type={port.type}
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
position={Position.Bottom}
style={portStyle(i, layout.ports.bottom.length)}
isConnectable={false}
/>
{/each}
<style>
.fpoint {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0 9px;
border: 1px solid var(--ink-2);
background: var(--paper);
color: var(--ink);
user-select: none;
}
.fpoint.dimmed {
border-color: var(--ink-4);
color: var(--ink-4);
}
.q {
font: 400 12px var(--mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.kw {
font-weight: 600;
}
</style>
+17 -1
View File
@@ -115,9 +115,13 @@
<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-box k-fork mono">x?</span>
<span>A decision both of whose ways are drawn: the box asks the condition once and each line out answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case. An arm that answers or leaves ends there</span>
</div>
<div class="lrow">
<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>
<span>A lone guard — an early exit, an <span class="mono">if</span> with one drawn side: 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>
@@ -144,6 +148,14 @@
<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? · yes</span>
<span
>A decision made inside a box, both of whose ways are drawn: the condition is said once under the box and
each line out of it answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case. These are
the only lines labelled before you select anything</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>
@@ -237,6 +249,10 @@
border-style: dashed;
border-color: var(--ink-3);
}
/* The decision's point draws quieter than a step, on the canvas and here. */
.k-box.k-fork {
border-color: var(--ink-2);
}
.k-anchor .mark {
font-size: 8px;
margin-right: 3px;
+164 -17
View File
@@ -31,10 +31,10 @@
* same pills, hover and panel. Only the graph changes.
*/
import { conditionTokens, joinTokens, type WordToken } from './conditions';
import { conditionTokens, joinTokens, whenWords, 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 { armWords, stepLabel, stepSub, type StepEdgeInfo, type StepForkInfo, type StepNodeInfo, type StepsModel } from './steps-model';
import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire';
/* ----------------------------------------------------------------- words -- */
@@ -76,6 +76,8 @@ export interface OrderEdge {
when: string;
/** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */
runs: string[];
/** A line out of a decision's point: which arm this is — `yes`, `no`, a case's value. */
arm?: string;
}
/** Where a next step would follow from, and under what. */
@@ -83,12 +85,26 @@ interface Tail {
id: string;
when: string[];
runs: string[];
arm?: string;
}
/**
* A decision drawn as a point of its own: a fork two or more of whose arms
* lead somewhere. Its condition is said once, on the point; each line out
* says only which arm it is.
*/
export interface OrderFork {
id: string;
on: string;
form: ForkForm;
}
export interface OrderGraph {
edges: OrderEdge[];
/** How many things happen before each step: its row. */
depth: Map<string, number>;
/** The decisions drawn as points, in the order the reading met them. */
forks: OrderFork[];
}
/**
@@ -99,6 +115,7 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
const edges: OrderEdge[] = [];
const seen = new Set<string>([anchor]);
const at = new Map<string, OrderEdge>();
const forks: OrderFork[] = [];
const join = (from: string, to: string, tail: Tail): void => {
if (from === to) return;
@@ -110,9 +127,12 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
// 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);
// Reached as two different arms of one decision: it happens either way,
// and the line stops claiming a side.
if (found.arm !== tail.arm) delete found.arm;
return;
}
const edge: OrderEdge = { from, to, when, runs: [...tail.runs] };
const edge: OrderEdge = { from, to, when, runs: [...tail.runs], ...(tail.arm !== undefined ? { arm: tail.arm } : {}) };
at.set(key, edge);
edges.push(edge);
};
@@ -129,11 +149,28 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
if (item.body && item.body.length > 0) inner = flow(item.body, inner, []);
tails = inner;
} else if (item.kind === 'fork') {
// A decision two or more of whose arms lead somewhere diverges from a
// POINT: the condition said once, each line out an arm — instead of
// two lines that each carry the whole predicate, one of them negated,
// with nothing saying they are the same choice. A fork with one drawn
// arm keeps the plain line: an early exit reads as a guard clause.
const drawn = item.arms.filter((a) => hasStep(a.body)).length;
const fork: OrderFork | null =
drawn >= 2 && tails.length > 0 ? { id: `fork:${forks.length}`, on: item.on, form: item.form } : null;
if (fork !== null) {
forks.push(fork);
for (const t of tails) join(t.id, fork.id, { ...t, runs: [...t.runs, ...runs] });
}
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] }));
const entry: Tail[] =
fork !== null && hasStep(arm.body)
? // From the point: the line says the arm; the arm's own
// condition rides along for the hover.
[{ id: fork.id, when: [arm.when], runs: [...runs], arm: armWord(item, arm) }]
: 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, []);
@@ -164,22 +201,50 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] });
}
return { edges, depth: rows(anchor, seen, edges) };
const ids = new Set(seen);
for (const f of forks) ids.add(f.id);
return { edges, depth: rows(anchor, ids, edges), forks };
}
/** Whether anything in this block draws a box — a fork with one drawn arm is a guard clause, not a point. */
function hasStep(block: WireBlock): boolean {
return block.some((item) =>
item.kind === 'step'
? true
: item.kind === 'fork'
? item.arms.some((a) => hasStep(a.body))
: item.kind === 'block'
? hasStep(item.body)
: false
);
}
/** The word on a line out of a decision's point — the same one the tree's arms say. */
function armWord(fork: Extract<WireItem, { kind: 'fork' }>, arm: WireArm): string {
return armWords({ on: fork.on, arm: arm.when, form: fork.form, not: arm.not });
}
/**
* 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.
* it, so a step never draws above something that has to happen first.
*
* A step reached twice (`session.add` before and after a check, a logout
* helper the code comes back to) makes the graph cyclic, and relaxing over a
* cycle never settles — it adds a row on every pass until the pass bound, so
* sixteen boxes spread over sixty rows and the picture is a mostly-empty
* ribbon no fit can open on. So the lines that close a cycle are dropped
* first: a line back to something already on the way here cannot be what
* decides its row. The longest path over what remains settles by relaxation,
* and no picture is ever taller than it has boxes.
*/
function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): Map<string, number> {
const forward = withoutBackEdges(anchor, nodes, edges);
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) {
for (const e of forward) {
const next = (depth.get(e.from) ?? 0) + 1;
if (next > (depth.get(e.to) ?? 0)) {
depth.set(e.to, next);
@@ -191,6 +256,46 @@ function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderE
return depth;
}
/**
* The edges minus the ones that close a cycle — those whose end is still open
* on the way in, found by one walk from the anchor (then from anything it
* does not reach), so the reading's own order decides which way round a cycle
* is the forward one.
*/
function withoutBackEdges(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): OrderEdge[] {
const out = new Map<string, OrderEdge[]>();
for (const e of edges) {
const list = out.get(e.from);
if (list) list.push(e);
else out.set(e.from, [e]);
}
/** 1 = open on the way in, 2 = done with. */
const state = new Map<string, 1 | 2>();
const back = new Set<OrderEdge>();
for (const root of [anchor, ...nodes]) {
if (state.has(root)) continue;
state.set(root, 1);
const stack: Array<{ id: string; next: number }> = [{ id: root, next: 0 }];
while (stack.length > 0) {
const top = stack[stack.length - 1]!;
const list = out.get(top.id) ?? [];
if (top.next >= list.length) {
state.set(top.id, 2);
stack.pop();
continue;
}
const edge = list[top.next++]!;
const seen = state.get(edge.to);
if (seen === 1) back.add(edge);
else if (seen === undefined) {
state.set(edge.to, 1);
stack.push({ id: edge.to, next: 0 });
}
}
}
return edges.filter((e) => !back.has(e));
}
/* ----------------------------------------------------------------- build -- */
/** Points a curve is sampled at for hit-testing (as the other reading's). */
@@ -207,6 +312,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
const graph = orderGraph(payload.program, anchorStep.id);
const nodes = new Map<string, StepNodeInfo>();
const forks = new Map<string, StepForkInfo>();
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>();
@@ -235,11 +341,30 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
});
}
// Each decision is a point of its own on the canvas: a small box asking the
// condition once, where the arms diverge.
for (const f of graph.forks) {
const label = forkLabel(f.on);
forks.set(f.id, { id: f.id, on: f.on, form: f.form, label });
modules.push({
id: f.id,
label,
files: 0,
symbols: degree.get(f.id) ?? 0,
languages: [],
test: false,
generated: 0,
generatedFiles: [],
facade: false,
fileList: { total: 0, shown: 0, truncated: false, items: [] },
});
}
const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
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;
if (!drawn(e.from) || !drawn(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: [] });
@@ -257,6 +382,15 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
});
}
// A point sits where its arms are: the leftmost of them, in the code's order.
const forkOrder = new Map<string, number>();
for (const e of graph.edges) {
if (!forks.has(e.from)) continue;
const order = byId.get(e.to)?.order;
if (order === undefined) continue;
forkOrder.set(e.from, Math.min(forkOrder.get(e.from) ?? Number.MAX_SAFE_INTEGER, order));
}
// 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> =>
@@ -268,6 +402,8 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
includeTests: true,
minWeight: 0,
sizing: (m) => {
const fork = forks.get(m.id);
if (fork) return { label: fork.label, meta: '' };
const info = nodes.get(m.id);
// Size for the ` …` a cut step wears and the anchor's ● mark, or the
// CSS ellipsis eats the name's tail.
@@ -276,7 +412,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
},
layering,
order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
order: (id) => nodes.get(id)?.step.order ?? forkOrder.get(id) ?? Number.MAX_SAFE_INTEGER,
layerGap: SCREEN_LAYER_GAP,
portPitch: PORT_PITCH,
ports: 'directional',
@@ -285,18 +421,29 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
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));
// The order reading needs no regions: its rows already say when.
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts, regions: null, regionEntries: null };
// The order reading needs no regions: its rows already say when. Its
// decisions are points between steps, not captions under a box.
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts, regions: null, regionEntries: null, forks, decisions: [] };
}
/**
* 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.
* What a line says: the arm it is, when it leaves a decision's point — the
* point asks, the line answers — else 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.arm) return e.arm;
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;
}
/** The point's words: the condition once, as the view says conditions, asking. */
function forkLabel(on: string): string {
const text = whenWords(on);
if (!text) return '?';
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…?` : `${text}?`;
}
+12 -3
View File
@@ -757,9 +757,18 @@ 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, atRest = false): PillLayout {
export function placeLabels(
model: Picture,
selected: string | null,
atRest: boolean | ReadonlySet<string> = false
): PillLayout {
const pills = new Map<string, PillPlacement>();
if (selected === null && !atRest) return { pills, hidden: 0 };
// `true` labels every line (the code's order, where the conditions ARE the
// picture); a SET labels only those lines (the tree, where the arms of a
// decision are the one thing worth saying before anything is selected).
const restLabels = (id: string): boolean => (atRest === true ? true : atRest !== false && atRest.has(id));
const anyAtRest = atRest === true || (atRest !== false && atRest.size > 0);
if (selected === null && !anyAtRest) 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 };
@@ -770,7 +779,7 @@ export function placeLabels(model: Picture, selected: string | null, atRest = fa
// 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) => atRest || e.source === selected || e.target === selected)
.filter((e) => restLabels(e.id) || e.source === selected || e.target === selected)
.map((edge) => {
const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target';
const far = nodes.get(end === 'source' ? edge.source : edge.target);
+200 -14
View File
@@ -12,7 +12,8 @@
* are the links into and out of the selected step.
*/
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
import { whenWords } from './conditions';
import type { WireMapLink, WireMapModule, WireStep, WireStepDecision, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
import {
buildMapLayout,
linkId,
@@ -59,6 +60,25 @@ export interface StepEdgeInfo {
synthesized: boolean;
/** The kind the links agree on, or `calls` when they differ. */
kind: WireStepLink['kind'];
/**
* The one way of a decision this connector is — `yes`, `no`, a case's
* value — when it and a sibling out of the same box are arms of one fork.
* The condition itself is said once, under the box ({@link StepDecision}).
*/
arm?: string;
}
/**
* A decision drawn where it is made: the condition said ONCE, under the box
* that decides it, while each line out of that box says only which way it is.
*/
export interface StepDecision {
id: string;
/** The condition as a reader says it, asking: `await hasSeenWelcome(…)?`. */
label: string;
x: number;
y: number;
width: number;
}
export interface StepsModel extends Picture {
@@ -78,6 +98,34 @@ export interface StepsModel extends Picture {
regions: StepRegionZone[] | null;
/** The first box of each region — where the anchor's at-rest line arrives. */
regionEntries: ReadonlySet<string> | null;
/**
* The order reading only: its decisions, each drawn as a point of its own
* where the arms diverge (`fork:N` in the layout). Null on the tree, whose
* decisions are made INSIDE a box and drawn under it ({@link decisions}).
*/
forks: Map<string, StepForkInfo> | null;
/**
* The tree reading's decisions: a condition said once under the box that
* decides it, its arms labelled on the lines out. Empty when the picture
* holds none.
*/
decisions: StepDecision[];
}
/**
* A decision on the order reading's canvas — a fork of the code with two or
* more arms that lead somewhere. The condition is said ONCE, on the point,
* and each line out answers it (`yes`, `no`, a case's value): two lines that
* each carried the whole predicate, one of them negated, never said they were
* the same choice.
*/
export interface StepForkInfo {
id: string;
/** The condition in positive words — a switch's subject; '' when the arms share none. */
on: string;
form: 'if' | 'switch' | 'ternary' | 'try';
/** The point's words: the condition, asked — `user AND (await …)?`. */
label: string;
}
/** One region of a screen's picture: its caption, and the space its boxes hold. */
@@ -204,6 +252,105 @@ export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
}
}
/* ------------------------------------------------------------- decisions -- */
/** A case value longer than this is cut on the line; the whole condition is a hover away. */
const ARM_WORD_MAX = 24;
/** Room for one line of a decision's caption under its box. */
const DECISION_LINE = 15;
/** Advance of the caption's 10.5px mono, and the room it may take past its box. */
const DECISION_CHAR = 6.3;
const DECISION_MAX_WIDTH = 320;
/**
* The word a line out of a decision says — the ONE place that decides it, so
* the two readings can never word an arm differently. `yes` / `no` for an
* `if` or a ternary; a case's own value for a switch, with the subject the
* decision already asks stripped off (`status === 'expired'` → `'expired'`),
* and `else` for its default; a `try`'s arms keep their own words.
*/
export function armWords(d: { on: string; arm: string; form: 'if' | 'switch' | 'ternary' | 'try'; not?: true }): string {
if (d.form === 'if' || d.form === 'ternary') return d.not ? 'no' : 'yes';
if (d.not) return 'else';
let text = d.arm;
if (d.on && text.startsWith(d.on)) text = text.slice(d.on.length).trim().replace(/^===?\s*/, '');
if (!text) return 'yes';
return text.length > ARM_WORD_MAX ? `${text.slice(0, ARM_WORD_MAX - 1)}` : text;
}
/**
* The one arm of one decision a connector is, when EVERY site behind it
* agrees. A connector with a site that runs under no condition is not
* exclusively an arm — the step happens either way — and one whose sites
* disagree is several stories; both stay plain lines rather than claim a side.
*/
function edgeArm(info: StepEdgeInfo): WireStepDecision | null {
let found: WireStepDecision | null = null;
for (const link of info.links) {
for (const site of link.sites) {
if (!site.decision) return null;
if (found === null) found = site.decision;
else if (found.branch !== site.decision.branch || found.arm !== site.decision.arm) return null;
}
}
return found;
}
/**
* Sibling connectors out of one box that are arms of ONE fork, marked as the
* choice they are: each line says only which way it is, and the condition is
* said once under the box that decides it. Two lines that each carried the
* whole predicate — one of them the other's negation, both truncated to the
* same forty characters — never said they were the same choice, and at rest
* the tree drew them with no label at all.
*
* A fork with ONE drawn arm is a guard clause, not a choice, and keeps its
* condition on the line: the decision has to have at least two ways drawn
* before it is worth a caption.
*/
function markDecisions(edges: Map<string, StepEdgeInfo>, layout: MapLayout): StepDecision[] {
const groups = new Map<string, Array<{ info: StepEdgeInfo; decision: WireStepDecision }>>();
for (const info of edges.values()) {
const decision = edgeArm(info);
if (decision === null) continue;
const key = `${info.from}${decision.branch}`;
const list = groups.get(key) ?? [];
list.push({ info, decision });
groups.set(key, list);
}
const boxes = new Map(layout.nodes.map((n) => [n.id, n]));
const out: StepDecision[] = [];
/** Two decisions made in one box stack under it rather than sitting on each other. */
const perBox = new Map<string, number>();
for (const [key, group] of groups) {
if (new Set(group.map((g) => g.decision.arm)).size < 2) continue;
const box = boxes.get(group[0]!.info.from);
if (!box) continue;
for (const { info, decision } of group) {
info.arm = armWords(decision);
// The connector's label IS the arm now: the decision says the rest.
info.label = info.arm;
}
const nth = perBox.get(box.id) ?? 0;
perBox.set(box.id, nth + 1);
const on = group[0]!.decision.on;
const label = `${whenWords(on) || on}?`;
// The condition is the whole point of the caption, so it may take a
// little more room than the box it sits under — centred on it, and capped
// so a long predicate cannot reach across its neighbours.
const width = Math.max(box.width, Math.min(label.length * DECISION_CHAR + 8, DECISION_MAX_WIDTH));
out.push({
id: key,
label,
x: box.x + (box.width - width) / 2,
y: box.y + box.height + 4 + nth * DECISION_LINE,
width,
});
}
return out;
}
/* ---------------------------------------------------------------- build -- */
export function buildStepsModel(payload: WireStepsPayload): StepsModel {
@@ -332,6 +479,10 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
counts,
regions: zones,
regionEntries: zones === null ? null : new Set(zones.map((z) => z.entry)),
forks: null,
// Placed against the finished layout: a decision is drawn under the box
// that makes it, so it needs to know where that box ended up.
decisions: markDecisions(edges, layout),
};
}
@@ -652,20 +803,55 @@ function packRegions(
}
/**
* Which edges draw, given the selection. Selecting a step says "show me
* everything about this one" — every line touching it comes out. At rest a
* regioned picture hides exactly two things: the anchor's own fan — the
* anchor leads to everything by definition, and a hundred and four ways of
* saying so were the whole canvas, so one line into each region stands in for
* it — and, as everywhere, what points back up the layering. Every other
* lead-to draws, a line between two regions included: the empty state's
* prompt firing the same handler as the header's is the picture, and hiding
* it made a box that leads three places read as wired to nothing. A shared
* step fed from below (the toast every handler calls) stays quiet through the
* back rule alone. An unregioned picture keeps the Map's rule.
* The selection, extended through decisions: a fork's point is not a step —
* it belongs to the steps around it — so selecting the step before a fork, or
* one of its arms, reaches the point and, through it, the fork's other lines.
* The set holds the selected id and every point connected to it through
* points alone; a picture without forks is just the selection.
*/
export function stepEdgeVisible(model: StepsModel, edge: MapEdgeLayout, selected: string | null): boolean {
if (selected !== null) return edge.source === selected || edge.target === selected;
export function selectionReach(model: StepsModel, selected: string): ReadonlySet<string> {
const reach = new Set([selected]);
if (model.forks === null || model.forks.size === 0) return reach;
for (let grew = true; grew; ) {
grew = false;
for (const e of model.layout.edges) {
const from = reach.has(e.source);
const to = reach.has(e.target);
if (from === to) continue;
const other = from ? e.target : e.source;
if (model.forks.has(other) && !reach.has(other)) {
reach.add(other);
grew = true;
}
}
}
return reach;
}
/**
* Which edges draw, given the selection. Selecting a step says "show me
* everything about this one" — every line touching it comes out, a decision's
* lines through its point ({@link selectionReach}). At rest a regioned
* picture hides exactly two things: the anchor's own fan — the anchor leads
* to everything by definition, and a hundred and four ways of saying so were
* the whole canvas, so one line into each region stands in for it — and, as
* everywhere, what points back up the layering. Every other lead-to draws, a
* line between two regions included: the empty state's prompt firing the same
* handler as the header's is the picture, and hiding it made a box that leads
* three places read as wired to nothing. A shared step fed from below (the
* toast every handler calls) stays quiet through the back rule alone. An
* unregioned picture keeps the Map's rule.
*/
export function stepEdgeVisible(
model: StepsModel,
edge: MapEdgeLayout,
selected: string | null,
reach?: ReadonlySet<string>
): boolean {
if (selected !== null) {
const r = reach ?? selectionReach(model, selected);
return r.has(edge.source) || r.has(edge.target);
}
if (edge.thin || edge.back) return false;
if (model.regions === null) return true;
const from = model.nodes.get(edge.source)?.step;
+20
View File
@@ -714,6 +714,26 @@ export interface WireStepSite {
trigger?: WireStepTrigger;
/** For a response site: the status code it sends, when literal. */
status?: number;
/**
* The decision the site's INNERMOST condition belongs to, when one was
* read. Two sites that agree on `branch` and disagree on `arm` are the two
* ways of ONE fork — which a joined condition string can never say, however
* exactly one reads as the other's negation.
*/
decision?: WireStepDecision;
}
/** One arm of one decision, as the site that runs under it records it. */
export interface WireStepDecision {
/** Where the branching construct starts (`line:column`) — the fork's identity. */
branch: string;
/** The decision as a reader says it, always positive: `await hasSeenWelcome(…)`. */
on: string;
/** THIS arm's own condition — an `if` and its `else` differ here and nowhere else. */
arm: string;
form: 'if' | 'switch' | 'ternary' | 'try';
/** The arm taken when the condition does NOT hold — the `else` side. */
not?: true;
}
/** What fires a step or a link: the event it is written under, and the function that writes it there. */
+95 -47
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 ForkPoint from '../components/steps/ForkPoint.svelte';
import DecisionCaption from '../components/steps/DecisionCaption.svelte';
import RegionCaption from '../components/steps/RegionCaption.svelte';
import StepsKey from '../components/steps/StepsKey.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
@@ -40,6 +42,7 @@
buildStepsModel,
kindWord,
kindWords,
selectionReach,
stepEdgeVisible,
stepNeighbourhood,
stepPairId,
@@ -130,7 +133,7 @@
? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
: { padding: 0.1, maxZoom: 1, minZoom: model !== null && model.regions !== null ? 0.2 : 0.4 }
);
const nodeTypes = { step: StepNode, region: RegionCaption };
const nodeTypes = { step: StepNode, region: RegionCaption, fork: ForkPoint, decision: DecisionCaption };
/** Two clicks on one box closer than this are a double-click. */
const DOUBLE_CLICK_MS = 400;
@@ -215,19 +218,31 @@
/** The order can be asked for and have nothing to read: the view then says so. */
const orderReadable = $derived(payload?.program != null);
/** The selection with the decision points it touches — what the edge filter and the dimming reason over. */
const reach = $derived(model === null || selected === null ? null : selectionReach(model, selected));
const neighbours = $derived.by(() => {
if (model === null || selected === null) return null;
const set = new Set<string>([selected]);
if (model === null || reach === null) return null;
const set = new Set<string>(reach);
for (const edge of model.layout.edges) {
if (edge.source === selected) set.add(edge.target);
if (edge.target === selected) set.add(edge.source);
if (reach.has(edge.source)) set.add(edge.target);
if (reach.has(edge.target)) set.add(edge.source);
}
return set;
});
// 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'));
/**
* Which lines are labelled before anything is selected. In the code's order
* that is all of them — there the conditions ARE the picture. In the tree it
* is the arms of a decision and nothing else: a `yes` and a `no` leaving one
* box are the one thing a reader cannot work out from the shape, and drawing
* every condition at rest is the unreadable picture the tree exists to avoid.
*/
const atRestLabels = $derived.by<boolean | ReadonlySet<string>>(() => {
if (readAs === 'order') return true;
if (model === null) return false;
return new Set([...model.edges.values()].filter((e) => e.arm !== undefined).map((e) => e.id));
});
const pills = $derived(model === null ? null : placeLabels(model, selected, atRestLabels));
const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
const focusPill = $derived.by(() => {
if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
@@ -248,48 +263,79 @@
connectable: false,
data: { label: zone.label, width: zone.width },
}));
return captions.concat(model.layout.nodes.map((node) => ({
id: node.id,
type: 'step',
position: { x: node.x, y: node.y },
draggable: false,
selectable: false,
connectable: false,
data: {
layout: node,
info: model.nodes.get(node.id)!,
project: payload?.project ?? 'app',
selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => {
// Two clicks on the same box within a beat are a double-click:
// the picture starts there. Read here rather than off the DOM's
// `dblclick`, which the flow canvas does not always pass on.
const now = performance.now();
if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
lastClick = null;
if (startHere(id)) return;
}
lastClick = { id, at: now };
selected = selected === id ? null : id;
hovered = null;
panelHot = null;
// A decision made inside a box, said once under it; each line out of that
// box says only which way it is.
for (const d of model.decisions) {
const owner = d.id.slice(0, d.id.indexOf(' '));
captions.push({
id: `decision:${d.id}`,
type: 'decision',
position: { x: d.x, y: d.y },
draggable: false,
selectable: false,
connectable: false,
data: { label: d.label, width: d.width, dimmed: neighbours !== null && !neighbours.has(owner) },
});
}
return captions.concat(model.layout.nodes.map((node) => {
// A decision's point: not a step — no selection, no panel; the box asks
// and the lines out answer.
const fork = model.forks?.get(node.id);
if (fork) {
return {
id: node.id,
type: 'fork',
position: { x: node.x, y: node.y },
draggable: false,
selectable: false,
connectable: false,
data: { layout: node, fork, dimmed: neighbours !== null && !neighbours.has(node.id) },
};
}
return {
id: node.id,
type: 'step',
position: { x: node.x, y: node.y },
draggable: false,
selectable: false,
connectable: false,
data: {
layout: node,
info: model.nodes.get(node.id)!,
project: payload?.project ?? 'app',
selected: selected === node.id,
dimmed: neighbours !== null && !neighbours.has(node.id),
onSelect: (id: string) => {
// Two clicks on the same box within a beat are a double-click:
// the picture starts there. Read here rather than off the DOM's
// `dblclick`, which the flow canvas does not always pass on.
const now = performance.now();
if (lastClick !== null && lastClick.id === id && now - lastClick.at < DOUBLE_CLICK_MS) {
lastClick = null;
if (startHere(id)) return;
}
lastClick = { id, at: now };
selected = selected === id ? null : id;
hovered = null;
panelHot = null;
},
// Double-click: the picture starts here — an endpoint or another
// screen drawn as a boundary opens as its own chapter. An effect has
// no symbol to start from.
...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
},
// Double-click: the picture starts here — an endpoint or another
// screen drawn as a boundary opens as its own chapter. An effect has
// no symbol to start from.
...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
},
})));
};
}));
});
const edges = $derived.by<Edge[]>(() => {
if (model === null) return [];
const focus = focusId;
return model.layout.edges
.filter((edge) => stepEdgeVisible(model, edge, selected))
.filter((edge) => stepEdgeVisible(model, edge, selected, reach ?? undefined))
.map((edge) => {
const touches = selected !== null && (edge.source === selected || edge.target === selected);
const touches =
reach !== null && (reach.has(edge.source) || reach.has(edge.target));
const isFocus = focus === edge.id;
const hot = isFocus || touches;
return {
@@ -403,7 +449,7 @@
}
function nameOf(id: string): string {
return model?.nodes.get(id)?.label ?? id;
return model?.nodes.get(id)?.label ?? model?.forks?.get(id)?.label ?? id;
}
/** A Flow strip between the two symbols of a link, when both are symbols. */
@@ -771,10 +817,12 @@
{#if readAs === 'order'}
<p class="dim">
<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 firstthe 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.
means <b>and then</b>. Where the code forks both ways, a small box asks the condition once and each
line out of it answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case; a lone
guard rides its line as <span class="mono">WHEN</span>. 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">