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
+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);