@principal-ai/subsystems-react 0.43.2 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,614 @@
1
+ /**
2
+ * SubsystemModelTransition — a sibling of `SubsystemComponentGraph` that
3
+ * presents ONE subsystem as a sequence of steps (e.g. constructs → static
4
+ * topology → dynamic topology → walkthrough) and animates between them.
5
+ *
6
+ * Same look as the component graph: it reuses the same node / frame / edge
7
+ * components and the edge-label chrome, so only the motion is new. Each step is
8
+ * laid out with `buildSubsystemGraph`; nodes and frames are flattened to
9
+ * absolute rects and position + size + opacity are interpolated on a rAF tween.
10
+ * The viewport refits to the incoming layout at the start of a transition, and
11
+ * the graph's real (ELK-routed, mechanism-colored) edges appear once it settles.
12
+ */
13
+
14
+ import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react';
15
+ import {
16
+ Background,
17
+ EdgeLabelRenderer,
18
+ ReactFlow,
19
+ ReactFlowProvider,
20
+ useReactFlow,
21
+ type Edge,
22
+ type EdgeProps,
23
+ type EdgeTypes,
24
+ type Node,
25
+ type NodeTypes,
26
+ } from '@xyflow/react';
27
+ import '@xyflow/react/dist/style.css';
28
+ import { useTheme } from '@principal-ade/industry-theme';
29
+ import {
30
+ buildSubsystemGraph,
31
+ edgeColor,
32
+ MECHANISM_DESCRIPTIONS,
33
+ walkthroughStepGraphEdgeId,
34
+ type SubsystemEdgeProvenance,
35
+ type SubsystemGraphNode,
36
+ type SubsystemModelDocument,
37
+ type SubsystemWalkthrough,
38
+ } from './model';
39
+ import {
40
+ hexWithAlpha,
41
+ SubsystemCallbacksProvider,
42
+ SubsystemComponentNode,
43
+ SubsystemEdge,
44
+ SubsystemGroupNode,
45
+ } from './nodes';
46
+ import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
47
+ import {
48
+ EDGE_LABEL_CLOUD_EXTRA_TOP,
49
+ EDGE_LABEL_CLOUD_PATH,
50
+ EDGE_LABEL_FONT_SIZE,
51
+ EDGE_LABEL_HEIGHT,
52
+ EDGE_LABEL_WIDTH,
53
+ } from '../utils/edgeLabel';
54
+
55
+ /** One step of the transition — a full model plus its view options. */
56
+ export interface SubsystemTransitionStep {
57
+ model: SubsystemModelDocument;
58
+ /** `path` derives directory frames and nests modules under them. */
59
+ moduleNesting?: 'exact' | 'path';
60
+ /** Show mechanism labels on edges. @default true */
61
+ showEdgeLabels?: boolean;
62
+ /** Explicit boundary frame colors (region key → hex); host override. */
63
+ boundaryColors?: Record<string, string>;
64
+ /**
65
+ * Cycle this step's walkthrough hops: focus each step (dim non-participants,
66
+ * number the labels, frame the step). Typically only the walkthrough layer.
67
+ */
68
+ autoPlayWalkthroughs?: boolean;
69
+ /** Show the focused walkthrough's title chip (with step ticks). */
70
+ showWalkthroughTitle?: boolean;
71
+ }
72
+
73
+ export interface SubsystemModelTransitionProps {
74
+ steps: SubsystemTransitionStep[];
75
+ /** Which step is shown. Controlled by the host (its nav / timer). */
76
+ activeIndex: number;
77
+ /** Tween duration, ms. @default 950 */
78
+ durationMs?: number;
79
+ /** fitView padding for the viewport refit. @default 0.18 */
80
+ fitPadding?: number;
81
+ /** Called when a node is clicked. */
82
+ onSelect?: (componentAlias: string) => void;
83
+ /** How an autoplayed walkthrough step reads. @default 'focus' */
84
+ walkthroughStepMode?: 'focus' | 'dim';
85
+ /** Dwell per walkthrough step while autoplaying, ms. @default 4000 */
86
+ walkthroughAutoPlayIntervalMs?: number;
87
+ className?: string;
88
+ style?: CSSProperties;
89
+ }
90
+
91
+ /** Graph edge look (ELK orth path) + the mechanism label the graph overlays. */
92
+ function TransitionEdge(props: EdgeProps) {
93
+ const { theme } = useTheme();
94
+ const data = props.data as
95
+ | {
96
+ mechanism?: string;
97
+ provenance?: SubsystemEdgeProvenance;
98
+ labelX?: number;
99
+ labelY?: number;
100
+ stepNos?: number[];
101
+ dimmed?: boolean;
102
+ }
103
+ | undefined;
104
+ const mechanism = data?.mechanism ?? 'uses';
105
+ const color = edgeColor({ mechanism, provenance: data?.provenance });
106
+ const text = data?.stepNos?.length
107
+ ? `${data.stepNos.map((n) => `${n}:`).join(' ')} ${mechanism}`
108
+ : mechanism;
109
+ const verifiable =
110
+ data?.provenance === 'graphify' ||
111
+ MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] !== false;
112
+ const labelBg = theme.colors.backgroundSecondary ?? theme.colors.background;
113
+ const showLabel = data?.labelX != null && data?.labelY != null;
114
+ return (
115
+ <>
116
+ <EdgeRenderer {...props} />
117
+ {showLabel && (
118
+ <EdgeLabelRenderer>
119
+ <div
120
+ style={{
121
+ position: 'absolute',
122
+ transform: `translate(-50%, -50%) translate(${data!.labelX}px, ${data!.labelY}px)`,
123
+ display: 'flex',
124
+ alignItems: 'center',
125
+ justifyContent: 'center',
126
+ width: EDGE_LABEL_WIDTH,
127
+ height: EDGE_LABEL_HEIGHT,
128
+ boxSizing: 'border-box',
129
+ padding: '7px 8px',
130
+ pointerEvents: 'none',
131
+ opacity: data?.dimmed ? 0.15 : 1,
132
+ }}
133
+ >
134
+ {verifiable ? (
135
+ <span
136
+ aria-hidden
137
+ style={{
138
+ position: 'absolute',
139
+ inset: 0,
140
+ borderRadius: 4,
141
+ border: `0.5px solid ${color}`,
142
+ background: labelBg,
143
+ }}
144
+ />
145
+ ) : (
146
+ <svg
147
+ aria-hidden
148
+ width={EDGE_LABEL_WIDTH}
149
+ height={EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}
150
+ viewBox={`0 0 ${EDGE_LABEL_WIDTH} ${EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}`}
151
+ style={{ position: 'absolute', left: 0, top: -EDGE_LABEL_CLOUD_EXTRA_TOP }}
152
+ >
153
+ <path
154
+ d={EDGE_LABEL_CLOUD_PATH}
155
+ fill={labelBg}
156
+ stroke={hexWithAlpha(color, 0.75)}
157
+ strokeWidth={1.2}
158
+ strokeLinejoin="round"
159
+ />
160
+ </svg>
161
+ )}
162
+ <span
163
+ style={{
164
+ position: 'relative',
165
+ display: 'block',
166
+ maxWidth: '100%',
167
+ overflow: 'hidden',
168
+ textOverflow: 'ellipsis',
169
+ whiteSpace: 'nowrap',
170
+ fontSize: EDGE_LABEL_FONT_SIZE,
171
+ lineHeight: 1,
172
+ fontFamily: theme.fonts.monospace,
173
+ fontWeight: 500,
174
+ color,
175
+ }}
176
+ >
177
+ {text}
178
+ </span>
179
+ </div>
180
+ </EdgeLabelRenderer>
181
+ )}
182
+ </>
183
+ );
184
+ }
185
+
186
+ const nodeTypes: NodeTypes = {
187
+ 'subsystem-component': SubsystemComponentNode,
188
+ 'subsystem-group': SubsystemGroupNode,
189
+ };
190
+ const edgeTypes: EdgeTypes = { 'subsystem-edge': TransitionEdge };
191
+
192
+ /** `SubsystemEdge` narrows its props; render it through a permissive alias. */
193
+ const EdgeRenderer = SubsystemEdge as unknown as ComponentType<EdgeProps>;
194
+
195
+ type Rect = { x: number; y: number; w: number; h: number };
196
+ type Live = Rect & { o: number };
197
+ type StepMeta = { type: 'subsystem-component' | 'subsystem-group'; data: Record<string, unknown> };
198
+ type StepLayout = {
199
+ rects: Map<string, Rect>;
200
+ meta: Map<string, StepMeta>;
201
+ order: string[];
202
+ edges: Edge[];
203
+ };
204
+
205
+ const ease = (t: number) => t * t * (3 - 2 * t);
206
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
207
+
208
+ function flatten(
209
+ result: Awaited<ReturnType<typeof buildSubsystemGraph>>,
210
+ boundaryColors?: Record<string, string>,
211
+ ): StepLayout {
212
+ const rects = new Map<string, Rect>();
213
+ const meta = new Map<string, StepMeta>();
214
+ const order: string[] = [];
215
+ const nodes: SubsystemGraphNode[] = [
216
+ ...result.nodes.filter((n) => n.type === 'subsystem-group'),
217
+ ...result.nodes.filter((n) => n.type !== 'subsystem-group'),
218
+ ];
219
+ for (const n of nodes) {
220
+ const r = result.absoluteRects.get(n.id);
221
+ if (!r) continue;
222
+ rects.set(n.id, { x: r.x, y: r.y, w: r.width, h: r.height });
223
+ const data = { ...((n.data ?? {}) as Record<string, unknown>) };
224
+ if (n.type === 'subsystem-group' && boundaryColors) {
225
+ const key = (data as { region?: { key?: string } }).region?.key;
226
+ if (key && boundaryColors[key] != null) data.color = boundaryColors[key];
227
+ }
228
+ meta.set(n.id, { type: n.type as StepMeta['type'], data });
229
+ order.push(n.id);
230
+ }
231
+ return { rects, meta, order, edges: result.edges as unknown as Edge[] };
232
+ }
233
+
234
+ function TransitionInner({
235
+ steps,
236
+ activeIndex,
237
+ durationMs = 950,
238
+ fitPadding = 0.18,
239
+ onSelect,
240
+ walkthroughStepMode = 'focus',
241
+ walkthroughAutoPlayIntervalMs = 4000,
242
+ className,
243
+ style,
244
+ }: SubsystemModelTransitionProps) {
245
+ const { theme } = useTheme();
246
+ const { fitBounds } = useReactFlow();
247
+ const [layouts, setLayouts] = useState<StepLayout[] | null>(null);
248
+ const [display, setDisplay] = useState<Map<string, Live>>(new Map());
249
+ const [settled, setSettled] = useState(false);
250
+ // React Flow can't fit until its pane has a real size; the first mount often
251
+ // reports 0, which clamped the initial fit (right-hand nodes cut off).
252
+ const [sized, setSized] = useState(false);
253
+ const wrapRef = useRef<HTMLDivElement>(null);
254
+ const displayRef = useRef(display);
255
+ displayRef.current = display;
256
+ const rafRef = useRef<number | null>(null);
257
+
258
+ useEffect(() => {
259
+ const el = wrapRef.current;
260
+ if (!el) return;
261
+ const measure = () => {
262
+ const r = el.getBoundingClientRect();
263
+ if (r.width > 0 && r.height > 0) setSized(true);
264
+ };
265
+ measure();
266
+ const ro = new ResizeObserver(measure);
267
+ ro.observe(el);
268
+ return () => ro.disconnect();
269
+ }, []);
270
+
271
+ // Lay out every step whenever the steps prop changes (identity-keyed).
272
+ useEffect(() => {
273
+ let alive = true;
274
+ void (async () => {
275
+ const next: StepLayout[] = [];
276
+ for (const s of steps) {
277
+ const result = await buildSubsystemGraph(s.model, {
278
+ showSingletonFrames: true,
279
+ moduleNesting: s.moduleNesting,
280
+ showEdgeLabels: s.showEdgeLabels ?? true,
281
+ });
282
+ next.push(flatten(result, s.boundaryColors));
283
+ }
284
+ if (alive) setLayouts(next);
285
+ })();
286
+ return () => {
287
+ alive = false;
288
+ };
289
+ }, [steps]);
290
+
291
+ // Tween to the active step; refit; hide edges until settled.
292
+ useEffect(() => {
293
+ if (!layouts) return;
294
+ const step = layouts[activeIndex];
295
+ if (!step) return;
296
+ setSettled(false);
297
+
298
+ const start = new Map(displayRef.current);
299
+ const ids = new Set<string>([...start.keys(), ...step.rects.keys()]);
300
+ const t0 = performance.now();
301
+ const tick = (now: number) => {
302
+ const t = Math.min(1, (now - t0) / durationMs);
303
+ const e = ease(t);
304
+ const next = new Map<string, Live>();
305
+ for (const id of ids) {
306
+ const cur = start.get(id);
307
+ const tgt = step.rects.get(id);
308
+ if (tgt) {
309
+ const from = cur ?? { ...tgt, o: 0 };
310
+ next.set(id, {
311
+ x: lerp(from.x, tgt.x, e),
312
+ y: lerp(from.y, tgt.y, e),
313
+ w: lerp(from.w, tgt.w, e),
314
+ h: lerp(from.h, tgt.h, e),
315
+ o: lerp(from.o ?? 0, 1, e),
316
+ });
317
+ } else if (cur) {
318
+ next.set(id, { ...cur, o: lerp(cur.o, 0, e) });
319
+ }
320
+ }
321
+ setDisplay(next);
322
+ if (t < 1) {
323
+ rafRef.current = requestAnimationFrame(tick);
324
+ } else {
325
+ setSettled(true);
326
+ }
327
+ };
328
+ rafRef.current = requestAnimationFrame(tick);
329
+ return () => {
330
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
331
+ };
332
+ }, [layouts, activeIndex, durationMs]);
333
+
334
+ // Refit the viewport to the active step — once the pane is sized, and on every
335
+ // step change. Kept separate so the initial fit waits for a real pane size.
336
+ useEffect(() => {
337
+ if (!sized || !layouts) return;
338
+ const step = layouts[activeIndex];
339
+ if (!step) return;
340
+ const rects = [...step.rects.values()];
341
+ if (rects.length === 0) return;
342
+ const minX = Math.min(...rects.map((r) => r.x));
343
+ const minY = Math.min(...rects.map((r) => r.y));
344
+ const maxX = Math.max(...rects.map((r) => r.x + r.w));
345
+ const maxY = Math.max(...rects.map((r) => r.y + r.h));
346
+ fitBounds(
347
+ { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
348
+ { padding: fitPadding, duration: durationMs },
349
+ );
350
+ }, [sized, layouts, activeIndex, fitPadding, durationMs, fitBounds]);
351
+
352
+ const unionMeta = useMemo(() => {
353
+ const m = new Map<string, StepMeta>();
354
+ for (const s of layouts ?? []) for (const [id, meta] of s.meta) if (!m.has(id)) m.set(id, meta);
355
+ return m;
356
+ }, [layouts]);
357
+
358
+ const activeLayout = layouts?.[activeIndex];
359
+
360
+ // Walkthrough step focus: cycle the active step's hops, dim non-participants,
361
+ // number the labels, and (in `focus` mode) frame the step.
362
+ const activeStepDef = steps[activeIndex];
363
+ const activeWalkthroughs = activeStepDef?.model.walkthroughs;
364
+ const autoPlaySteps =
365
+ activeStepDef?.autoPlayWalkthroughs === true && (activeWalkthroughs?.length ?? 0) > 0;
366
+ const hopList = useMemo(() => {
367
+ const out: Array<{
368
+ edgeId: string;
369
+ from: string;
370
+ to: string;
371
+ stepNo: number;
372
+ total: number;
373
+ walkthroughId: string;
374
+ walkthroughTitle: string;
375
+ }> = [];
376
+ for (const wt of (activeWalkthroughs ?? []) as SubsystemWalkthrough[]) {
377
+ wt.steps.forEach((s, i) => {
378
+ out.push({
379
+ edgeId: walkthroughStepGraphEdgeId(s),
380
+ from: s.from,
381
+ to: s.to,
382
+ stepNo: i + 1,
383
+ total: wt.steps.length,
384
+ walkthroughId: wt.id,
385
+ walkthroughTitle: wt.title,
386
+ });
387
+ });
388
+ }
389
+ return out;
390
+ }, [activeWalkthroughs]);
391
+ const [stepPointer, setStepPointer] = useState(0);
392
+ useEffect(() => {
393
+ setStepPointer(0);
394
+ }, [activeIndex, hopList.length]);
395
+ useEffect(() => {
396
+ if (!autoPlaySteps || hopList.length === 0) return;
397
+ // Pointer 0 = whole flow (no step focus); 1..N = each hop, then wraps.
398
+ const t = window.setInterval(
399
+ () => setStepPointer((p) => (p + 1) % (hopList.length + 1)),
400
+ Math.max(600, walkthroughAutoPlayIntervalMs),
401
+ );
402
+ return () => window.clearInterval(t);
403
+ }, [autoPlaySteps, hopList.length, walkthroughAutoPlayIntervalMs]);
404
+ const focusedHop =
405
+ autoPlaySteps && hopList.length > 0 && stepPointer > 0
406
+ ? hopList[(stepPointer - 1) % hopList.length]
407
+ : undefined;
408
+ const stepNoByEdge = useMemo(() => {
409
+ const m = new Map<string, number[]>();
410
+ for (const h of hopList) {
411
+ const arr = m.get(h.edgeId) ?? [];
412
+ arr.push(h.stepNo);
413
+ m.set(h.edgeId, arr);
414
+ }
415
+ return m;
416
+ }, [hopList]);
417
+ const focusParticipants = useMemo(
418
+ () => (focusedHop ? new Set([focusedHop.from, focusedHop.to]) : null),
419
+ [focusedHop],
420
+ );
421
+
422
+ const nodes: Node[] = useMemo(() => {
423
+ const activeOrder = new Set(activeLayout?.order ?? []);
424
+ const ids = [...display.entries()]
425
+ .filter(([id, r]) => r.o > 0.01 || activeOrder.has(id))
426
+ .map(([id]) => id);
427
+ ids.sort((a, b) => {
428
+ const ga = unionMeta.get(a)?.type === 'subsystem-group' ? 0 : 1;
429
+ const gb = unionMeta.get(b)?.type === 'subsystem-group' ? 0 : 1;
430
+ return ga - gb;
431
+ });
432
+ const out: Node[] = [];
433
+ for (const id of ids) {
434
+ const meta = unionMeta.get(id);
435
+ const rect = display.get(id);
436
+ if (!meta || !rect) continue;
437
+ out.push({
438
+ id,
439
+ type: meta.type,
440
+ position: { x: rect.x, y: rect.y },
441
+ width: rect.w,
442
+ height: rect.h,
443
+ data: meta.data,
444
+ draggable: false,
445
+ selectable: false,
446
+ style: {
447
+ opacity:
448
+ rect.o * (focusParticipants && !focusParticipants.has(id) ? 0.18 : 1),
449
+ },
450
+ } as Node);
451
+ }
452
+ return out;
453
+ }, [display, activeLayout, unionMeta, focusParticipants]);
454
+
455
+ // Real graph edges once settled; step focus adds hop numbers and dims the
456
+ // non-focused hops.
457
+ const edges: Edge[] = useMemo(() => {
458
+ if (!settled) return [];
459
+ const list = activeLayout?.edges ?? [];
460
+ const numbering = autoPlaySteps && hopList.length > 0;
461
+ if (!numbering && !focusedHop) return list;
462
+ return list.map((e) => {
463
+ // Number every hop while autoplaying (whole view included); dim only the
464
+ // hops other than the focused one.
465
+ const stepNos = numbering ? stepNoByEdge.get(e.id) : undefined;
466
+ const dimmed = focusedHop ? e.id !== focusedHop.edgeId : false;
467
+ if (!stepNos && !dimmed) return e;
468
+ return {
469
+ ...e,
470
+ data: { ...(e.data as object), stepNos, dimmed },
471
+ } as Edge;
472
+ });
473
+ }, [settled, activeLayout, focusedHop, stepNoByEdge, autoPlaySteps, hopList.length]);
474
+
475
+ // Camera: frame a focused hop's endpoints, or the whole graph during the
476
+ // "whole flow" phase.
477
+ useEffect(() => {
478
+ if (walkthroughStepMode !== 'focus' || !settled || !sized || !autoPlaySteps) return;
479
+ if (!activeLayout) return;
480
+ const rects = focusedHop
481
+ ? [focusedHop.from, focusedHop.to]
482
+ .map((id) => activeLayout.rects.get(id))
483
+ .filter((r): r is Rect => !!r)
484
+ : [...activeLayout.rects.values()];
485
+ if (rects.length === 0) return;
486
+ const minX = Math.min(...rects.map((r) => r.x));
487
+ const minY = Math.min(...rects.map((r) => r.y));
488
+ const maxX = Math.max(...rects.map((r) => r.x + r.w));
489
+ const maxY = Math.max(...rects.map((r) => r.y + r.h));
490
+ fitBounds(
491
+ { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
492
+ { padding: focusedHop ? 0.3 : fitPadding, duration: durationMs },
493
+ );
494
+ }, [
495
+ walkthroughStepMode,
496
+ focusedHop,
497
+ settled,
498
+ sized,
499
+ activeLayout,
500
+ durationMs,
501
+ fitBounds,
502
+ autoPlaySteps,
503
+ fitPadding,
504
+ ]);
505
+
506
+ const callbacks = useMemo(() => (onSelect ? { onSelect } : {}), [onSelect]);
507
+
508
+ return (
509
+ <div
510
+ ref={wrapRef}
511
+ className={className}
512
+ style={{
513
+ position: 'relative',
514
+ width: '100%',
515
+ height: '100%',
516
+ minHeight: 0,
517
+ background: theme.colors.background,
518
+ ...style,
519
+ }}
520
+ >
521
+ {!layouts ? null : (
522
+ <SubsystemCallbacksProvider value={callbacks}>
523
+ <ReactFlow
524
+ nodes={nodes}
525
+ edges={edges}
526
+ nodeTypes={nodeTypes}
527
+ edgeTypes={edgeTypes}
528
+ className={GRAPH_CANVAS_CLASS}
529
+ {...GRAPH_NAV_PROPS}
530
+ proOptions={{ hideAttribution: true }}
531
+ nodesDraggable={false}
532
+ nodesConnectable={false}
533
+ style={{ width: '100%', height: '100%', background: theme.colors.background }}
534
+ >
535
+ <Background />
536
+ <GraphChrome />
537
+ </ReactFlow>
538
+ </SubsystemCallbacksProvider>
539
+ )}
540
+ {activeStepDef?.showWalkthroughTitle && focusedHop && (
541
+ <div
542
+ aria-label={`${focusedHop.walkthroughTitle}, step ${focusedHop.stepNo} of ${focusedHop.total}`}
543
+ style={{
544
+ position: 'absolute',
545
+ top: 12,
546
+ left: '50%',
547
+ transform: 'translateX(-50%)',
548
+ minWidth: 160,
549
+ maxWidth: '60%',
550
+ display: 'flex',
551
+ flexDirection: 'column',
552
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
553
+ border: `1px solid ${theme.colors.border}`,
554
+ borderRadius: 6,
555
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
556
+ overflow: 'hidden',
557
+ opacity: 0.95,
558
+ pointerEvents: 'none',
559
+ zIndex: 10,
560
+ }}
561
+ >
562
+ <div
563
+ style={{
564
+ padding: '5px 14px',
565
+ fontSize: theme.fontSizes[2] ?? theme.fontSizes[1],
566
+ fontWeight: 600,
567
+ fontFamily: theme.fonts.monospace,
568
+ color: theme.colors.text,
569
+ overflow: 'hidden',
570
+ textOverflow: 'ellipsis',
571
+ whiteSpace: 'nowrap',
572
+ textAlign: 'center',
573
+ }}
574
+ >
575
+ {focusedHop.walkthroughTitle}
576
+ </div>
577
+ {focusedHop.total > 0 && (
578
+ <div style={{ display: 'flex', gap: 3, padding: '0 6px 5px' }} aria-hidden="true">
579
+ {Array.from({ length: focusedHop.total }, (_, i) => {
580
+ const n = i + 1;
581
+ const active = n === focusedHop.stepNo;
582
+ const done = n < focusedHop.stepNo;
583
+ return (
584
+ <span
585
+ key={n}
586
+ style={{
587
+ flex: 1,
588
+ height: 2,
589
+ borderRadius: 1,
590
+ background: active
591
+ ? (theme.colors.accent ?? theme.colors.primary ?? theme.colors.text)
592
+ : done
593
+ ? (theme.colors.textSecondary ?? theme.colors.text)
594
+ : (theme.colors.border ?? 'rgba(127,127,127,0.45)'),
595
+ opacity: active ? 1 : done ? 0.75 : 0.4,
596
+ }}
597
+ />
598
+ );
599
+ })}
600
+ </div>
601
+ )}
602
+ </div>
603
+ )}
604
+ </div>
605
+ );
606
+ }
607
+
608
+ export function SubsystemModelTransition(props: SubsystemModelTransitionProps) {
609
+ return (
610
+ <ReactFlowProvider>
611
+ <TransitionInner {...props} />
612
+ </ReactFlowProvider>
613
+ );
614
+ }
@@ -2159,6 +2159,9 @@ export async function buildSubsystemGraph(
2159
2159
  // layered pass place those sibling frames along the horizontal axis
2160
2160
  // while each frame's members stack vertically.
2161
2161
  direction: edges.length > 0 ? 'RIGHT' : 'DOWN',
2162
+ // Edge-less containment: fold the root frames into a column-major grid
2163
+ // so the shape reads closer to square than one long strip.
2164
+ reflowGrid: edges.length === 0,
2162
2165
  nodeSpacing: 60,
2163
2166
  edgeSpacing: 30,
2164
2167
  edgeNodeSpacing: 60,