@principal-ai/subsystems-react 0.18.0 → 0.19.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.
- package/README.md +11 -85
- package/dist/subsystem/SubsystemCarousel.d.ts +36 -0
- package/dist/subsystem/SubsystemCarousel.d.ts.map +1 -0
- package/dist/subsystem/SubsystemCarousel.js +163 -0
- package/dist/subsystem/SubsystemCarousel.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +25 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +376 -72
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/package.json +3 -3
- package/src/subsystem/SubsystemComponentGraph.tsx +460 -53
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
applyNodeChanges,
|
|
33
33
|
} from '@xyflow/react';
|
|
34
34
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
35
|
-
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
|
35
|
+
import { ChevronDown, ChevronUp, Pause, Play, X } from 'lucide-react';
|
|
36
36
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
37
37
|
import {
|
|
38
38
|
buildSubsystemGraph,
|
|
@@ -58,6 +58,8 @@ const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
|
|
|
58
58
|
/** Rough monospace width at fontSize 10 + horizontal padding/border. */
|
|
59
59
|
const EDGE_LABEL_CHAR_PX = 6.2;
|
|
60
60
|
const EDGE_LABEL_PAD_PX = 18;
|
|
61
|
+
/** Pause (ms) between steps when a throughline autoplays. */
|
|
62
|
+
const THROUGHLINE_PLAY_PAUSE_MS = 2500;
|
|
61
63
|
|
|
62
64
|
/** Context passed to `renderThroughlineViewer` when a flow/step is focused. */
|
|
63
65
|
export interface ThroughlineViewerContext {
|
|
@@ -99,12 +101,37 @@ export interface SubsystemComponentGraphProps {
|
|
|
99
101
|
* subsystem name visible as an overlay on the canvas.
|
|
100
102
|
*/
|
|
101
103
|
hideSidebar?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* How throughline step highlighting behaves on the canvas.
|
|
106
|
+
* - `focus` (default): zoom to the step, hide non-participants, open drawer
|
|
107
|
+
* - `dim`: keep the full graph, dim non-participants (same as hovering a step)
|
|
108
|
+
*/
|
|
109
|
+
throughlineStepMode?: 'focus' | 'dim';
|
|
110
|
+
/**
|
|
111
|
+
* When true, cycles throughline steps automatically using `dim` highlighting
|
|
112
|
+
* (no zoom, no drawer). Loops across all throughlines that have steps.
|
|
113
|
+
* Useful for graph-only embeds (`hideSidebar`).
|
|
114
|
+
*/
|
|
115
|
+
autoPlayThroughlines?: boolean;
|
|
116
|
+
/** Pause between autoplay steps in ms. @default 2500 */
|
|
117
|
+
throughlineAutoPlayIntervalMs?: number;
|
|
118
|
+
/**
|
|
119
|
+
* When false, focusing a throughline/step does not call `fitView`.
|
|
120
|
+
* @default true
|
|
121
|
+
*/
|
|
122
|
+
zoomOnThroughlineFocus?: boolean;
|
|
102
123
|
/**
|
|
103
124
|
* Subsystem title rendered as a non-interactive overlay chip on the graph
|
|
104
125
|
* canvas (top-center). Does not trigger the sidebar — for graph-only
|
|
105
126
|
* embeds that still need to name what they show.
|
|
106
127
|
*/
|
|
107
128
|
graphTitle?: string;
|
|
129
|
+
/**
|
|
130
|
+
* When true, shows the active throughline's title as a non-interactive
|
|
131
|
+
* overlay chip on the canvas (under `graphTitle` when both are set).
|
|
132
|
+
* Uses the focused or hover-highlighted throughline.
|
|
133
|
+
*/
|
|
134
|
+
showThroughlineTitle?: boolean;
|
|
108
135
|
/** Markdown description rendered in the sidebar. */
|
|
109
136
|
description?: string;
|
|
110
137
|
/** Rendered over the graph canvas only (not the title/legend sidebar). */
|
|
@@ -183,7 +210,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
183
210
|
measured: { w: number; h: number } | null;
|
|
184
211
|
}
|
|
185
212
|
|
|
186
|
-
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
213
|
+
function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, throughlineStepMode = 'focus', autoPlayThroughlines = false, throughlineAutoPlayIntervalMs = THROUGHLINE_PLAY_PAUSE_MS, zoomOnThroughlineFocus = true, graphTitle, showThroughlineTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
187
214
|
const { theme } = useTheme();
|
|
188
215
|
const { fitView } = useReactFlow();
|
|
189
216
|
const viewport = useViewport();
|
|
@@ -202,6 +229,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
202
229
|
const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
|
|
203
230
|
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
204
231
|
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
|
|
232
|
+
// Hovered step in the flows panel: dims every canvas node/edge not involved
|
|
233
|
+
// with that step (transient — no camera move, no drawer, no focus change).
|
|
234
|
+
const [hoveredThroughlineStep, setHoveredThroughlineStep] = useState<{
|
|
235
|
+
throughlineId: string;
|
|
236
|
+
stepIndex: number;
|
|
237
|
+
} | null>(null);
|
|
205
238
|
// Sidebar bottom half: which panel is shown when throughlines exist.
|
|
206
239
|
const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
|
|
207
240
|
throughlines?.length ? 'flows' : 'files',
|
|
@@ -215,6 +248,50 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
215
248
|
const [descToggleHover, setDescToggleHover] = useState(false);
|
|
216
249
|
// `true` only when a description exists AND the user opened it.
|
|
217
250
|
const showDesc = !!description && descriptionVisible;
|
|
251
|
+
// Sidebar width (px). Draggable via the resize handle between the sidebar
|
|
252
|
+
// and the graph canvas; clamps to sensible bounds while dragging.
|
|
253
|
+
const [sidebarWidth, setSidebarWidth] = useState(340);
|
|
254
|
+
const [sidebarDrag, setSidebarDrag] = useState(false);
|
|
255
|
+
const sidebarDragStartX = useRef(0);
|
|
256
|
+
const sidebarDragStartWidth = useRef(340);
|
|
257
|
+
const sidebarMinWidth = 240;
|
|
258
|
+
const sidebarMaxWidth = useMemo(
|
|
259
|
+
() => Math.max(Math.min((_measured?.w ?? 680) * 0.5, 600), sidebarMinWidth),
|
|
260
|
+
[_measured?.w],
|
|
261
|
+
);
|
|
262
|
+
// Resize drag: capture the drag state so document-level move/up listeners
|
|
263
|
+
// stay attached for the duration of the gesture, then release on mouseup.
|
|
264
|
+
const onSidebarResizeStart = useCallback(
|
|
265
|
+
(e: ReactMouseEvent) => {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
sidebarDragStartX.current = e.clientX;
|
|
268
|
+
sidebarDragStartWidth.current = sidebarWidth;
|
|
269
|
+
setSidebarDrag(true);
|
|
270
|
+
},
|
|
271
|
+
[sidebarWidth],
|
|
272
|
+
);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!sidebarDrag) return;
|
|
275
|
+
const onMove = (e: globalThis.MouseEvent) => {
|
|
276
|
+
const delta = e.clientX - sidebarDragStartX.current;
|
|
277
|
+
const next = Math.min(
|
|
278
|
+
Math.max(sidebarDragStartWidth.current + delta, sidebarMinWidth),
|
|
279
|
+
sidebarMaxWidth,
|
|
280
|
+
);
|
|
281
|
+
setSidebarWidth(next);
|
|
282
|
+
};
|
|
283
|
+
const onUp = () => {
|
|
284
|
+
setSidebarDrag(false);
|
|
285
|
+
// Re-fit the canvas so the graph re-centers in the new available space.
|
|
286
|
+
requestAnimationFrame(() => fitView());
|
|
287
|
+
};
|
|
288
|
+
document.addEventListener('mousemove', onMove);
|
|
289
|
+
document.addEventListener('mouseup', onUp);
|
|
290
|
+
return () => {
|
|
291
|
+
document.removeEventListener('mousemove', onMove);
|
|
292
|
+
document.removeEventListener('mouseup', onUp);
|
|
293
|
+
};
|
|
294
|
+
}, [sidebarDrag, sidebarMaxWidth, fitView]);
|
|
218
295
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
219
296
|
// closure over the effect deps) can toggle without a stale value.
|
|
220
297
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
@@ -233,6 +310,46 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
233
310
|
return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
|
|
234
311
|
}, [drawerTarget, throughlines]);
|
|
235
312
|
|
|
313
|
+
// Throughline shown on the canvas title chip (focus or hover/autoplay highlight).
|
|
314
|
+
const overlayThroughlineTitle = useMemo(() => {
|
|
315
|
+
if (!showThroughlineTitle || !throughlines?.length) return null;
|
|
316
|
+
const id = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
|
|
317
|
+
if (!id) return null;
|
|
318
|
+
return throughlines.find((t) => t.id === id)?.title ?? null;
|
|
319
|
+
}, [
|
|
320
|
+
showThroughlineTitle,
|
|
321
|
+
throughlines,
|
|
322
|
+
focusedThroughlineId,
|
|
323
|
+
hoveredThroughlineStep,
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
// Active step for the bottom-of-title progress + annotation chip.
|
|
327
|
+
const overlayThroughlineStep = useMemo(() => {
|
|
328
|
+
if (!showThroughlineTitle || !throughlines?.length) return null;
|
|
329
|
+
const tlId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId ?? null;
|
|
330
|
+
let stepIndex: number | null = null;
|
|
331
|
+
if (focusedThroughlineId != null) {
|
|
332
|
+
stepIndex = focusedStepIndex;
|
|
333
|
+
} else if (hoveredThroughlineStep != null) {
|
|
334
|
+
stepIndex = hoveredThroughlineStep.stepIndex;
|
|
335
|
+
}
|
|
336
|
+
if (tlId == null || stepIndex == null) return null;
|
|
337
|
+
const tl = throughlines.find((t) => t.id === tlId);
|
|
338
|
+
const step = tl?.steps[stepIndex];
|
|
339
|
+
if (!step || !tl) return null;
|
|
340
|
+
return {
|
|
341
|
+
index: stepIndex + 1,
|
|
342
|
+
total: tl.steps.length,
|
|
343
|
+
annotation: step.annotation,
|
|
344
|
+
};
|
|
345
|
+
}, [
|
|
346
|
+
showThroughlineTitle,
|
|
347
|
+
throughlines,
|
|
348
|
+
focusedThroughlineId,
|
|
349
|
+
focusedStepIndex,
|
|
350
|
+
hoveredThroughlineStep,
|
|
351
|
+
]);
|
|
352
|
+
|
|
236
353
|
const drawerTitle = useMemo(() => {
|
|
237
354
|
if (!drawerTarget) return null;
|
|
238
355
|
if (drawerTarget.kind === 'file') return drawerTarget.file;
|
|
@@ -398,11 +515,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
398
515
|
return new Set(tl.steps.map((s) => s.edgeId));
|
|
399
516
|
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
400
517
|
|
|
401
|
-
// 1-based step numbers per edge of the
|
|
402
|
-
// in more than one step
|
|
518
|
+
// 1-based step numbers per edge of the active flow (focused or
|
|
519
|
+
// hover/autoplay-highlighted). An edge can appear in more than one step.
|
|
403
520
|
const selectedFlowStepNos = useMemo(() => {
|
|
404
|
-
|
|
405
|
-
|
|
521
|
+
const activeId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
|
|
522
|
+
if (activeId == null || !throughlines) return null;
|
|
523
|
+
const tl = throughlines.find((t) => t.id === activeId);
|
|
406
524
|
if (!tl) return null;
|
|
407
525
|
const map = new Map<string, number[]>();
|
|
408
526
|
tl.steps.forEach((s, i) => {
|
|
@@ -411,7 +529,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
411
529
|
map.set(s.edgeId, list);
|
|
412
530
|
});
|
|
413
531
|
return map;
|
|
414
|
-
}, [throughlines, focusedThroughlineId]);
|
|
532
|
+
}, [throughlines, focusedThroughlineId, hoveredThroughlineStep]);
|
|
415
533
|
|
|
416
534
|
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
417
535
|
const openedEdgeIds = useMemo(() => {
|
|
@@ -434,6 +552,19 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
434
552
|
}
|
|
435
553
|
return ids.size > 0 ? ids : null;
|
|
436
554
|
};
|
|
555
|
+
// Edge/nodes involved in the hovered step — dim everything else.
|
|
556
|
+
const hoverEdgeIds = useMemo(() => {
|
|
557
|
+
if (!hoveredThroughlineStep || !throughlines) return null;
|
|
558
|
+
const tl = throughlines.find((t) => t.id === hoveredThroughlineStep.throughlineId);
|
|
559
|
+
if (!tl) return null;
|
|
560
|
+
const step = tl.steps[hoveredThroughlineStep.stepIndex];
|
|
561
|
+
return step ? new Set([step.edgeId]) : null;
|
|
562
|
+
}, [throughlines, hoveredThroughlineStep]);
|
|
563
|
+
const hoverNodeIds = useMemo(
|
|
564
|
+
() => endpointsOf(hoverEdgeIds),
|
|
565
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
566
|
+
[baseEdges, hoverEdgeIds],
|
|
567
|
+
);
|
|
437
568
|
const openedNodeIds = useMemo(
|
|
438
569
|
() => endpointsOf(openedEdgeIds),
|
|
439
570
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -467,6 +598,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
467
598
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
468
599
|
// React Flow's own selection state from updating.
|
|
469
600
|
const dispNodes = useMemo(() => {
|
|
601
|
+
// While a step is hovered, only the hovered step's endpoints stay bright;
|
|
602
|
+
// everything else is dimmed (never hidden) so the step's context survives.
|
|
470
603
|
return xyflowNodesBase.map((n) => {
|
|
471
604
|
// Boundary frames follow their members: hidden when no member is
|
|
472
605
|
// visible, dimmed when members are dimmed. Never selectable.
|
|
@@ -478,13 +611,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
478
611
|
anyOpened: openedNodeIds != null,
|
|
479
612
|
anySelected: brightNodeIds != null,
|
|
480
613
|
});
|
|
614
|
+
const dimmed = hoverNodeIds
|
|
615
|
+
? !memberIds.some((id) => hoverNodeIds.has(id))
|
|
616
|
+
: vis.dimmed;
|
|
617
|
+
const hidden = hoverNodeIds ? false : vis.hidden;
|
|
481
618
|
return {
|
|
482
619
|
...n,
|
|
483
|
-
hidden
|
|
620
|
+
hidden,
|
|
484
621
|
selectable: false,
|
|
485
622
|
data: {
|
|
486
623
|
...(n.data as object),
|
|
487
|
-
...(
|
|
624
|
+
...(dimmed && { dimmed: true }),
|
|
488
625
|
},
|
|
489
626
|
};
|
|
490
627
|
}
|
|
@@ -497,22 +634,24 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
497
634
|
anyOpened: openedNodeIds != null,
|
|
498
635
|
anySelected: brightNodeIds != null,
|
|
499
636
|
});
|
|
500
|
-
|
|
637
|
+
const dimmed = hoverNodeIds ? !hoverNodeIds.has(n.id) : vis.dimmed;
|
|
638
|
+
const hidden = hoverNodeIds ? false : vis.hidden;
|
|
639
|
+
if (fileMatch === undefined && !isSelected && !dimmed) {
|
|
501
640
|
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
502
|
-
return { ...n, hidden
|
|
641
|
+
return { ...n, hidden, data: rest };
|
|
503
642
|
}
|
|
504
643
|
return {
|
|
505
644
|
...n,
|
|
506
|
-
hidden
|
|
645
|
+
hidden,
|
|
507
646
|
data: {
|
|
508
647
|
...(n.data as object),
|
|
509
648
|
...(fileMatch !== undefined && { fileMatch }),
|
|
510
649
|
...(isSelected && { isSelected }),
|
|
511
|
-
...(
|
|
650
|
+
...(dimmed && { dimmed: true }),
|
|
512
651
|
},
|
|
513
652
|
};
|
|
514
653
|
});
|
|
515
|
-
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
654
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, hoverNodeIds]);
|
|
516
655
|
|
|
517
656
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
518
657
|
const baseEdgesKey = useMemo(
|
|
@@ -533,7 +672,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
533
672
|
markerEnd: nextMarker,
|
|
534
673
|
};
|
|
535
674
|
};
|
|
536
|
-
if (openedEdgeIds || focusEdgeIds) {
|
|
675
|
+
if (openedEdgeIds || focusEdgeIds || hoverEdgeIds) {
|
|
537
676
|
return baseEdges.map((e) => {
|
|
538
677
|
const vis = flowElementVisibility({
|
|
539
678
|
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
@@ -541,12 +680,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
541
680
|
anyOpened: openedEdgeIds != null,
|
|
542
681
|
anySelected: focusEdgeIds != null,
|
|
543
682
|
});
|
|
544
|
-
|
|
683
|
+
const dimmed = hoverEdgeIds ? !hoverEdgeIds.has(e.id) : vis.dimmed;
|
|
684
|
+
const hidden = hoverEdgeIds ? false : vis.hidden;
|
|
685
|
+
return { ...paint(e, dimmed), hidden };
|
|
545
686
|
});
|
|
546
687
|
}
|
|
547
688
|
if (!selectedEdgeId) return baseEdges;
|
|
548
689
|
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
549
|
-
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
690
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, hoverEdgeIds]);
|
|
550
691
|
|
|
551
692
|
const onNodesChange = useCallback(
|
|
552
693
|
(changes: NodeChange[]) => {
|
|
@@ -672,6 +813,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
672
813
|
// components the edge attaches to (and therefore the edge line between them).
|
|
673
814
|
const fitFocusBounds = useCallback(
|
|
674
815
|
(ids: ReadonlySet<string>) => {
|
|
816
|
+
if (!zoomOnThroughlineFocus) return;
|
|
675
817
|
const nodeIds = new Set<string>();
|
|
676
818
|
for (const e of baseEdges) {
|
|
677
819
|
if (!ids.has(e.id)) continue;
|
|
@@ -685,7 +827,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
685
827
|
duration: 300,
|
|
686
828
|
});
|
|
687
829
|
},
|
|
688
|
-
[baseEdges, fitView],
|
|
830
|
+
[baseEdges, fitView, zoomOnThroughlineFocus],
|
|
689
831
|
);
|
|
690
832
|
|
|
691
833
|
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
@@ -693,34 +835,54 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
693
835
|
// reads as the narrative. No drawer: the code view only opens on a step
|
|
694
836
|
// click. A stale throughline drawer (from a previously focused flow's step)
|
|
695
837
|
// closes; an explicitly opened file drawer stays.
|
|
838
|
+
// In `dim` mode: clear step highlight and leave the full graph visible
|
|
839
|
+
// (no zoom / hide) — matching "step away from a hovered step".
|
|
696
840
|
const focusThroughlineEdges = useCallback(
|
|
697
841
|
(tl: SubsystemThroughline) => {
|
|
698
842
|
setSelected(null);
|
|
699
843
|
setSelectedEdgeId(null);
|
|
844
|
+
setHoveredThroughlineStep(null);
|
|
845
|
+
if (throughlineStepMode === 'dim') {
|
|
846
|
+
setFocusedStepIndex(null);
|
|
847
|
+
setFocusedThroughlineId(null);
|
|
848
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
700
851
|
setFocusedStepIndex(null);
|
|
701
852
|
setFocusedThroughlineId(tl.id);
|
|
702
853
|
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
703
854
|
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
704
855
|
},
|
|
705
|
-
[fitFocusBounds],
|
|
856
|
+
[fitFocusBounds, throughlineStepMode],
|
|
706
857
|
);
|
|
707
858
|
|
|
708
859
|
const clearThroughlineFocus = useCallback(() => {
|
|
709
860
|
setFocusedThroughlineId(null);
|
|
710
861
|
setFocusedStepIndex(null);
|
|
862
|
+
setHoveredThroughlineStep(null);
|
|
711
863
|
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
712
864
|
}, []);
|
|
713
865
|
|
|
714
866
|
// Focus a single step's edge on the canvas and open/scroll the throughline
|
|
715
867
|
// drawer to that step's snippet.
|
|
868
|
+
// In `dim` mode: only dim non-participants (same as hovering a step) —
|
|
869
|
+
// no camera move, no drawer, no hide.
|
|
716
870
|
const focusThroughlineStep = useCallback(
|
|
717
871
|
(tl: SubsystemThroughline, stepIndex: number) => {
|
|
718
872
|
const step = tl.steps[stepIndex];
|
|
719
873
|
if (!step) return;
|
|
720
874
|
setSelected(null);
|
|
721
875
|
setSelectedEdgeId(null);
|
|
876
|
+
if (throughlineStepMode === 'dim') {
|
|
877
|
+
setFocusedStepIndex(null);
|
|
878
|
+
setFocusedThroughlineId(null);
|
|
879
|
+
setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex });
|
|
880
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
722
883
|
setFocusedStepIndex(stepIndex);
|
|
723
884
|
setFocusedThroughlineId(tl.id);
|
|
885
|
+
setHoveredThroughlineStep(null);
|
|
724
886
|
fitFocusBounds(new Set([step.edgeId]));
|
|
725
887
|
if (renderThroughlineViewer) {
|
|
726
888
|
setDrawerTarget({
|
|
@@ -736,9 +898,45 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
736
898
|
});
|
|
737
899
|
}
|
|
738
900
|
},
|
|
739
|
-
[fitFocusBounds, renderThroughlineViewer],
|
|
901
|
+
[fitFocusBounds, renderThroughlineViewer, throughlineStepMode],
|
|
740
902
|
);
|
|
741
903
|
|
|
904
|
+
// Graph-only embeds: cycle throughline steps with hover-style dimming.
|
|
905
|
+
useEffect(() => {
|
|
906
|
+
if (!autoPlayThroughlines || !throughlines?.length || !layoutReady) return;
|
|
907
|
+
const playable = throughlines.filter((tl) => tl.steps.length > 0);
|
|
908
|
+
if (playable.length === 0) return;
|
|
909
|
+
|
|
910
|
+
let cancelled = false;
|
|
911
|
+
let tlIdx = 0;
|
|
912
|
+
let stepIdx = 0;
|
|
913
|
+
let timer: number | null = null;
|
|
914
|
+
const interval = Math.max(400, throughlineAutoPlayIntervalMs);
|
|
915
|
+
|
|
916
|
+
const tick = () => {
|
|
917
|
+
if (cancelled) return;
|
|
918
|
+
const tl = playable[tlIdx]!;
|
|
919
|
+
setSelected(null);
|
|
920
|
+
setSelectedEdgeId(null);
|
|
921
|
+
setFocusedThroughlineId(null);
|
|
922
|
+
setFocusedStepIndex(null);
|
|
923
|
+
setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: stepIdx });
|
|
924
|
+
stepIdx += 1;
|
|
925
|
+
if (stepIdx >= tl.steps.length) {
|
|
926
|
+
stepIdx = 0;
|
|
927
|
+
tlIdx = (tlIdx + 1) % playable.length;
|
|
928
|
+
}
|
|
929
|
+
timer = window.setTimeout(tick, interval);
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
tick();
|
|
933
|
+
return () => {
|
|
934
|
+
cancelled = true;
|
|
935
|
+
if (timer != null) window.clearTimeout(timer);
|
|
936
|
+
setHoveredThroughlineStep(null);
|
|
937
|
+
};
|
|
938
|
+
}, [autoPlayThroughlines, throughlines, throughlineAutoPlayIntervalMs, layoutReady]);
|
|
939
|
+
|
|
742
940
|
// Arrow keys step through the focused throughline once a step is active
|
|
743
941
|
// (sidebar click or drawer open). Ignores typing targets and chords.
|
|
744
942
|
useEffect(() => {
|
|
@@ -894,15 +1092,15 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
894
1092
|
);
|
|
895
1093
|
|
|
896
1094
|
return (
|
|
897
|
-
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
1095
|
+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row', userSelect: sidebarDrag ? 'none' : undefined }}>
|
|
898
1096
|
{/* Sidebar: scrollable title/description on top, files or flows pinned below.
|
|
899
1097
|
The description hides by default so files/flows get the room; the
|
|
900
1098
|
title-row toggle reveals it, and the lower panel yields back to 50%. */}
|
|
901
1099
|
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
902
1100
|
<div
|
|
903
1101
|
style={{
|
|
904
|
-
width:
|
|
905
|
-
minWidth:
|
|
1102
|
+
width: sidebarWidth,
|
|
1103
|
+
minWidth: sidebarWidth,
|
|
906
1104
|
borderRight: `1px solid ${theme.colors.border}`,
|
|
907
1105
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
908
1106
|
display: 'flex',
|
|
@@ -1045,7 +1243,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1045
1243
|
flex: 1,
|
|
1046
1244
|
minHeight: 0,
|
|
1047
1245
|
overflowY: 'auto',
|
|
1048
|
-
padding: '0
|
|
1246
|
+
padding: '0 0 12px',
|
|
1049
1247
|
}}
|
|
1050
1248
|
>
|
|
1051
1249
|
{throughlines.map((tl) => (
|
|
@@ -1054,11 +1252,21 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1054
1252
|
throughline={tl}
|
|
1055
1253
|
edges={edges}
|
|
1056
1254
|
collapsed={!expandedThroughlines.has(tl.id)}
|
|
1057
|
-
active={
|
|
1255
|
+
active={
|
|
1256
|
+
focusedThroughlineId === tl.id
|
|
1257
|
+
? { stepIndex: focusedStepIndex }
|
|
1258
|
+
: hoveredThroughlineStep?.throughlineId === tl.id
|
|
1259
|
+
? { stepIndex: hoveredThroughlineStep.stepIndex }
|
|
1260
|
+
: null
|
|
1261
|
+
}
|
|
1058
1262
|
onToggleCollapsed={toggleThroughlineCollapsed}
|
|
1059
1263
|
onFocusFlow={focusThroughlineEdges}
|
|
1060
1264
|
onClearFocus={clearThroughlineFocus}
|
|
1061
1265
|
onFocusStep={focusThroughlineStep}
|
|
1266
|
+
onHoverStep={(tl, i) =>
|
|
1267
|
+
setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: i })
|
|
1268
|
+
}
|
|
1269
|
+
onLeaveStep={() => setHoveredThroughlineStep(null)}
|
|
1062
1270
|
/>
|
|
1063
1271
|
))}
|
|
1064
1272
|
</div>
|
|
@@ -1104,6 +1312,23 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1104
1312
|
</div>
|
|
1105
1313
|
)}
|
|
1106
1314
|
|
|
1315
|
+
{/* Drag handle between sidebar and canvas — resize the left panel. */}
|
|
1316
|
+
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
|
|
1317
|
+
<div
|
|
1318
|
+
onMouseDown={onSidebarResizeStart}
|
|
1319
|
+
aria-label="Resize sidebar"
|
|
1320
|
+
title="Drag to resize"
|
|
1321
|
+
style={{
|
|
1322
|
+
width: 3,
|
|
1323
|
+
flexShrink: 0,
|
|
1324
|
+
cursor: 'col-resize',
|
|
1325
|
+
background: theme.colors.border,
|
|
1326
|
+
transition: 'background 120ms ease',
|
|
1327
|
+
zIndex: 1,
|
|
1328
|
+
}}
|
|
1329
|
+
/>
|
|
1330
|
+
)}
|
|
1331
|
+
|
|
1107
1332
|
{/* Graph area */}
|
|
1108
1333
|
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
1109
1334
|
{/* Canvas box — the edge-label overlay and legend anchor here, so
|
|
@@ -1213,10 +1438,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1213
1438
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
1214
1439
|
<Controls showZoom showFitView showInteractive />
|
|
1215
1440
|
</ReactFlow>
|
|
1216
|
-
{/* Graph
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
{graphTitle && (
|
|
1441
|
+
{/* Graph / throughline / step titles — non-interactive chips at the top
|
|
1442
|
+
of the canvas. Graph-only embeds use these without opening the sidebar. */}
|
|
1443
|
+
{(graphTitle || overlayThroughlineTitle || overlayThroughlineStep) && (
|
|
1220
1444
|
<div
|
|
1221
1445
|
style={{
|
|
1222
1446
|
position: 'absolute',
|
|
@@ -1224,23 +1448,124 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1224
1448
|
left: '50%',
|
|
1225
1449
|
transform: 'translateX(-50%)',
|
|
1226
1450
|
zIndex: 6,
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
fontSize: theme.fontSizes[3],
|
|
1233
|
-
fontWeight: 600,
|
|
1234
|
-
fontFamily: theme.fonts.monospace,
|
|
1235
|
-
color: theme.colors.text,
|
|
1236
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1237
|
-
border: `1px solid ${theme.colors.border}`,
|
|
1238
|
-
borderRadius: 6,
|
|
1239
|
-
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1451
|
+
display: 'flex',
|
|
1452
|
+
flexDirection: 'column',
|
|
1453
|
+
alignItems: 'center',
|
|
1454
|
+
gap: 6,
|
|
1455
|
+
maxWidth: '70%',
|
|
1240
1456
|
pointerEvents: 'none',
|
|
1241
1457
|
}}
|
|
1242
1458
|
>
|
|
1243
|
-
{graphTitle
|
|
1459
|
+
{graphTitle && (
|
|
1460
|
+
<div
|
|
1461
|
+
style={{
|
|
1462
|
+
maxWidth: '100%',
|
|
1463
|
+
overflow: 'hidden',
|
|
1464
|
+
textOverflow: 'ellipsis',
|
|
1465
|
+
whiteSpace: 'nowrap',
|
|
1466
|
+
padding: '6px 18px',
|
|
1467
|
+
fontSize: theme.fontSizes[3],
|
|
1468
|
+
fontWeight: 600,
|
|
1469
|
+
fontFamily: theme.fonts.monospace,
|
|
1470
|
+
color: theme.colors.text,
|
|
1471
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1472
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1473
|
+
borderRadius: 6,
|
|
1474
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1475
|
+
}}
|
|
1476
|
+
>
|
|
1477
|
+
{graphTitle}
|
|
1478
|
+
</div>
|
|
1479
|
+
)}
|
|
1480
|
+
{overlayThroughlineTitle && (
|
|
1481
|
+
<div
|
|
1482
|
+
style={{
|
|
1483
|
+
maxWidth: '100%',
|
|
1484
|
+
minWidth: overlayThroughlineStep ? 160 : undefined,
|
|
1485
|
+
display: 'flex',
|
|
1486
|
+
flexDirection: 'column',
|
|
1487
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1488
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1489
|
+
borderRadius: 6,
|
|
1490
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1491
|
+
overflow: 'hidden',
|
|
1492
|
+
opacity: 0.95,
|
|
1493
|
+
}}
|
|
1494
|
+
aria-label={
|
|
1495
|
+
overlayThroughlineStep
|
|
1496
|
+
? `${overlayThroughlineTitle}, step ${overlayThroughlineStep.index} of ${overlayThroughlineStep.total}`
|
|
1497
|
+
: overlayThroughlineTitle
|
|
1498
|
+
}
|
|
1499
|
+
>
|
|
1500
|
+
<div
|
|
1501
|
+
style={{
|
|
1502
|
+
padding: '5px 14px',
|
|
1503
|
+
fontSize: theme.fontSizes[2] ?? theme.fontSizes[1],
|
|
1504
|
+
fontWeight: 600,
|
|
1505
|
+
fontFamily: theme.fonts.monospace,
|
|
1506
|
+
color: theme.colors.text,
|
|
1507
|
+
overflow: 'hidden',
|
|
1508
|
+
textOverflow: 'ellipsis',
|
|
1509
|
+
whiteSpace: 'nowrap',
|
|
1510
|
+
textAlign: 'center',
|
|
1511
|
+
}}
|
|
1512
|
+
>
|
|
1513
|
+
{overlayThroughlineTitle}
|
|
1514
|
+
</div>
|
|
1515
|
+
{overlayThroughlineStep && overlayThroughlineStep.total > 0 && (
|
|
1516
|
+
<div
|
|
1517
|
+
style={{
|
|
1518
|
+
display: 'flex',
|
|
1519
|
+
gap: 3,
|
|
1520
|
+
padding: '0 6px 5px',
|
|
1521
|
+
}}
|
|
1522
|
+
aria-hidden="true"
|
|
1523
|
+
>
|
|
1524
|
+
{Array.from({ length: overlayThroughlineStep.total }, (_, i) => {
|
|
1525
|
+
const n = i + 1;
|
|
1526
|
+
const active = n === overlayThroughlineStep.index;
|
|
1527
|
+
const done = n < overlayThroughlineStep.index;
|
|
1528
|
+
return (
|
|
1529
|
+
<span
|
|
1530
|
+
key={n}
|
|
1531
|
+
style={{
|
|
1532
|
+
flex: 1,
|
|
1533
|
+
height: 2,
|
|
1534
|
+
borderRadius: 1,
|
|
1535
|
+
background: active
|
|
1536
|
+
? (theme.colors.accent ?? theme.colors.primary ?? theme.colors.text)
|
|
1537
|
+
: done
|
|
1538
|
+
? (theme.colors.textSecondary ?? theme.colors.text)
|
|
1539
|
+
: (theme.colors.border ?? 'rgba(127,127,127,0.45)'),
|
|
1540
|
+
opacity: active ? 1 : done ? 0.75 : 0.4,
|
|
1541
|
+
transition: 'opacity 120ms ease, background 120ms ease',
|
|
1542
|
+
}}
|
|
1543
|
+
/>
|
|
1544
|
+
);
|
|
1545
|
+
})}
|
|
1546
|
+
</div>
|
|
1547
|
+
)}
|
|
1548
|
+
</div>
|
|
1549
|
+
)}
|
|
1550
|
+
{overlayThroughlineStep?.annotation && (
|
|
1551
|
+
<div
|
|
1552
|
+
style={{
|
|
1553
|
+
maxWidth: '100%',
|
|
1554
|
+
padding: '6px 14px',
|
|
1555
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1556
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1557
|
+
borderRadius: 6,
|
|
1558
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1559
|
+
textAlign: 'center',
|
|
1560
|
+
fontSize: theme.fontSizes[0],
|
|
1561
|
+
fontFamily: theme.fonts.body,
|
|
1562
|
+
color: theme.colors.textMuted ?? theme.colors.textSecondary,
|
|
1563
|
+
lineHeight: 1.35,
|
|
1564
|
+
}}
|
|
1565
|
+
>
|
|
1566
|
+
{overlayThroughlineStep.annotation}
|
|
1567
|
+
</div>
|
|
1568
|
+
)}
|
|
1244
1569
|
</div>
|
|
1245
1570
|
)}
|
|
1246
1571
|
{/* Selected-component declaration — floating card over the canvas
|
|
@@ -1388,6 +1713,8 @@ function ThroughlineFlow({
|
|
|
1388
1713
|
onFocusFlow,
|
|
1389
1714
|
onClearFocus,
|
|
1390
1715
|
onFocusStep,
|
|
1716
|
+
onHoverStep,
|
|
1717
|
+
onLeaveStep,
|
|
1391
1718
|
}: {
|
|
1392
1719
|
throughline: SubsystemThroughline;
|
|
1393
1720
|
edges: SubsystemComponentEdge[];
|
|
@@ -1398,6 +1725,8 @@ function ThroughlineFlow({
|
|
|
1398
1725
|
onFocusFlow: (tl: SubsystemThroughline) => void;
|
|
1399
1726
|
onClearFocus: () => void;
|
|
1400
1727
|
onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1728
|
+
onHoverStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1729
|
+
onLeaveStep: () => void;
|
|
1401
1730
|
}) {
|
|
1402
1731
|
const { theme } = useTheme();
|
|
1403
1732
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -1406,8 +1735,52 @@ function ThroughlineFlow({
|
|
|
1406
1735
|
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
1407
1736
|
const [headerHover, setHeaderHover] = useState(false);
|
|
1408
1737
|
const [closeHover, setCloseHover] = useState(false);
|
|
1738
|
+
const [playHover, setPlayHover] = useState(false);
|
|
1409
1739
|
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
1410
1740
|
const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
1741
|
+
// Autoplay: stepping through the flow's steps with a pause between each.
|
|
1742
|
+
const [playing, setPlaying] = useState(false);
|
|
1743
|
+
const playTimerRef = useRef<number | null>(null);
|
|
1744
|
+
|
|
1745
|
+
const stopPlaying = useCallback(() => {
|
|
1746
|
+
if (playTimerRef.current != null) {
|
|
1747
|
+
window.clearTimeout(playTimerRef.current);
|
|
1748
|
+
playTimerRef.current = null;
|
|
1749
|
+
}
|
|
1750
|
+
setPlaying(false);
|
|
1751
|
+
}, []);
|
|
1752
|
+
|
|
1753
|
+
// Clear any pending timer on unmount.
|
|
1754
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1755
|
+
useEffect(() => () => stopPlaying(), []);
|
|
1756
|
+
|
|
1757
|
+
const startPlaying = useCallback(() => {
|
|
1758
|
+
if (collapsed) onToggleCollapsed(throughline.id);
|
|
1759
|
+
if (active === null || active.stepIndex !== null) onFocusFlow(throughline);
|
|
1760
|
+
const stepCount = throughline.steps.length;
|
|
1761
|
+
if (stepCount === 0) return;
|
|
1762
|
+
setPlaying(true);
|
|
1763
|
+
let i = 0;
|
|
1764
|
+
const tick = () => {
|
|
1765
|
+
if (i >= stepCount) {
|
|
1766
|
+
playTimerRef.current = null;
|
|
1767
|
+
setPlaying(false);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
onFocusStep(throughline, i);
|
|
1771
|
+
i += 1;
|
|
1772
|
+
playTimerRef.current = window.setTimeout(tick, THROUGHLINE_PLAY_PAUSE_MS);
|
|
1773
|
+
};
|
|
1774
|
+
tick();
|
|
1775
|
+
}, [collapsed, active, onToggleCollapsed, onFocusFlow, throughline, onFocusStep]);
|
|
1776
|
+
|
|
1777
|
+
const togglePlay = useCallback(() => {
|
|
1778
|
+
if (playing) {
|
|
1779
|
+
stopPlaying();
|
|
1780
|
+
} else {
|
|
1781
|
+
startPlaying();
|
|
1782
|
+
}
|
|
1783
|
+
}, [playing, stopPlaying, startPlaying]);
|
|
1411
1784
|
|
|
1412
1785
|
// Keep DOM focus on the active step so the browser focus ring (and
|
|
1413
1786
|
// subsequent arrow keys) follow arrow navigation, not the originally
|
|
@@ -1418,7 +1791,7 @@ function ThroughlineFlow({
|
|
|
1418
1791
|
}, [active?.stepIndex]);
|
|
1419
1792
|
|
|
1420
1793
|
return (
|
|
1421
|
-
<div
|
|
1794
|
+
<div>
|
|
1422
1795
|
<div
|
|
1423
1796
|
onMouseEnter={() => setHeaderHover(true)}
|
|
1424
1797
|
onMouseLeave={() => setHeaderHover(false)}
|
|
@@ -1426,7 +1799,6 @@ function ThroughlineFlow({
|
|
|
1426
1799
|
display: 'flex',
|
|
1427
1800
|
alignItems: 'center',
|
|
1428
1801
|
gap: 4,
|
|
1429
|
-
borderRadius: 6,
|
|
1430
1802
|
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
1431
1803
|
transition: 'background 120ms ease',
|
|
1432
1804
|
}}
|
|
@@ -1449,8 +1821,7 @@ function ThroughlineFlow({
|
|
|
1449
1821
|
display: 'flex',
|
|
1450
1822
|
alignItems: 'center',
|
|
1451
1823
|
minWidth: 0,
|
|
1452
|
-
padding: '
|
|
1453
|
-
borderRadius: 6,
|
|
1824
|
+
padding: '10px 8px',
|
|
1454
1825
|
border: 'none',
|
|
1455
1826
|
background: 'transparent',
|
|
1456
1827
|
textAlign: 'left',
|
|
@@ -1472,6 +1843,35 @@ function ThroughlineFlow({
|
|
|
1472
1843
|
</span>
|
|
1473
1844
|
</button>
|
|
1474
1845
|
{!collapsed && (
|
|
1846
|
+
<>
|
|
1847
|
+
<button
|
|
1848
|
+
type="button"
|
|
1849
|
+
aria-label={playing ? `Pause ${throughline.title} autoplay` : `Play ${throughline.title}`}
|
|
1850
|
+
title={playing ? 'Pause' : 'Play through steps'}
|
|
1851
|
+
onMouseEnter={() => setPlayHover(true)}
|
|
1852
|
+
onMouseLeave={() => setPlayHover(false)}
|
|
1853
|
+
onClick={(e) => {
|
|
1854
|
+
e.stopPropagation();
|
|
1855
|
+
togglePlay();
|
|
1856
|
+
}}
|
|
1857
|
+
style={{
|
|
1858
|
+
display: 'inline-flex',
|
|
1859
|
+
alignItems: 'center',
|
|
1860
|
+
justifyContent: 'center',
|
|
1861
|
+
flexShrink: 0,
|
|
1862
|
+
width: 22,
|
|
1863
|
+
height: 22,
|
|
1864
|
+
padding: 0,
|
|
1865
|
+
border: 'none',
|
|
1866
|
+
borderRadius: 4,
|
|
1867
|
+
background: playing || playHover ? theme.colors.border : 'transparent',
|
|
1868
|
+
color: playing || playHover ? theme.colors.text : muted,
|
|
1869
|
+
cursor: 'pointer',
|
|
1870
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
1871
|
+
}}
|
|
1872
|
+
>
|
|
1873
|
+
{playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
|
|
1874
|
+
</button>
|
|
1475
1875
|
<button
|
|
1476
1876
|
type="button"
|
|
1477
1877
|
aria-label={`Close ${throughline.title}`}
|
|
@@ -1501,6 +1901,7 @@ function ThroughlineFlow({
|
|
|
1501
1901
|
>
|
|
1502
1902
|
<X size={12} strokeWidth={2} />
|
|
1503
1903
|
</button>
|
|
1904
|
+
</>
|
|
1504
1905
|
)}
|
|
1505
1906
|
</div>
|
|
1506
1907
|
{!collapsed && (
|
|
@@ -1517,15 +1918,21 @@ function ThroughlineFlow({
|
|
|
1517
1918
|
stepButtonRefs.current[i] = el;
|
|
1518
1919
|
}}
|
|
1519
1920
|
type="button"
|
|
1520
|
-
onMouseEnter={() =>
|
|
1521
|
-
|
|
1921
|
+
onMouseEnter={() => {
|
|
1922
|
+
setHoveredStep(i);
|
|
1923
|
+
onHoverStep(throughline, i);
|
|
1924
|
+
}}
|
|
1925
|
+
onMouseLeave={() => {
|
|
1926
|
+
setHoveredStep(null);
|
|
1927
|
+
onLeaveStep();
|
|
1928
|
+
}}
|
|
1522
1929
|
onClick={() => onFocusStep(throughline, i)}
|
|
1523
1930
|
style={{
|
|
1524
1931
|
display: 'flex',
|
|
1525
1932
|
alignItems: 'center',
|
|
1526
1933
|
gap: 8,
|
|
1527
1934
|
minWidth: 0,
|
|
1528
|
-
padding: '
|
|
1935
|
+
padding: '8px 8px 8px 12px',
|
|
1529
1936
|
textAlign: 'left',
|
|
1530
1937
|
borderRadius: 6,
|
|
1531
1938
|
border: 'none',
|
|
@@ -1554,9 +1961,9 @@ function ThroughlineFlow({
|
|
|
1554
1961
|
overflow: 'hidden',
|
|
1555
1962
|
textOverflow: 'ellipsis',
|
|
1556
1963
|
whiteSpace: 'nowrap',
|
|
1557
|
-
fontSize: theme.fontSizes[
|
|
1964
|
+
fontSize: theme.fontSizes[1],
|
|
1558
1965
|
fontFamily: theme.fonts.monospace,
|
|
1559
|
-
color:
|
|
1966
|
+
color: theme.colors.text,
|
|
1560
1967
|
}}
|
|
1561
1968
|
>
|
|
1562
1969
|
{step.symbol}
|
|
@@ -1586,7 +1993,7 @@ function ThroughlineFlow({
|
|
|
1586
1993
|
whiteSpace: 'nowrap',
|
|
1587
1994
|
fontSize: theme.fontSizes[0],
|
|
1588
1995
|
fontFamily: theme.fonts.monospace,
|
|
1589
|
-
color:
|
|
1996
|
+
color: theme.colors.text,
|
|
1590
1997
|
}}
|
|
1591
1998
|
>
|
|
1592
1999
|
{step.file.split('/').pop()}
|