feat(central-ui): FlowGraphView SVG swimlane renderer

Dependency-free inline SVG: three fixed 110px lanes (Central / Site / Tags, sublabelled
from the steps' site/instance), columns from a topological pass over the graph edges with
same-lane collision bumping so independent siblings never overlap, 150x78 nodes on a
130+col*170 grid, orthogonal connectors with arrow markers (accent for boundary crossings,
dashed for external actors, light for predicted edges). Renders a real FlowGraph, a
PredictedFlow (dashed nodes, input-dependent finer-dashed), or both — predicted nodes
matched to real steps by kind in order, the leftovers appended greyed as ghosts. Selected
node = accent 2px on --active-bg; failed/timed-out/not-traced outcomes styled; each node a
role=button tabindex=0 <g> with click + Enter/Space selection; svg role=img with a summary
aria-label. Layout is a pure static ComputeLayout, every coordinate InvariantCulture.
Razor reserves the <text> tag, so SVG text elements are emitted through a small
HTML-encoding MarkupString helper.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 00:25:54 -04:00
parent f9b8a2cf8f
commit ba15a38327
4 changed files with 741 additions and 0 deletions
@@ -0,0 +1,65 @@
/* Flow Debugger swimlane — mirrors the design mockup's values through the kit's
CSS variables (theme.css light defaults; site.css dark overrides) so dark mode
follows automatically. Border #d2d2cb = --rule-strong, selected 2px #2f5fd0 on
#eef2fc = --accent on --active-bg. */
.fg-wrap {
overflow: auto;
border: 1px solid var(--rule);
border-radius: 4px;
background: var(--bs-body-bg, #fff);
}
.fg-svg {
display: block;
font-family: var(--bs-body-font-family, system-ui, sans-serif);
}
/* Lanes */
.fg-lane-bg { fill: transparent; }
.fg-lane-bg.alt { fill: var(--rule); fill-opacity: 0.25; }
.fg-lane-rule { stroke: var(--rule); stroke-width: 1; }
.fg-lane-label { fill: var(--ink-soft); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.fg-lane-sublabel{ fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); }
.fg-empty { fill: var(--ink-faint); font-size: 0.85rem; font-style: italic; }
/* Nodes */
.fg-node { cursor: pointer; outline: none; }
.fg-node .fg-node-box {
fill: var(--bs-body-bg, #fff);
stroke: var(--rule-strong);
stroke-width: 1;
}
.fg-node:hover .fg-node-box,
.fg-node:focus-visible .fg-node-box { stroke: var(--accent); }
.fg-node.selected .fg-node-box {
stroke: var(--accent);
stroke-width: 2;
fill: var(--active-bg);
}
.fg-node.predicted .fg-node-box { stroke-dasharray: 4 3; }
.fg-node.input-dependent .fg-node-box { stroke-dasharray: 2 3; }
.fg-node.ghost { opacity: 0.45; }
.fg-node.ghost .fg-node-box { fill: var(--rule); fill-opacity: 0.35; }
.fg-node.failed .fg-node-box,
.fg-node.timed-out .fg-node-box { stroke: var(--bs-danger, #c0392b); }
.fg-node.not-traced .fg-node-box { stroke-dasharray: 1 3; }
.fg-eyebrow { fill: var(--ink-faint); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
.fg-duration { fill: var(--ink-soft); font-size: 0.6rem; font-family: var(--mono); }
.fg-title { fill: var(--ink); font-size: 0.8rem; font-weight: 600; }
.fg-subtitle { fill: var(--ink-soft); font-size: 0.68rem; }
.fg-node.failed .fg-eyebrow,
.fg-node.timed-out .fg-eyebrow { fill: var(--bs-danger, #c0392b); }
/* Connectors */
.fg-edge {
fill: none;
stroke: var(--ink-faint);
stroke-width: 1.5;
}
.fg-edge.boundary { stroke: var(--accent); }
.fg-edge.external { stroke-dasharray: 5 4; }
.fg-edge.predicted { stroke-opacity: 0.5; stroke-dasharray: 3 3; }
.fg-arrowhead { fill: var(--ink-faint); }
.fg-arrowhead.accent { fill: var(--accent); }