feat(ui): where the graph stops — the Flow strip's dynamic-dispatch end cap (CG-51)

A flow that does not reach what it was asked about now ends in a cap instead
of in silence: the dispatch form that ended it, the line, the static key when
the source spells one out, the candidate runtime targets as clickable rows,
and the name-only matches under 0.6 the search refused to follow. A flow that
does reach its destination never shows one.

The verdict is lifted out of `ToolHandler` into
`src/graph/dynamic-boundary-report.ts` and both callers render it —
`codegraph_explore`'s prose and `/api/flow`'s `WireFlowBoundary` — the same
move `named-symbol-flow.ts` made for the path finder, and for the same reason:
a reader holding the strip and the MCP answer must not be told two different
things. The explore prose is unchanged, byte for byte.

When nothing connects at all and a dispatch site explains why, the strip is
that site: one card opened at the line where the static path ends, plus the
cap. When nothing explains it, no stopping point is invented.
This commit is contained in:
Colby McHenry
2026-08-27 04:54:37 -05:00
parent ecd6e1cd15
commit dc7f1e590e
16 changed files with 1666 additions and 160 deletions
+23 -1
View File
@@ -43,7 +43,7 @@ src/
lib/trail.svelte.ts the walked path; mirrored into the `t` query param
lib/kinds.ts kind glyph letters
lib/map-model.ts the Map's deterministic layered layout (pure)
lib/flow-model.ts the Flow strip's card/link geometry — a DAG (pure)
lib/flow-model.ts the Flow strip's card/link geometry + the end cap — a DAG (pure)
lib/filecode-model.ts the whole-file view: fixed line height, arcs, paging (pure)
lib/live.svelte.ts /api/events: two counters every screen refreshes from
lib/toast.svelte.ts the one transient note ("Index updated · reloaded")
@@ -68,6 +68,28 @@ announce the project to a font CDN.
| `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
| `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
## Where the graph stops
A flow that does not reach everything it was asked about carries a
`boundary` on the wire, and `buildFlowLayout` turns it into an extra 240px node
one column past the symbol the path stopped at, joined by a dotted `2 4` link
labelled "end of static path" that deliberately has **no arrowhead** — an arrow
would point at a continuation, and the absence of one is the finding.
Two rules hold it together:
- **The cap's height is arithmetic, like a card's.** `endCapText()` builds every
sentence the cap shows and `endCapHeight()` measures them; the component then
renders exactly what was measured. Change the wording in one and the other
moves with it — they are the same function read twice.
- **One cap per stopping symbol, not per flow.** Two paths that run out at the
same place ran out for the same reason, and two caps side by side would read
as two different findings.
The verdict itself is not computed here or in the server: it is
`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same
detector `codegraph_explore` announces boundaries with.
## Live updates
The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on
+1 -1
View File
@@ -73,7 +73,7 @@
const claimed = assignRefs(lineTokens, refs.get(n) ?? []);
return {
n,
call: n === hop.callRef?.line,
call: n === hop.callRef?.line || n === card.stopLine,
parts: lineTokens.map((token, index): Part => {
const ref = claimed.get(index) ?? null;
return { text: token.text, cls: ref ? null : tokenClass(token.cls), ref };
+179
View File
@@ -0,0 +1,179 @@
<!--
Where the graph stops (design spec §3.5).
The last thing on a strip that did not reach what it was asked about. It is
not an error state and not an apology: a flow running through a computed
member call, a string-keyed bus or a reflective invoke genuinely has no static
edge to follow, and the useful answer is the dispatch site itself — the form,
the key when the source makes it visible, the line, and the symbols that could
plausibly be on the other side.
Every claim on it comes from the server, which builds it with the same
detector `codegraph_explore` announces boundaries with. Nothing here guesses:
a candidate row is a shortlist, and it says so by being under a heading that
counts it rather than under an arrow that asserts it.
The last block is the one that matters most. A name-only match under 0.6
confidence is a continuation the search deliberately refused to follow, and
leaving it invisible would read as "there is nothing here" — which is the one
thing it does not mean.
-->
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte';
import { endCapText, type FlowEndCapLayout } from '../../lib/flow-model';
import { basename } from '../../lib/symbol-model';
interface Props {
data: {
cap: FlowEndCapLayout;
dimmed: boolean;
onOpen: (nodeId: string) => void;
};
}
let { data }: Props = $props();
let cap = $derived(data.cap);
let text = $derived(endCapText(cap.boundary));
</script>
<div
class="endcap"
class:dim={data.dimmed}
style={`width:${cap.width}px;min-height:${cap.height}px`}
>
<Handle type="target" position={Position.Left} id="in" isConnectable={false} />
<p class="lead"><b>Where the graph stops.</b> {text.intro}</p>
{#each text.sites as site, i (i)}
<div class="site">
<p class="form">{site.headline}</p>
{#if site.key !== null}
<p class="key">key <span class="mono">{site.key}</span></p>
{/if}
{#each site.notes as note (note)}
<p class="soft">{note}</p>
{/each}
{#if site.candidateHeading !== null}
<p class="soft">{site.candidateHeading}</p>
{#each site.candidates as candidate (candidate.node.id)}
<button type="button" class="row" onclick={() => data.onOpen(candidate.node.id)}>
<span class="nm">{candidate.display}</span>
<span class="at">{basename(candidate.node.file)}:{candidate.node.line}</span>
</button>
{/each}
{:else if site.candidateNote !== null}
<p class="soft">{site.candidateNote}</p>
{/if}
</div>
{/each}
{#if text.quiet !== null}
<p class="soft block">{text.quiet}</p>
{/if}
{#if text.uncertainHeading !== null}
<div class="block">
<p class="soft">{text.uncertainHeading}</p>
{#each text.uncertain as next (next.node.id)}
<button type="button" class="row" onclick={() => data.onOpen(next.node.id)}>
<span class="nm unsure">{next.node.name}</span>
<span class="at">{next.confidence === null ? '' : next.confidence.toFixed(2)}</span>
</button>
{/each}
</div>
{/if}
{#if text.further !== null}
<p class="block">{text.further}</p>
{/if}
{#if text.missed !== null}
<p class="block">{text.missed}</p>
{/if}
</div>
<style>
.endcap {
box-sizing: border-box;
padding: 12px;
background: var(--paper);
border: 1px dashed var(--rule-soft);
color: var(--ink-2);
font-size: 12px;
line-height: 1.45;
text-align: left;
}
.endcap.dim {
opacity: 0.4;
}
.lead {
margin: 0;
}
.lead b {
color: var(--ink);
font-weight: 600;
}
.site,
.block {
margin-top: 8px;
}
.endcap p {
margin: 0;
}
.form {
color: var(--ink);
}
.soft {
color: var(--ink-3);
}
.key .mono,
.mono {
font-family: var(--mono);
}
.row {
display: flex;
width: 100%;
align-items: baseline;
padding: 0;
background: none;
border: 0;
color: var(--ink-2);
cursor: pointer;
font: 11.5px / 18px var(--mono);
gap: 8px;
justify-content: space-between;
text-align: left;
}
.row:hover .nm {
color: var(--accent);
}
.nm {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A refused match reads as refused: the dotted rule under it is the same one
the code block draws under an uncertain call site. */
.unsure {
text-decoration: underline dotted var(--ink-4);
text-underline-offset: 3px;
}
.at {
color: var(--ink-4);
font-size: 11px;
white-space: nowrap;
}
</style>
+7 -1
View File
@@ -11,6 +11,10 @@
Straight, not curved: two cards on the same row sit at the same height, and a
bezier between them would be a decorative wobble. The path bends only when a
branch puts them on different rows.
The link into an end cap is the exception with no edge behind it: `2 4` dots,
no arrowhead, labelled "end of static path". An arrow would point at a
continuation, and the whole point of the cap is that there isn't one.
-->
<script lang="ts">
import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
@@ -39,7 +43,9 @@
</script>
<BaseEdge {path} class={`flink${d.dimmed ? ' dimmed' : ''}`} style={dashStyle} />
<polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
{#if !d.link.cap}
<polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
{/if}
<g class={`flabel${d.dimmed ? ' dimmed' : ''}`}>
<title>{d.link.label}{d.link.lineLabel ? ` (${d.link.lineLabel})` : ''}</title>
{#each above as line, i (i)}
+39
View File
@@ -459,11 +459,50 @@ export interface WireFlowHop {
source: WireFlowSource | null;
}
/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
export interface WireBoundaryCandidate {
node: WireNodeRef;
display: string;
named: boolean;
}
/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
export interface WireBoundarySite {
form: string;
label: string;
snippet: string;
line: number;
key: string | null;
keyIsType: boolean;
moreSites: number;
candidates: WireBoundaryCandidate[];
candidateNote: string | null;
}
export interface WireFlowContinuation {
node: WireNodeRef;
line: number | null;
confidence: number | null;
}
/** Where the graph stops — the strip's end cap (design spec §3.5). */
export interface WireFlowBoundary {
node: WireNodeRef;
sites: WireBoundarySite[];
uncertain: WireList<WireFlowContinuation>;
further: WireList<WireFlowContinuation>;
missed: WireNodeRef[];
}
export interface WireFlow {
id: string;
/** "execute → rowToFileRecord", for the header's flow picker. */
label: string;
hops: WireFlowHop[];
/** Null on a flow that reaches everything it was asked about. */
boundary: WireFlowBoundary | null;
/** One card at the dispatch site, not a path: the answer ran out here. */
partial: boolean;
}
export interface WireFlowAmbiguity {
+310 -40
View File
@@ -23,7 +23,14 @@
* Tested in `__tests__/ui-flow-model.test.ts`.
*/
import type { WireFlow, WireFlowEdge, WireFlowHop } from './api';
import type {
WireBoundaryCandidate,
WireFlow,
WireFlowBoundary,
WireFlowContinuation,
WireFlowEdge,
WireFlowHop,
} from './api';
/* ------------------------------------------------------------ dimensions -- */
@@ -67,6 +74,143 @@ export function cardHeight(hop: WireFlowHop): number {
return HEADER_HEIGHT + body;
}
/* --------------------------------------------------------------- end cap -- */
/** End-cap width (design spec §3.5). */
export const END_CAP_WIDTH = 240;
/** Padding inside the cap, all four sides. */
export const END_CAP_PADDING = 12;
/** 12px text at 1.45 — the cap's own line box. */
export const END_CAP_LINE = 17.4;
/** One mono row: a candidate target, an uncertain continuation. */
export const END_CAP_ROW = 18;
/** Space between two blocks inside the cap. */
export const END_CAP_GAP = 8;
/**
* Characters of 12px Archivo that fit across the cap's 216px of content.
*
* The cap's height has to be known before it renders, for the same reason a
* card's does — the layout packs columns with it. So the text is built here
* (see {@link endCapText}), measured with this constant, and the component
* renders exactly what was measured. Deliberately a little pessimistic: a cap
* estimated too tall leaves white space, a cap estimated too short would put
* its last row under the next one.
*/
const END_CAP_CHARS = 32;
function wrappedLines(text: string): number {
return Math.max(1, Math.ceil(text.length / END_CAP_CHARS));
}
/** One dispatch site as the cap words it. */
export interface EndCapSite {
/** "computed member call at line 61". */
headline: string;
/** The statically visible key, set in mono. Null when it is a runtime value. */
key: string | null;
/** "key is a runtime value", or the "+N more such sites" tail. */
notes: string[];
candidates: WireBoundaryCandidate[];
/** "N candidate targets", the heading over the rows. Null when there are none. */
candidateHeading: string | null;
/** Why there is no shortlist, when a key was visible but too generic. */
candidateNote: string | null;
}
/**
* Everything the end cap says, as strings.
*
* Built here rather than in the component so the layout can measure the cap
* before it exists — and so the wording is testable without a browser.
*/
export interface EndCapText {
/** The sentence after the bold "Where the graph stops." lead. */
intro: string;
sites: EndCapSite[];
/** "No dynamic-dispatch site …", when the detector found nothing. */
quiet: string | null;
uncertainHeading: string | null;
uncertain: WireFlowContinuation[];
further: string | null;
missed: string | null;
}
const plural = (n: number, one: string, many: string): string => (n === 1 ? one : many);
export function endCapText(boundary: WireFlowBoundary): EndCapText {
const sites: EndCapSite[] = boundary.sites.map((site) => {
const notes: string[] = [];
if (site.key === null) notes.push('the key is a runtime value');
if (site.moreSites > 0) {
notes.push(`+${site.moreSites} more such ${plural(site.moreSites, 'site', 'sites')} here`);
}
return {
headline: `${site.label} at line ${site.line}`,
key: site.key,
notes,
candidates: site.candidates,
candidateHeading:
site.candidates.length > 0
? `${site.candidates.length} candidate ${plural(site.candidates.length, 'target', 'targets')} \u203a`
: null,
candidateNote: site.candidateNote,
};
});
const missedNames = boundary.missed.map((m) => m.name);
return {
intro:
boundary.sites.length > 0
? `${boundary.node.name} chooses its next call at runtime.`
: `${boundary.node.name} is the last symbol on this path.`,
sites,
quiet:
boundary.sites.length > 0
? null
: 'No dynamic-dispatch site was detected in its body, so nothing here explains the break.',
uncertainHeading:
boundary.uncertain.total > 0
? `${boundary.uncertain.total} name-only ${plural(boundary.uncertain.total, 'match', 'matches')} not followed (confidence < 0.6)`
: null,
uncertain: boundary.uncertain.items,
further:
boundary.further.total > 0
? `It makes ${boundary.further.total} further resolved ${plural(boundary.further.total, 'call', 'calls')} this path does not need.`
: null,
missed:
missedNames.length > 0
? `Never reaches ${missedNames.join(', ')}${boundary.missed.length < missedNames.length ? '…' : '.'}`
: null,
};
}
/** Exact rendered height of an end cap, which its CSS then pins as a minimum. */
export function endCapHeight(boundary: WireFlowBoundary): number {
const text = endCapText(boundary);
let h = END_CAP_PADDING * 2;
h += wrappedLines(`Where the graph stops. ${text.intro}`) * END_CAP_LINE;
for (const site of text.sites) {
h += END_CAP_GAP;
h += wrappedLines(site.headline) * END_CAP_LINE;
if (site.key !== null) h += END_CAP_ROW;
for (const note of site.notes) h += wrappedLines(note) * END_CAP_LINE;
if (site.candidateHeading !== null) {
h += END_CAP_LINE + site.candidates.length * END_CAP_ROW;
} else if (site.candidateNote !== null) {
h += wrappedLines(site.candidateNote) * END_CAP_LINE;
}
}
if (text.quiet !== null) h += END_CAP_GAP + wrappedLines(text.quiet) * END_CAP_LINE;
if (text.uncertainHeading !== null) {
h += END_CAP_GAP + wrappedLines(text.uncertainHeading) * END_CAP_LINE;
h += text.uncertain.length * END_CAP_ROW;
}
if (text.further !== null) h += END_CAP_GAP + wrappedLines(text.further) * END_CAP_LINE;
if (text.missed !== null) h += END_CAP_GAP + wrappedLines(text.missed) * END_CAP_LINE;
return Math.round(h);
}
/* ----------------------------------------------------------------- model -- */
export interface FlowCardLayout {
@@ -82,13 +226,22 @@ export interface FlowCardLayout {
flows: string[];
/** Position in the ACTIVE flow, or -1 when it is not on it. */
step: number;
/**
* The dispatch line an end cap hangs off, when one does.
*
* The card is tinted there for the same reason a hop is tinted at its call
* site: it is the line the next thing on screen is about. A boundary card has
* no resolved call to link, so the tint is all the connection there is.
*/
stopLine: number | null;
}
export interface FlowLinkLayout {
id: string;
source: string;
target: string;
edge: WireFlowEdge;
/** Null on the dotted link into an end cap — no edge records a non-call. */
edge: WireFlowEdge | null;
/** Flows this link belongs to. */
flows: string[];
/** The full label, for the connector's tooltip. */
@@ -104,10 +257,29 @@ export interface FlowLinkLayout {
lineLabel: string | null;
/** SVG dasharray, or null for a solid line. */
dash: string | null;
/** This is the dotted link into an end cap, not a recorded edge. */
cap: boolean;
}
/** An end cap, placed one column past the symbol whose path stopped. */
export interface FlowEndCapLayout {
/** `cap:<anchor node id>`. */
id: string;
/** The card the dotted link comes out of. */
anchorId: string;
boundary: WireFlowBoundary;
x: number;
y: number;
width: number;
height: number;
column: number;
/** Flows that stop here — what dims when one is picked. */
flows: string[];
}
export interface FlowLayout {
cards: FlowCardLayout[];
endCaps: FlowEndCapLayout[];
links: FlowLinkLayout[];
width: number;
height: number;
@@ -124,6 +296,14 @@ export function dashFor(edge: WireFlowEdge): string | null {
return null;
}
/** The dotted link into an end cap (design spec §3.5). */
export const END_CAP_DASH = '2 4';
/** The layout id of the cap hanging off `anchorId`, and the way back. */
export const capId = (anchorId: string): string => `cap:${anchorId}`;
export const isCapId = (id: string): boolean => id.startsWith('cap:');
export const anchorOf = (id: string): string => (isCapId(id) ? id.slice(4) : id);
/** Longest a connector label line may be before it is cut. */
export const LABEL_MAX_CHARS = 26;
@@ -209,7 +389,7 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
}
if (cards.size === 0) {
return { cards: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
return { cards: [], endCaps: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
}
// ---- column = longest distance from a start -----------------------------
@@ -241,20 +421,69 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
column.set(id, best);
}
// ---- the end caps ------------------------------------------------------
// One cap per stopping symbol, not per flow: two paths that run out at the
// same place ran out for the same reason, and two caps side by side saying so
// would read as two different findings.
const caps = new Map<string, { boundary: WireFlowBoundary; flows: string[] }>();
for (const flow of flows) {
const boundary = flow.boundary;
if (!boundary || !cards.has(boundary.node.id)) continue;
const hit = caps.get(boundary.node.id);
if (hit) {
if (!hit.flows.includes(flow.id)) hit.flows.push(flow.id);
} else {
caps.set(boundary.node.id, { boundary, flows: [flow.id] });
}
}
// ---- pack each column, active flow first --------------------------------
interface Member {
id: string;
width: number;
height: number;
/** Cards before caps, then first-seen order. */
rank: number;
onActive: boolean;
}
const members = new Map<string, Member>();
for (const [id, card] of cards) {
members.set(id, {
id,
width: CARD_WIDTH,
height: cardHeight(card.hop),
rank: card.order,
onActive: activeSteps.has(id),
});
}
const capColumn = new Map<string, number>();
let capRank = cards.size;
for (const [anchorId, cap] of caps) {
const id = capId(anchorId);
capColumn.set(id, (column.get(anchorId) ?? 0) + 1);
members.set(id, {
id,
width: END_CAP_WIDTH,
height: endCapHeight(cap.boundary),
rank: capRank++,
onActive: activeSteps.has(anchorId),
});
}
const columnOf = (id: string): number => capColumn.get(id) ?? column.get(id) ?? 0;
const byColumn = new Map<number, string[]>();
for (const id of cards.keys()) {
const c = column.get(id) ?? 0;
for (const id of members.keys()) {
const c = columnOf(id);
const list = byColumn.get(c);
if (list) list.push(id);
else byColumn.set(c, [id]);
}
for (const list of byColumn.values()) {
list.sort((a, b) => {
const onA = activeSteps.has(a) ? 0 : 1;
const onB = activeSteps.has(b) ? 0 : 1;
const onA = (members.get(a) as Member).onActive ? 0 : 1;
const onB = (members.get(b) as Member).onActive ? 0 : 1;
if (onA !== onB) return onA - onB;
return (cards.get(a)?.order ?? 0) - (cards.get(b)?.order ?? 0);
return (members.get(a) as Member).rank - (members.get(b) as Member).rank;
});
}
@@ -262,7 +491,8 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
// Each gap is wide enough for the widest label that crosses it. Labels are
// built here rather than in the render pass because the geometry depends on
// them — see LABEL_CHAR_WIDTH.
// them — see LABEL_CHAR_WIDTH. A cap's dotted link keeps the spec's 86px: its
// label is fixed and stacks into two short lines.
const labelled = [...links.entries()].map(([key, link]) => ({
key,
link,
@@ -276,60 +506,100 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
const widest = Math.max(0, ...lines.map((l) => l.length), lineLabel?.length ?? 0);
gaps[from] = Math.max(gaps[from] as number, Math.ceil(widest * LABEL_CHAR_WIDTH) + LABEL_PAD);
}
// A column is as wide as its widest member, so a cap sharing a column with a
// card does not push the card's neighbours out of line.
const columnWidth = Array.from({ length: columns }, () => 0);
for (const [c, list] of byColumn) {
columnWidth[c] = Math.max(...list.map((id) => (members.get(id) as Member).width));
}
const columnX: number[] = [PADDING];
for (let c = 1; c < columns; c++) {
columnX[c] = (columnX[c - 1] as number) + CARD_WIDTH + (gaps[c - 1] as number);
columnX[c] = (columnX[c - 1] as number) + (columnWidth[c - 1] as number) + (gaps[c - 1] as number);
}
const heights = new Map<string, number>();
for (const [id, card] of cards) heights.set(id, cardHeight(card.hop));
// Rows are centred on the tallest column, so a one-card column sits opposite
// the middle of a two-card one instead of hugging the top of the canvas.
const columnHeights = new Map<number, number>();
for (const [c, list] of byColumn) {
columnHeights.set(
c,
list.reduce((sum, id) => sum + (heights.get(id) ?? 0), 0) + ROW_GAP * (list.length - 1)
list.reduce((sum, id) => sum + (members.get(id) as Member).height, 0) +
ROW_GAP * (list.length - 1)
);
}
const tallest = Math.max(...columnHeights.values());
const laidOut = new Map<string, FlowCardLayout>();
const endCaps: FlowEndCapLayout[] = [];
for (const [c, list] of byColumn) {
let y = PADDING + (tallest - (columnHeights.get(c) ?? 0)) / 2;
for (const id of list) {
const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
const height = heights.get(id) ?? 0;
laidOut.set(id, {
id,
hop: card.hop,
x: columnX[c] as number,
y,
width: CARD_WIDTH,
height,
column: c,
flows: card.flows,
step: activeSteps.get(id) ?? -1,
});
y += height + ROW_GAP;
const member = members.get(id) as Member;
const cap = caps.get(anchorOf(id));
if (cap && isCapId(id)) {
endCaps.push({
id,
anchorId: anchorOf(id),
boundary: cap.boundary,
x: columnX[c] as number,
y,
width: member.width,
height: member.height,
column: c,
flows: cap.flows,
});
} else {
const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
laidOut.set(id, {
id,
hop: card.hop,
x: columnX[c] as number,
y,
width: member.width,
height: member.height,
column: c,
flows: card.flows,
step: activeSteps.get(id) ?? -1,
stopLine: caps.get(id)?.boundary.sites[0]?.line ?? null,
});
}
y += member.height + ROW_GAP;
}
}
const linkLayouts: FlowLinkLayout[] = labelled.map(({ link, lines, lineLabel }) => ({
id: `${link.source}->${link.target}`,
source: link.source,
target: link.target,
edge: link.edge,
flows: link.flows,
label: link.edge.label,
labelLines: lines,
lineLabel,
dash: dashFor(link.edge),
cap: false,
}));
for (const cap of endCaps) {
linkLayouts.push({
id: `${cap.anchorId}->${cap.id}`,
source: cap.anchorId,
target: cap.id,
edge: null,
flows: cap.flows,
label: 'end of static path',
labelLines: ['end of', 'static path'],
lineLabel: null,
dash: END_CAP_DASH,
cap: true,
});
}
return {
cards: [...laidOut.values()].sort((a, b) => a.column - b.column || a.y - b.y),
links: labelled.map(({ link, lines, lineLabel }) => ({
id: `${link.source}->${link.target}`,
source: link.source,
target: link.target,
edge: link.edge,
flows: link.flows,
label: link.edge.label,
labelLines: lines,
lineLabel,
dash: dashFor(link.edge),
})),
width: (columnX[columns - 1] as number) + CARD_WIDTH + PADDING,
endCaps: endCaps.sort((a, b) => a.column - b.column || a.y - b.y),
links: linkLayouts,
width: (columnX[columns - 1] as number) + (columnWidth[columns - 1] as number) + PADDING,
height: PADDING * 2 + tallest,
columns,
gaps,
+54 -15
View File
@@ -18,6 +18,7 @@
import '@xyflow/svelte/dist/style.css';
import FlowCard from '../components/flow/FlowCard.svelte';
import FlowLink from '../components/flow/FlowLink.svelte';
import FlowEndCap from '../components/flow/FlowEndCap.svelte';
import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { navigate, symbolHref } from '../lib/router.svelte';
@@ -55,7 +56,7 @@
* there for anyone who wants the shape rather than the code.
*/
const START_VIEWPORT = { x: 0, y: 0, zoom: 1 };
const nodeTypes = { flow: FlowCard };
const nodeTypes = { flow: FlowCard, cap: FlowEndCap };
const edgeTypes = { flow: FlowLink };
/** The hops the trail form asks for, as `<dir><id>` — the wire's own spelling. */
@@ -107,24 +108,41 @@
const nodes = $derived.by<Node[]>(() => {
if (layout === null) return [];
return layout.cards.map((card) => ({
id: card.id,
type: 'flow',
position: { x: card.x, y: card.y },
const caps: Node[] = layout.endCaps.map((cap) => ({
id: cap.id,
type: 'cap',
position: { x: cap.x, y: cap.y },
draggable: false,
selectable: false,
connectable: false,
data: {
// The accent border marks the picked path, and only means something
// when there is more than one on screen. A single flow whose every
// card is accented has said nothing.
card,
current: showAll && card.step >= 0,
dimmed: showAll && card.step < 0,
onOpen: openCard,
onFollow: followCard,
cap,
dimmed: showAll && picked !== null && !cap.flows.includes(picked),
onOpen: openNode,
},
}));
// Caps first, so a card that overlaps one paints on top of it.
return [
...caps,
...layout.cards.map((card) => ({
id: card.id,
type: 'flow',
position: { x: card.x, y: card.y },
draggable: false,
selectable: false,
connectable: false,
data: {
// The accent border marks the picked path, and only means something
// when there is more than one on screen. A single flow whose every
// card is accented has said nothing.
card,
current: showAll && card.step >= 0,
dimmed: showAll && card.step < 0,
onOpen: openCard,
onFollow: followCard,
},
})),
];
});
const edges = $derived.by<Edge[]>(() => {
@@ -172,6 +190,19 @@
);
}
/**
* A row on the end cap: a candidate runtime target, or a continuation the
* search refused to follow.
*
* It opens as a fresh start rather than as another hop, because neither is a
* call the graph recorded — pushing one onto the trail would draw a step
* nobody took. That is the whole reason the cap exists.
*/
function openNode(nodeId: string): void {
trail.clear();
navigate(symbolHref(nodeId));
}
/** The accent link inside a card: step to the symbol it names. */
function followCard(card: FlowCardLayout): void {
const target = card.hop.callRef?.targetId;
@@ -184,6 +215,9 @@
if (p.query.kind === 'trail') {
return 'Your trail, read as a flow: each card is opened at the line that carried you to the next one.';
}
if (p.flows.some((f) => f.partial)) {
return 'No static path connects them. The card is where the looking stopped — a call whose target is chosen at runtime — and the cap names the form, the key and who could be on the other side.';
}
if (p.query.kind === 'directed') {
return 'Every card is a call the graph recorded. A dashed link is a hop no one can see in the source — a callback, an interface, a re-render — and it names where it was wired.';
}
@@ -205,7 +239,9 @@
}}
>
{#each flows as flow (flow.id)}
<option value={flow.id}>{flow.label} · {flow.hops.length} hops</option>
<option value={flow.id}
>{flow.label}{flow.hops.length > 1 ? ` · ${flow.hops.length} hops` : ''}</option
>
{/each}
{#if flows.length > 1}
<option value={ALL}>All {flows.length} paths</option>
@@ -265,8 +301,11 @@
{/if}
</div>
{#if payload && (payload.ambiguous.length > 0 || payload.unresolved.length > 0)}
{#if payload && (payload.reason !== null || payload.ambiguous.length > 0 || payload.unresolved.length > 0) && layout !== null}
<footer class="fnote">
{#if payload.reason !== null}
<p>{payload.reason}</p>
{/if}
{#each payload.ambiguous as amb (amb.token)}
<p>
<span class="mono">{amb.token}</span> names {amb.others.length + 1} definitions.