fix(central-ui): Flow Debugger node text readability — HTML node bodies, shared-prefix gutter, wrap hints

The swimlane's node text was clipped by character count (20/24), so dotted attribute
paths showed only their shared head ("DelmiaReceiver.Reci…" five times in a row) and
the subtitle spilled past the box. It was also unstyled: the SVG <text> elements are
emitted as raw markup (Razor reserves the <text> tag), so they never carried the CSS
isolation scope attribute and every .fg-* rule silently missed.

- Node body is now HTML inside an SVG foreignObject: the browser measures the text,
  the title wraps to two lines and ellipsizes by width, the subtitle by width on one
  line, nothing can overflow the box, and the div's title attribute carries the full
  unstripped text as a tooltip. Node height 78 -> 84.
- A lane whose real titles share a dotted head (CommonDottedPrefix) shows it once in
  the lane gutter ("DelmiaReceiver.*") and each node shows only the tail that tells
  it apart; the write subtitle strips the same head per item. Predicted-only nodes
  are never stripped.
- WrapHints inserts zero-width spaces at camel-case and ./_/ boundaries so a wrapped
  title breaks at "DelmiaRecipe|Download" rather than mid-word.
- Gutter widened 130 -> 160 with the sublabel clip raised to 22 so "site-a · DelmiaSim01"
  fits; the remaining SvgText gutter labels are styled through ::deep.

Verified live on the rig (replay of the Task 22 run): all three lanes readable, no
overflow, gutter shows the factored prefix. CentralUI suite 1199 pass.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
Joseph Doherty
2026-09-06 01:15:56 -04:00
parent 6cd9204d69
commit 038eda5d91
5 changed files with 236 additions and 24 deletions
+1 -1
View File
@@ -222,7 +222,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
- **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed.
- **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the two run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same three commands back the CLI's `flow-debug run-method | run-value-change | replay`.
- State machine `Idle → Predicted → Running → Completed | Failed | TimedOut`; the status strip shows run number, start, total duration, step/boundary/write counts and the root `ExecutionId` (linking to the Execution Chain page). A run whose route call came back with no child execution and no Site-lane steps shows a *site not traced* note (older site build, or an untraced call). Last entry point and per-method inputs persist in `localStorage`.
- The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`.
- The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`. Node text is HTML inside an SVG `foreignObject` so the browser measures it: the title wraps to two lines and ellipsizes by width (never by character count), the subtitle by width on one line, and the hover tooltip carries the full text. A lane whose real titles share a dotted head (Tags: `DelmiaReceiver.RecipeDownloadFlag`, `DelmiaReceiver.RecipeProcessedFlag`, …) shows that head once in the lane gutter (`DelmiaReceiver.*`) and each node shows only the tail that tells it apart (`FlowGraphView.CommonDottedPrefix`).
### Alarm Summary (Deployment Role)
- A dedicated operator **Alarm Summary** page (`/monitoring/alarms`, `RequireDeployment`) gives a **cross-instance, read-only** roll-up of live alarm state at a site — the operator-facing complement to the per-instance Debug View.
@@ -28,7 +28,12 @@
@SvgText("12", LaneLabelY(i), "fg-lane-label", LaneName(i))
@if (!string.IsNullOrEmpty(_layout.LaneSublabels[i]))
{
@SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 16), title: _layout.LaneSublabels[i])
@SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 22), title: _layout.LaneSublabels[i])
}
@if (!string.IsNullOrEmpty(_layout.LanePrefixes[i]))
{
@* The dotted head every node in this lane shares — shown here once, stripped from the nodes. *@
@SvgText("12", F(i * LaneHeight + 56), "fg-lane-prefix", Clip(_layout.LanePrefixes[i] + "*", 22), title: _layout.LanePrefixes[i] + "* (prefix shared by every node in this lane)", dataTest: "flow-lane-prefix")
}
</g>
}
@@ -53,12 +58,20 @@
aria-label="@(Eyebrow(node) + ": " + node.Title)"
data-test="flow-node" data-key="@node.Key" data-kind="@node.Kind" data-lane="@node.Lane" data-column="@node.Column"
@onclick="() => SelectAsync(node)" @onkeydown="e => OnKeyAsync(e, node)">
<title>@node.Title@(string.IsNullOrEmpty(node.Subtitle) ? "" : " — " + node.Subtitle)</title>
<rect x="0" y="0" width="@F(NodeWidth)" height="@F(NodeHeight)" rx="4" ry="4" class="fg-node-box" />
@SvgText("10", "16", "fg-eyebrow", Eyebrow(node))
@SvgText(F(NodeWidth - 10), "16", "fg-duration", Duration(node), anchor: "end")
@SvgText("10", "40", "fg-title", Clip(node.Title, 20))
@SvgText("10", "60", "fg-subtitle", Clip(node.Subtitle, 24))
@* HTML body inside the SVG so the browser measures the text: the title wraps to two
lines and both lines ellipsize by real width, never by a character count, and
nothing can spill past the box. The tooltip carries the full, unstripped text. *@
<foreignObject x="0" y="0" width="@F(NodeWidth)" height="@F(NodeHeight)">
<div xmlns="http://www.w3.org/1999/xhtml" class="fg-body" title="@node.FullText" data-test="flow-node-body">
<div class="fg-head">
<span class="fg-eyebrow">@Eyebrow(node)</span>
<span class="fg-duration">@Duration(node)</span>
</div>
<div class="fg-title" data-test="flow-node-title">@WrapHints(node.DisplayTitle)</div>
<div class="fg-subtitle" data-test="flow-node-subtitle">@node.DisplaySubtitle</div>
</div>
</foreignObject>
</g>
}
</svg>
@@ -30,6 +30,21 @@ public sealed record FlowLayoutNode(
/// <summary>Display subtitle (real: the step's; predicted: the note).</summary>
public string? Subtitle => Step?.Subtitle ?? Predicted?.Note;
/// <summary>
/// The dotted prefix shared by every real title in this node's lane (e.g. <c>DelmiaReceiver.</c>),
/// factored out of the node text and shown once in the lane gutter; null when the lane has none.
/// </summary>
public string? LanePrefix { get; init; }
/// <summary>Title with <see cref="LanePrefix"/> removed — the discriminating tail of a dotted path.</summary>
public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title;
/// <summary>Subtitle with <see cref="LanePrefix"/> removed from each comma-separated item.</summary>
public string? DisplaySubtitle => FlowGraphView.StripPrefix(Subtitle, LanePrefix);
/// <summary>Full, unclipped text for the hover tooltip.</summary>
public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle;
}
/// <summary>One laid-out connector.</summary>
@@ -47,12 +62,14 @@ public sealed record FlowLayoutEdge(string From, string To, string Path, bool Cr
/// <param name="Width">Canvas width in SVG units.</param>
/// <param name="Height">Canvas height in SVG units.</param>
/// <param name="LaneSublabels">Per-lane sublabel (index = <see cref="FlowLane"/>), empty when unknown.</param>
/// <param name="LanePrefixes">Per-lane dotted prefix factored out of the node titles (index = <see cref="FlowLane"/>), empty when none.</param>
public sealed record FlowLayout(
IReadOnlyList<FlowLayoutNode> Nodes,
IReadOnlyList<FlowLayoutEdge> Edges,
double Width,
double Height,
IReadOnlyList<string> LaneSublabels)
IReadOnlyList<string> LaneSublabels,
IReadOnlyList<string> LanePrefixes)
{
/// <summary>True when there is nothing to draw.</summary>
public bool IsEmpty => Nodes.Count == 0;
@@ -72,10 +89,10 @@ public partial class FlowGraphView : ComponentBase
public const double LaneHeight = 110;
/// <summary>Node width.</summary>
public const double NodeWidth = 150;
/// <summary>Node height.</summary>
public const double NodeHeight = 78;
/// <summary>Left edge of column 0 (the lane label gutter sits to its left).</summary>
public const double ColumnOrigin = 130;
/// <summary>Node height (eyebrow row + a two-line title + subtitle inside the lane's 110).</summary>
public const double NodeHeight = 84;
/// <summary>Left edge of column 0 (the lane label gutter sits to its left; wide enough for a 22-character mono sublabel).</summary>
public const double ColumnOrigin = 160;
/// <summary>Column pitch.</summary>
public const double ColumnPitch = 170;
/// <summary>Top padding inside a lane.</summary>
@@ -175,13 +192,70 @@ public partial class FlowGraphView : ComponentBase
private static string Duration(FlowLayoutNode n)
=> n.Step?.DurationMs is int ms ? ms.ToString(CultureInfo.InvariantCulture) + " ms" : (n.PredictedOnly ? "predicted" : string.Empty);
/// <summary>Clips a label to what fits the node width, keeping the full text in a <c>title</c>.</summary>
/// <summary>Clips a gutter label to what fits, keeping the full text in a <c>title</c>.</summary>
public static string Clip(string? text, int max)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
return text.Length <= max ? text : text[..(max - 1)] + "…";
}
/// <summary>
/// The longest dotted prefix (ending in <c>.</c>) shared by every title, or null when there are
/// fewer than two titles, no common prefix, or stripping it would leave some title empty. Node
/// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the
/// shared head is shown once in the lane gutter and each node shows the tail that tells it apart.
/// </summary>
public static string? CommonDottedPrefix(IReadOnlyList<string> titles)
{
if (titles.Count < 2) return null;
var prefix = titles[0];
foreach (var t in titles.Skip(1))
{
var n = 0;
while (n < prefix.Length && n < t.Length && prefix[n] == t[n]) n++;
prefix = prefix[..n];
if (prefix.Length == 0) return null;
}
var dot = prefix.LastIndexOf('.');
if (dot < 0) return null;
prefix = prefix[..(dot + 1)];
return titles.All(t => t.Length > prefix.Length) ? prefix : null;
}
/// <summary>
/// Inserts zero-width spaces (U+200B) after <c>.</c>, <c>/</c>, <c>_</c> and at lower→upper
/// camel-case boundaries so a wrapped title breaks at <c>DelmiaRecipe|Download</c> rather than
/// mid-word; the browser still falls back to breaking anywhere for a single overlong segment.
/// </summary>
public static string WrapHints(string? text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
var sb = new StringBuilder(text.Length + 8);
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (i > 0 && char.IsUpper(c) && char.IsLower(text[i - 1]))
sb.Append('\u200B');
sb.Append(c);
if (c is '.' or '/' or '_' && i < text.Length - 1)
sb.Append('\u200B');
}
return sb.ToString();
}
/// <summary>
/// Removes <paramref name="prefix"/> where it starts <paramref name="text"/> or a comma-separated
/// item within it (the write subtitle is <c>a.X = 1, a.Y = 2</c>); null/empty text passes through.
/// </summary>
public static string? StripPrefix(string? text, string? prefix)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text;
var s = text.StartsWith(prefix, StringComparison.Ordinal) ? text[prefix.Length..] : text;
return s.Replace(", " + prefix, ", ", StringComparison.Ordinal);
}
/// <summary>
/// Emits an SVG <c>&lt;text&gt;</c> element as pre-encoded markup. Razor reserves the
/// <c>&lt;text&gt;</c> tag for its own markup-mode switch and refuses attributes on it,
@@ -336,6 +410,16 @@ public partial class FlowGraphView : ComponentBase
maxCol = Math.Max(maxCol, col);
}
// A lane whose real titles share a dotted head (Tags: "DelmiaReceiver.X", "DelmiaReceiver.Y")
// shows that head once in the gutter and only the tail on each node.
var prefixes = new string?[LaneCount];
for (var l = 0; l < LaneCount; l++)
{
var titles = keys.Where(k => lane[k] == (FlowLane)l && stepByKey.ContainsKey(k))
.Select(k => stepByKey[k].Title).ToList();
prefixes[l] = CommonDottedPrefix(titles);
}
foreach (var k in keys)
{
var col = column[k];
@@ -343,7 +427,10 @@ public partial class FlowGraphView : ComponentBase
var y = LanePad + (int)lane[k] * LaneHeight;
stepByKey.TryGetValue(k, out var step);
predByKey.TryGetValue(k, out var pn);
nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k)));
nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k))
{
LanePrefix = step is null ? null : prefixes[(int)lane[k]],
});
}
var byKey = nodes.ToDictionary(n => n.Key, StringComparer.Ordinal);
@@ -358,7 +445,9 @@ public partial class FlowGraphView : ComponentBase
if (maxCol < 0) width = ColumnOrigin + ColumnPitch + RightPad;
var height = LaneCount * LaneHeight + LanePad;
return new FlowLayout(nodes, edges, width, height, sublabels.Select(s => s ?? string.Empty).ToArray());
return new FlowLayout(nodes, edges, width, height,
sublabels.Select(s => s ?? string.Empty).ToArray(),
prefixes.Select(p => p ?? string.Empty).ToArray());
}
private static string JoinLabel(string? a, string? b)
@@ -15,13 +15,15 @@
font-family: var(--bs-body-font-family, system-ui, sans-serif);
}
/* Lanes */
/* Lanes. The gutter labels are emitted as raw markup (SvgText — Razor reserves the <text> tag),
so they carry no CSS-isolation scope attribute and must be reached through ::deep. */
.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; }
.fg-wrap ::deep .fg-lane-label { fill: var(--ink-soft); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.fg-wrap ::deep .fg-lane-sublabel{ fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); }
.fg-wrap ::deep .fg-lane-prefix { fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); }
.fg-wrap ::deep .fg-empty { fill: var(--ink-faint); font-size: 0.85rem; font-style: italic; }
/* Nodes */
.fg-node { cursor: pointer; outline: none; }
@@ -45,12 +47,35 @@
.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; }
/* Node body: HTML inside a foreignObject so text is measured by the browser. */
.fg-body {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 6px 8px 5px;
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
line-height: 1.2;
color: var(--ink);
font-family: var(--bs-body-font-family, system-ui, sans-serif);
}
.fg-head { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
.fg-eyebrow { color: var(--ink-faint); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fg-duration { color: var(--ink-soft); font-size: 0.6rem; font-family: var(--mono); white-space: nowrap; flex-shrink: 0; }
.fg-title {
font-size: 0.78rem;
font-weight: 600;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fg-subtitle { color: var(--ink-soft); font-size: 0.66rem; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fg-node.failed .fg-eyebrow,
.fg-node.timed-out .fg-eyebrow { fill: var(--bs-danger, #c0392b); }
.fg-node.timed-out .fg-eyebrow { color: var(--bs-danger, #c0392b); }
/* Connectors */
.fg-edge {
@@ -216,4 +216,89 @@ public class FlowGraphViewTests : BunitContext
CultureInfo.CurrentUICulture = previousUi;
}
}
// ── Node text readability (2026-09-06): shared-prefix stripping, width-based clipping, tooltips ──
[Theory]
[InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver.RecipeProcessedFlag" }, "DelmiaReceiver.")]
[InlineData(new[] { "A.B.C.X", "A.B.C.Y", "A.B.D.Z" }, "A.B.")]
[InlineData(new[] { "Same.Path", "Same.Path" }, "Same.")]
[InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag" }, null)]
[InlineData(new[] { "ProcessRecipeDownload", "DelmiaReceiverSimulator" }, null)]
[InlineData(new[] { "NoDotsHere", "NoDotsThere" }, null)]
[InlineData(new[] { "A.B", "A.B.C" }, "A.")]
public void CommonDottedPrefix_FactorsOnlyASharedDottedHeadThatLeavesEveryTail(string[] titles, string? expected)
{
Assert.Equal(expected, FlowGraphView.CommonDottedPrefix(titles));
}
[Theory]
[InlineData("DelmiaRecipeDownload", "Delmia\u200BRecipe\u200BDownload")]
[InlineData("DelmiaSim01.ProcessRecipeDownload", "Delmia\u200BSim01.\u200BProcess\u200BRecipe\u200BDownload")]
[InlineData("POST /api/X", "POST /\u200Bapi/\u200BX")]
[InlineData("lower", "lower")]
[InlineData("", "")]
[InlineData(null, "")]
public void WrapHints_MarksCamelCaseAndSeparatorBoundaries(string? input, string expected)
{
Assert.Equal(expected, FlowGraphView.WrapHints(input));
}
[Fact]
public void StripPrefix_RemovesTheHeadFromTheTitleAndFromEachSubtitleItem()
{
Assert.Equal("RecipeDownloadFlag", FlowGraphView.StripPrefix("DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver."));
Assert.Equal("X = 1, Y = true", FlowGraphView.StripPrefix("DelmiaReceiver.X = 1, DelmiaReceiver.Y = true", "DelmiaReceiver."));
Assert.Equal("Other.Z", FlowGraphView.StripPrefix("Other.Z", "DelmiaReceiver."));
Assert.Null(FlowGraphView.StripPrefix(null, "DelmiaReceiver."));
Assert.Equal("kept", FlowGraphView.StripPrefix("kept", null));
}
[Fact]
public void Layout_TagsLane_FactorsTheSharedDottedHeadIntoTheGutter()
{
var layout = FlowGraphView.ComputeLayout(DelmiaGraph(), null);
Assert.Equal("DelmiaSim01.", layout.LanePrefixes[(int)FlowLane.Tags]);
Assert.Equal(string.Empty, layout.LanePrefixes[(int)FlowLane.Central]);
Assert.Equal(string.Empty, layout.LanePrefixes[(int)FlowLane.Site]);
var writes = layout.Nodes.Where(n => n.Kind == FlowStepKind.AttributeWrite).ToList();
Assert.Equal(["RecipeProcessedFlag", "RecipeDownloadFlag"], writes.Select(w => w.DisplayTitle));
Assert.All(writes, w => Assert.StartsWith("DelmiaSim01.", w.Title));
// Site-lane titles share no dotted head, so they render unchanged.
var wait = layout.Nodes.Single(n => n.Kind == FlowStepKind.AttributeWait);
Assert.Equal(wait.Title, wait.DisplayTitle);
}
[Fact]
public void Layout_PredictedOnlyNodes_NeverGetALanePrefix()
{
var predicted = FlowShapePredictor.PredictSiteScript("s", "Attributes.SetAsync(\"Flag\", true);");
var layout = FlowGraphView.ComputeLayout(null, predicted);
Assert.All(layout.Nodes, n => Assert.Null(n.LanePrefix));
Assert.All(layout.LanePrefixes, p => Assert.Equal(string.Empty, p));
}
[Fact]
public void Render_NodeBodyIsHtmlInAForeignObject_WithStrippedTitleAndFullTextTooltip()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, DelmiaGraph()));
var write = cut.FindAll("g[data-test='flow-node'][data-kind='AttributeWrite']")[0];
var body = write.QuerySelector("foreignObject [data-test='flow-node-body']")!;
var title = write.QuerySelector("[data-test='flow-node-title']")!.TextContent.Trim();
// Wrap hints (zero-width spaces) sit at the camel-case boundaries and nowhere else.
Assert.Equal("Recipe\u200BProcessed\u200BFlag", title);
Assert.Equal("RecipeProcessedFlag", title.Replace("\u200B", ""));
Assert.StartsWith("DelmiaSim01.RecipeProcessedFlag", body.GetAttribute("title"));
// The gutter shows the factored head once.
var prefix = cut.Find("[data-test='flow-lane-prefix']");
Assert.Equal("DelmiaSim01.*", prefix.TextContent.Replace(prefix.QuerySelector("title")?.TextContent ?? "", "").Trim());
// No character-count clipping in any node body: the browser ellipsizes by width instead.
Assert.All(cut.FindAll("[data-test='flow-node-body']"), b => Assert.DoesNotContain("…", b.TextContent));
// The gutter sublabel fits without clipping at the widened gutter.
Assert.Contains("site-a · DelmiaSim01", cut.Markup.Replace("&#183;", "·"));
}
}