Skip to content

Commit dd1519f

Browse files
fahchenclaude
andcommitted
fix(dashboard): NetDiagram nodes compact — token corner badge + external label + snug transition
User feedback on node density: - Place circles carried token count + name + colset name inside, crowded. - Transition rectangles were 128×56 — generous whitespace around short names like "approve". Place nodes: - Inner circle is now empty (just colored ring + fill). Diameter 72. - Token count moves to a corner Badge overlapping top-right of the circle (size-5, cf-accent-tint / cf-accent-ink). Hidden when tokens = 0. testid `place-token-badge-${name}`. - Place name + colour set name move OUTSIDE as a stacked label below the circle. Name `font-mono text-xs text-cf-ink`, colset `font-mono text-[10px] text-cf-ink-muted`. testid `place-label-${name}`. - Dagre bounding box widened from 96×96 to 96×104 to give the external label vertical room. Transition nodes: - Text stays inside the rectangle. - Width computed from longest transition name in the cpnet: `clamp(maxNameLen * 7 + 24, 64, 200)` (7px/char for text-xs + 24px padding ≈ px-4 each side). Single width applied to ALL transitions for a clean dagre grid. - Height tightened 56 → 40. - Examples: `approve`(7) → 64 (floor); `submit_for_review`(17) → 143; `notify`(6) → 64. Tests: place-tokens-* testid migrated to place-token-badge-*; new place-label-* + transition-width assertions. Vitest 153 → 155. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8efd67c commit dd1519f

2 files changed

Lines changed: 111 additions & 44 deletions

File tree

dashboard/ui/src/components/NetDiagram.test.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,47 @@ describe("NetDiagram", () => {
5656

5757
it("shows a token badge only when the place has tokens", () => {
5858
render(<NetDiagram diagram={baseDiagram()} />)
59-
expect(screen.getByTestId("place-tokens-pending")).toBeDefined()
60-
expect(screen.queryByTestId("place-tokens-decided")).toBeNull()
59+
expect(screen.getByTestId("place-token-badge-pending")).toBeDefined()
60+
expect(screen.queryByTestId("place-token-badge-decided")).toBeNull()
61+
})
62+
63+
it("renders place name + colset stacked outside the circle", () => {
64+
render(<NetDiagram diagram={baseDiagram()} />)
65+
const pendingLabel = screen.getByTestId("place-label-pending")
66+
expect(pendingLabel.textContent).toContain("pending")
67+
expect(pendingLabel.textContent).toContain("trigger_t")
68+
const decidedLabel = screen.getByTestId("place-label-decided")
69+
expect(decidedLabel.textContent).toContain("decided")
70+
expect(decidedLabel.textContent).toContain("outcome")
71+
})
72+
73+
it("renders the transition name inside the rectangle and sizes width by longest name", () => {
74+
const diagram = baseDiagram()
75+
diagram.transitions = [
76+
{
77+
name: "approve",
78+
enabled_count: 0,
79+
rejected_by_guard_count: 0,
80+
rejected_by_arc_eval_count: 0,
81+
rejected_by_marking_count: 0,
82+
last_fired_at: null
83+
},
84+
{
85+
name: "submit_for_review",
86+
enabled_count: 0,
87+
rejected_by_guard_count: 0,
88+
rejected_by_arc_eval_count: 0,
89+
rejected_by_marking_count: 0,
90+
last_fired_at: null
91+
}
92+
]
93+
render(<NetDiagram diagram={diagram} />)
94+
const a = screen.getByTestId("transition-node-approve") as HTMLElement
95+
const b = screen.getByTestId("transition-node-submit_for_review") as HTMLElement
96+
expect(a.style.width).toBe(b.style.width)
97+
expect(a.style.width).not.toBe("")
98+
expect(a.textContent).toContain("approve")
99+
expect(b.textContent).toContain("submit_for_review")
61100
})
62101

63102
it("applies the enabled glow data attribute when enabled_count > 0", () => {

dashboard/ui/src/components/NetDiagram.tsx

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,29 @@ const EMPTY_FIRING_SET: ReadonlySet<string> = new Set()
5757
const PLACE_NODE = "cf-place"
5858
const TRANSITION_NODE = "cf-transition"
5959

60+
const PLACE_CIRCLE = 72
61+
const PLACE_LABEL_GAP = 32
6062
const PLACE_W = 96
61-
const PLACE_H = 96
62-
const TRANSITION_W = 128
63-
const TRANSITION_H = 56
63+
const PLACE_H = PLACE_CIRCLE + PLACE_LABEL_GAP
64+
const TRANSITION_H = 40
65+
const TRANSITION_MIN_W = 64
66+
const TRANSITION_MAX_W = 200
67+
const TRANSITION_CHAR_PX = 7
68+
const TRANSITION_PAD_PX = 24
69+
70+
function computeTransitionWidth(transitions: ReadonlyArray<DiagramTransition>): number {
71+
let maxLen = 0
72+
for (const t of transitions) {
73+
if (t.name.length > maxLen) maxLen = t.name.length
74+
}
75+
const raw = maxLen * TRANSITION_CHAR_PX + TRANSITION_PAD_PX
76+
return Math.max(TRANSITION_MIN_W, Math.min(TRANSITION_MAX_W, raw))
77+
}
6478

6579
type PlaceNodeData = DiagramPlace & Record<string, unknown>
6680
type TransitionNodeData = DiagramTransition & {
6781
enactmentState: EnactmentState
82+
width: number
6883
} & Record<string, unknown>
6984

7085
type PlaceNode = Node<PlaceNodeData, typeof PLACE_NODE>
@@ -159,6 +174,8 @@ export function buildGraph(
159174
return { nodes: [], edges: [], isEmpty: true }
160175
}
161176

177+
const transitionW = computeTransitionWidth(transitions)
178+
162179
const g = new dagre.graphlib.Graph()
163180
g.setDefaultEdgeLabel(() => ({}))
164181
const tight = places.length + transitions.length <= 6
@@ -177,7 +194,7 @@ export function buildGraph(
177194
}
178195
for (const transition of transitions) {
179196
g.setNode(transitionId(transition.name), {
180-
width: TRANSITION_W,
197+
width: transitionW,
181198
height: TRANSITION_H
182199
})
183200
}
@@ -207,8 +224,8 @@ export function buildGraph(
207224
return {
208225
id: transitionId(transition.name),
209226
type: TRANSITION_NODE,
210-
position: { x: pos.x - TRANSITION_W / 2, y: pos.y - TRANSITION_H / 2 },
211-
data: { ...transition, enactmentState } as TransitionNodeData,
227+
position: { x: pos.x - transitionW / 2, y: pos.y - TRANSITION_H / 2 },
228+
data: { ...transition, enactmentState, width: transitionW } as TransitionNodeData,
212229
sourcePosition: Position.Bottom,
213230
targetPosition: Position.Top,
214231
draggable: true,
@@ -265,49 +282,62 @@ function PlaceNodeViewImpl({ data }: NodeProps<PlaceNode>) {
265282
const hasTokens = data.tokens_count > 0
266283
const tooltip = data.tokens_summary || `${data.tokens_count} token(s)`
267284
return (
268-
<Surface
269-
as="div"
270-
title={tooltip}
271-
className="relative flex h-[96px] w-[96px] items-center justify-center rounded-full border bg-cf-surface text-center shadow-sm"
272-
style={{
273-
borderColor: hasTokens ? "var(--color-cf-accent)" : "var(--color-cf-border)",
274-
borderWidth: hasTokens ? 2 : 1.5
275-
}}
276-
data-testid={`place-node-${data.name}`}
285+
<div
286+
className="flex flex-col items-center"
287+
style={{ width: PLACE_W, height: PLACE_H }}
277288
>
278-
<Handle
279-
type="target"
280-
position={Position.Left}
281-
style={PLACE_HANDLE_STYLE}
282-
isConnectable={false}
283-
/>
284-
<div className="flex flex-col items-center gap-0.5 px-2">
289+
<div className="relative" style={{ width: PLACE_CIRCLE, height: PLACE_CIRCLE }}>
290+
<Surface
291+
as="div"
292+
title={tooltip}
293+
className="flex h-full w-full items-center justify-center rounded-full border bg-cf-surface shadow-sm"
294+
style={{
295+
borderColor: hasTokens ? "var(--color-cf-accent)" : "var(--color-cf-border)",
296+
borderWidth: hasTokens ? 2 : 1.5
297+
}}
298+
data-testid={`place-node-${data.name}`}
299+
>
300+
<Handle
301+
type="target"
302+
position={Position.Left}
303+
style={PLACE_HANDLE_STYLE}
304+
isConnectable={false}
305+
/>
306+
<Handle
307+
type="source"
308+
position={Position.Right}
309+
style={PLACE_HANDLE_STYLE}
310+
isConnectable={false}
311+
/>
312+
</Surface>
285313
{hasTokens ? (
286-
<span data-testid={`place-tokens-${data.name}`} className="contents">
314+
<span
315+
data-testid={`place-token-badge-${data.name}`}
316+
className="absolute -right-1 -top-1"
317+
>
287318
<Badge
288319
variant="primary"
289-
className="bg-cf-accent-tint px-2 py-0 text-base font-semibold leading-none tabular-nums text-cf-accent-ink"
320+
className="inline-flex size-5 items-center justify-center rounded-full bg-cf-accent-tint p-0 text-[11px] font-semibold leading-none tabular-nums text-cf-accent-ink shadow-sm"
290321
>
291322
{data.tokens_count}
292323
</Badge>
293324
</span>
294325
) : null}
295-
<span className="max-w-[80px] truncate text-xs font-medium text-cf-ink">
296-
{truncate(data.name, 12)}
326+
</div>
327+
<div
328+
data-testid={`place-label-${data.name}`}
329+
className="mt-1 flex w-[120px] flex-col items-center leading-tight"
330+
>
331+
<span className="max-w-full truncate font-mono text-xs font-medium text-cf-ink">
332+
{data.name}
297333
</span>
298334
{data.colour_set ? (
299-
<span className="max-w-[80px] truncate text-[10px] uppercase tracking-wide text-cf-ink-faint">
300-
{truncate(data.colour_set, 12)}
335+
<span className="max-w-full truncate font-mono text-[10px] text-cf-ink-muted">
336+
{data.colour_set}
301337
</span>
302338
) : null}
303339
</div>
304-
<Handle
305-
type="source"
306-
position={Position.Right}
307-
style={PLACE_HANDLE_STYLE}
308-
isConnectable={false}
309-
/>
310-
</Surface>
340+
</div>
311341
)
312342
}
313343

@@ -341,8 +371,10 @@ function TransitionNodeViewImpl({ data }: NodeProps<TransitionNode>) {
341371
return (
342372
<Surface
343373
as="div"
344-
className="flex h-[56px] w-[128px] items-center justify-center rounded-md border bg-cf-surface text-center shadow-sm"
374+
className="flex items-center justify-center rounded-md border bg-cf-surface text-center shadow-sm"
345375
style={{
376+
width: data.width,
377+
height: TRANSITION_H,
346378
borderColor: "var(--color-cf-border)",
347379
borderWidth: 1.5,
348380
boxShadow: pulseShadow ?? baseShadow,
@@ -361,8 +393,8 @@ function TransitionNodeViewImpl({ data }: NodeProps<TransitionNode>) {
361393
style={PLACE_HANDLE_STYLE}
362394
isConnectable={false}
363395
/>
364-
<span className="max-w-[112px] truncate px-2 text-xs font-medium text-cf-ink">
365-
{truncate(data.name, 16)}
396+
<span className="truncate px-4 font-mono text-xs font-medium text-cf-ink">
397+
{data.name}
366398
</span>
367399
<Handle
368400
type="source"
@@ -401,7 +433,3 @@ function usePulseOnChange(value: string | null): boolean {
401433
return pulsing
402434
}
403435

404-
function truncate(value: string, max: number): string {
405-
if (value.length <= max) return value
406-
return `${value.slice(0, max - 1)}…`
407-
}

0 commit comments

Comments
 (0)