();
/**
@@ -818,6 +861,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const wireSite: WireStepSite = { file: posix(fold.node.filePath), line: ref.line, text, when: '' };
if (args !== null) wireSite.args = args;
if (status !== null) wireSite.status = status;
+ const effectDecision = decisionOf(guards);
+ if (effectDecision) wireSite.decision = effectDecision;
// Where the call is written HERE — in this function, at this line. The
// rail places the step by it; the tree's row order uses the hop out of the
// step's root, which is the same position when nothing was folded.
@@ -1278,6 +1323,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const hop = fold.first ?? local;
if (!to.first) to.first = hop;
if (!to.region) to.region = regionOf(from, fold.chain);
+ const decision = decisionOf(guards);
+ if (decision) site = { ...site, decision };
const id = link(from, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within);
record(fold.node, local, guards, { step: to.id, link: id }, a.trigger, await loopsAt(fold.node, at));
if (to.root !== null && !explored.has(to.id)) {
@@ -1317,7 +1364,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
const guards = await guardsAt(fold.node, at);
const local = await hopAt(fold.node, at, target.name);
const hop = fold.first ?? local;
- const id = link(firedBy ?? step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within);
+ const knownDecision = decisionOf(guards);
+ const knownSite = await withArgs(a.site, fold.node, at);
+ const id = link(firedBy ?? step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], knownDecision ? { ...knownSite, decision: knownDecision } : knownSite, e, a.trigger, hop.within);
record(fold.node, local, guards, { step: known.id, link: id }, a.trigger, await loopsAt(fold.node, at));
}
continue;
diff --git a/ui/src/components/steps/DecisionCaption.svelte b/ui/src/components/steps/DecisionCaption.svelte
new file mode 100644
index 0000000..1c9dc31
--- /dev/null
+++ b/ui/src/components/steps/DecisionCaption.svelte
@@ -0,0 +1,47 @@
+
+
+
+ {#if plain}{caption.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each}?{/if}
+
+
+
diff --git a/ui/src/components/steps/ForkPoint.svelte b/ui/src/components/steps/ForkPoint.svelte
new file mode 100644
index 0000000..9d29a66
--- /dev/null
+++ b/ui/src/components/steps/ForkPoint.svelte
@@ -0,0 +1,90 @@
+
+
+{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
+
+{/each}
+
+
+ {#if plain}{node.fork.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each}?{/if}
+
+
+{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
+
+{/each}
+
+
diff --git a/ui/src/components/steps/StepsKey.svelte b/ui/src/components/steps/StepsKey.svelte
index 09b1f08..fcffd0a 100644
--- a/ui/src/components/steps/StepsKey.svelte
+++ b/ui/src/components/steps/StepsKey.svelte
@@ -115,9 +115,13 @@
And then — the step at the other end happens after this one; the plumbing between them is folded into the line
+
+ x?
+ A decision both of whose ways are drawn: the box asks the condition once and each line out answers — yes, no, a case. An arm that answers or leaves ends there
+
WHEN x
- Where the code forks — an if, a switch, a try, an early exit: what has to hold for the step at the other end. No label = it happens either way
+ A lone guard — an early exit, an if with one drawn side: what has to hold for the step at the other end. No label = it happens either way
via x
@@ -144,6 +148,14 @@
Goes back up the picture — leaves the top of its box, arrives at the bottom of the other
+
+ x? · yes
+ 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 — yes, no, a case. These are
+ the only lines labelled before you select anything
+
→ …x
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
@@ -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;
diff --git a/ui/src/lib/program-model.ts b/ui/src/lib/program-model.ts
index 5c5d97e..1afd081 100644
--- a/ui/src/lib/program-model.ts
+++ b/ui/src/lib/program-model.ts
@@ -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
;
+ /** The decisions drawn as points, in the order the reading met them. */
+ forks: OrderFork[];
}
/**
@@ -99,6 +115,7 @@ export function orderGraph(program: NonNullable, an
const edges: OrderEdge[] = [];
const seen = new Set([anchor]);
const at = new Map();
+ 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, 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, 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, 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, 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, edges: readonly OrderEdge[]): Map {
+ const forward = withoutBackEdges(anchor, nodes, edges);
const depth = new Map();
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, 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, edges: readonly OrderEdge[]): OrderEdge[] {
+ const out = new Map();
+ 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();
+ const back = new Set();
+ 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();
+ const forks = new Map();
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();
@@ -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();
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();
+ 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 =>
@@ -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();
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}?`;
+}
diff --git a/ui/src/lib/screens-model.ts b/ui/src/lib/screens-model.ts
index 3d29355..d3af1c7 100644
--- a/ui/src/lib/screens-model.ts
+++ b/ui/src/lib/screens-model.ts
@@ -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 = false
+): PillLayout {
const pills = new Map();
- 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);
diff --git a/ui/src/lib/steps-model.ts b/ui/src/lib/steps-model.ts
index 2bc008b..a47bd0d 100644
--- a/ui/src/lib/steps-model.ts
+++ b/ui/src/lib/steps-model.ts
@@ -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 | 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 | 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, layout: MapLayout): StepDecision[] {
+ const groups = new Map>();
+ 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();
+ 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 {
+ 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
+): 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;
diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts
index dbea0a0..4c8a58b 100644
--- a/ui/src/lib/wire.ts
+++ b/ui/src/lib/wire.ts
@@ -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. */
diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte
index 2f5ee49..b5f7996 100644
--- a/ui/src/views/StepsView.svelte
+++ b/ui/src/views/StepsView.svelte
@@ -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([selected]);
+ if (model === null || reach === null) return null;
+ const set = new Set(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>(() => {
+ 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(() => {
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'}
● The anchor is at the top, and each row down is what happens next: a line
- means and then, 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.
+ means and then. Where the code forks both ways, a small box asks the condition once and each
+ line out of it answers — yes, no, a case; a lone
+ guard rides its line as WHEN. 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.
{:else}