@principal-ai/subsystems-react 0.17.0 → 0.19.0
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/index.d.ts +7 -44
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -32
- package/dist/index.js.map +1 -1
- 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 +329 -68
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +9 -199
- package/src/subsystem/SubsystemComponentGraph.tsx +396 -50
- package/src/components/ConfigurationSelector.tsx +0 -133
- package/src/components/GraphRenderer.tsx +0 -3195
- package/src/components/MultiCanvasRenderer.tsx +0 -295
- package/src/components/NodeTooltip.tsx +0 -279
- package/src/components/PendingChanges.test.tsx +0 -375
- package/src/components/SelectionSidebar.tsx +0 -352
- package/src/components/SequenceDiagramRenderer.tsx +0 -1156
- package/src/components/WorkflowSequenceDiagram.tsx +0 -270
- package/src/components/dashboard/DashboardRenderer.tsx +0 -317
- package/src/components/dashboard/MetricPanel.tsx +0 -254
- package/src/components/dashboard/MockDataProvider.ts +0 -330
- package/src/components/dashboard/components/BarChart.tsx +0 -299
- package/src/components/dashboard/components/LineChart.tsx +0 -279
- package/src/components/dashboard/components/MetricCard.tsx +0 -270
- package/src/components/dashboard/components/SourceLink.tsx +0 -63
- package/src/components/dashboard/components/TimeRangeSelector.tsx +0 -280
- package/src/components/dashboard/components/index.ts +0 -5
- package/src/components/dashboard/index.ts +0 -47
- package/src/components/dashboard/types.ts +0 -126
- package/src/components/state-view/PipelineView.tsx +0 -347
- package/src/components/state-view/index.ts +0 -14
- package/src/components/state-view/types.ts +0 -261
- package/src/components/state-view/useStateView.ts +0 -205
- package/src/contexts/GraphEditContext.tsx +0 -27
- package/src/contexts/TooltipPortalContext.ts +0 -8
- package/src/edges/CustomEdge.tsx +0 -402
- package/src/edges/GenericEdge.tsx +0 -36
- package/src/hooks/usePathBasedEvents.ts +0 -192
- package/src/hooks/useSequenceLayout.ts +0 -543
- package/src/hooks/useUndoRedo.ts +0 -144
- package/src/nodes/CustomNode.tsx +0 -1231
- package/src/nodes/GenericNode.tsx +0 -55
- package/src/nodes/otel/OtelBoundaryNode.tsx +0 -224
- package/src/nodes/otel/OtelEventNode.tsx +0 -225
- package/src/nodes/otel/OtelResourceNode.tsx +0 -251
- package/src/nodes/otel/OtelScopeNode.tsx +0 -221
- package/src/nodes/otel/OtelSpanConventionNode.tsx +0 -295
- package/src/nodes/otel/index.ts +0 -23
- package/src/nodes/otel/shared/NodeBadges.tsx +0 -295
- package/src/nodes/otel/shared/NodeContent.tsx +0 -204
- package/src/nodes/otel/shared/index.ts +0 -8
- package/src/nodes/otel/shared/types.ts +0 -138
- package/src/nodes/otel/shared/useNodeBehavior.ts +0 -114
- package/src/stories/Audit/BoundaryNodes.stories.tsx +0 -385
- package/src/stories/Audit/CanvasEdgeTypes.stories.tsx +0 -1448
- package/src/stories/Audit/CanvasNodeTypes.stories.tsx +0 -1049
- package/src/stories/Audit/ColorPriority.stories.tsx +0 -895
- package/src/stories/Audit/DiamondBadges.stories.tsx +0 -629
- package/src/stories/Audit/NodeDimensionsTesting.stories.tsx +0 -724
- package/src/stories/Audit/NodeFieldsAudit.stories.tsx +0 -996
- package/src/stories/Audit/NodeFontSizeTesting.stories.tsx +0 -715
- package/src/stories/Audit/NodeShapes.stories.tsx +0 -1208
- package/src/stories/Audit/WideNodesTesting.stories.tsx +0 -1045
- package/src/stories/Components/DashboardRenderer.stories.tsx +0 -263
- package/src/stories/Components/GraphRenderer.stories.tsx +0 -3236
- package/src/stories/Components/MultiCanvasRenderer.stories.tsx +0 -370
- package/src/stories/Components/SequenceDiagram.stories.tsx +0 -255
- package/src/stories/Components/StateView.stories.tsx +0 -417
- package/src/stories/Components/TraceViewer.stories.tsx +0 -442
- package/src/stories/Components/WorkflowSequenceDiagram.stories.tsx +0 -802
- package/src/stories/Features/AnimationWorkshop.stories.tsx +0 -727
- package/src/stories/Features/ChangeTypeVisual.stories.tsx +0 -67
- package/src/stories/Features/EventDrivenAnimations.stories.tsx +0 -513
- package/src/stories/Features/MultiConfig.stories.tsx +0 -539
- package/src/stories/Layout/ElkEdgeRouting.stories.tsx +0 -539
- package/src/stories/Layout/GraphOrientation.stories.tsx +0 -500
- package/src/stories/Layout/MultiDirectionalConnections.stories.tsx +0 -352
- package/src/stories/Nodes/NodeDefinitionComparison.stories.tsx +0 -560
- package/src/stories/Nodes/NodeNameFallbacks.stories.tsx +0 -491
- package/src/stories/Nodes/NumericColors.stories.tsx +0 -440
- package/src/stories/OTEL/EventNameDisplay.stories.tsx +0 -694
- package/src/stories/OTEL/EventNameFallback.stories.tsx +0 -578
- package/src/stories/OTEL/OtelComponents.stories.tsx +0 -422
- package/src/stories/OTEL/OtelNodeTypesPrototype.stories.tsx +0 -806
- package/src/stories/OTEL/RawEventCards.stories.tsx +0 -57
- package/src/stories/OTEL/SpanBadges.stories.tsx +0 -840
- package/src/stories/OTEL/ValidationEventsCanvas.stories.tsx +0 -209
- package/src/stories/RealWorld/BacklogMdCanvases.stories.tsx +0 -721
- package/src/stories/RealWorld/FileCitySequence.stories.tsx +0 -516
- package/src/stories/RealWorld/RealCanvasFiles.stories.tsx +0 -336
- package/src/stories/__traces__/test-run.canvas.json +0 -241
- package/src/stories/data/activity-feed-analytics.dashboard.json +0 -300
- package/src/stories/data/file-city-images-transformed.otel.canvas.json +0 -295
- package/src/stories/data/file-city-workflows.json +0 -269
- package/src/stories/data/graph-converter-test-execution.json +0 -443
- package/src/stories/data/graph-converter-test.workflow.json +0 -105
- package/src/stories/data/graph-converter-validated-execution.json +0 -58
- package/src/utils/animationMapping.ts +0 -166
- package/src/utils/canvasBounds.ts +0 -146
- package/src/utils/graphConverter.ts +0 -148
- package/src/utils/iconResolver.tsx +0 -53
- package/src/utils/orientationUtils.ts +0 -73
|
@@ -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',
|
|
@@ -233,6 +266,46 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
233
266
|
return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
|
|
234
267
|
}, [drawerTarget, throughlines]);
|
|
235
268
|
|
|
269
|
+
// Throughline shown on the canvas title chip (focus or hover/autoplay highlight).
|
|
270
|
+
const overlayThroughlineTitle = useMemo(() => {
|
|
271
|
+
if (!showThroughlineTitle || !throughlines?.length) return null;
|
|
272
|
+
const id = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
|
|
273
|
+
if (!id) return null;
|
|
274
|
+
return throughlines.find((t) => t.id === id)?.title ?? null;
|
|
275
|
+
}, [
|
|
276
|
+
showThroughlineTitle,
|
|
277
|
+
throughlines,
|
|
278
|
+
focusedThroughlineId,
|
|
279
|
+
hoveredThroughlineStep,
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
// Active step for the bottom-of-title progress + annotation chip.
|
|
283
|
+
const overlayThroughlineStep = useMemo(() => {
|
|
284
|
+
if (!showThroughlineTitle || !throughlines?.length) return null;
|
|
285
|
+
const tlId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId ?? null;
|
|
286
|
+
let stepIndex: number | null = null;
|
|
287
|
+
if (focusedThroughlineId != null) {
|
|
288
|
+
stepIndex = focusedStepIndex;
|
|
289
|
+
} else if (hoveredThroughlineStep != null) {
|
|
290
|
+
stepIndex = hoveredThroughlineStep.stepIndex;
|
|
291
|
+
}
|
|
292
|
+
if (tlId == null || stepIndex == null) return null;
|
|
293
|
+
const tl = throughlines.find((t) => t.id === tlId);
|
|
294
|
+
const step = tl?.steps[stepIndex];
|
|
295
|
+
if (!step || !tl) return null;
|
|
296
|
+
return {
|
|
297
|
+
index: stepIndex + 1,
|
|
298
|
+
total: tl.steps.length,
|
|
299
|
+
annotation: step.annotation,
|
|
300
|
+
};
|
|
301
|
+
}, [
|
|
302
|
+
showThroughlineTitle,
|
|
303
|
+
throughlines,
|
|
304
|
+
focusedThroughlineId,
|
|
305
|
+
focusedStepIndex,
|
|
306
|
+
hoveredThroughlineStep,
|
|
307
|
+
]);
|
|
308
|
+
|
|
236
309
|
const drawerTitle = useMemo(() => {
|
|
237
310
|
if (!drawerTarget) return null;
|
|
238
311
|
if (drawerTarget.kind === 'file') return drawerTarget.file;
|
|
@@ -398,11 +471,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
398
471
|
return new Set(tl.steps.map((s) => s.edgeId));
|
|
399
472
|
}, [throughlines, focusedThroughlineId, focusedStepIndex]);
|
|
400
473
|
|
|
401
|
-
// 1-based step numbers per edge of the
|
|
402
|
-
// in more than one step
|
|
474
|
+
// 1-based step numbers per edge of the active flow (focused or
|
|
475
|
+
// hover/autoplay-highlighted). An edge can appear in more than one step.
|
|
403
476
|
const selectedFlowStepNos = useMemo(() => {
|
|
404
|
-
|
|
405
|
-
|
|
477
|
+
const activeId = focusedThroughlineId ?? hoveredThroughlineStep?.throughlineId;
|
|
478
|
+
if (activeId == null || !throughlines) return null;
|
|
479
|
+
const tl = throughlines.find((t) => t.id === activeId);
|
|
406
480
|
if (!tl) return null;
|
|
407
481
|
const map = new Map<string, number[]>();
|
|
408
482
|
tl.steps.forEach((s, i) => {
|
|
@@ -411,7 +485,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
411
485
|
map.set(s.edgeId, list);
|
|
412
486
|
});
|
|
413
487
|
return map;
|
|
414
|
-
}, [throughlines, focusedThroughlineId]);
|
|
488
|
+
}, [throughlines, focusedThroughlineId, hoveredThroughlineStep]);
|
|
415
489
|
|
|
416
490
|
// Union of every expanded (opened) throughline's edges — the visible set.
|
|
417
491
|
const openedEdgeIds = useMemo(() => {
|
|
@@ -434,6 +508,19 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
434
508
|
}
|
|
435
509
|
return ids.size > 0 ? ids : null;
|
|
436
510
|
};
|
|
511
|
+
// Edge/nodes involved in the hovered step — dim everything else.
|
|
512
|
+
const hoverEdgeIds = useMemo(() => {
|
|
513
|
+
if (!hoveredThroughlineStep || !throughlines) return null;
|
|
514
|
+
const tl = throughlines.find((t) => t.id === hoveredThroughlineStep.throughlineId);
|
|
515
|
+
if (!tl) return null;
|
|
516
|
+
const step = tl.steps[hoveredThroughlineStep.stepIndex];
|
|
517
|
+
return step ? new Set([step.edgeId]) : null;
|
|
518
|
+
}, [throughlines, hoveredThroughlineStep]);
|
|
519
|
+
const hoverNodeIds = useMemo(
|
|
520
|
+
() => endpointsOf(hoverEdgeIds),
|
|
521
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
522
|
+
[baseEdges, hoverEdgeIds],
|
|
523
|
+
);
|
|
437
524
|
const openedNodeIds = useMemo(
|
|
438
525
|
() => endpointsOf(openedEdgeIds),
|
|
439
526
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -467,6 +554,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
467
554
|
// `isSelected` rides in data because the node's stopPropagation() keeps
|
|
468
555
|
// React Flow's own selection state from updating.
|
|
469
556
|
const dispNodes = useMemo(() => {
|
|
557
|
+
// While a step is hovered, only the hovered step's endpoints stay bright;
|
|
558
|
+
// everything else is dimmed (never hidden) so the step's context survives.
|
|
470
559
|
return xyflowNodesBase.map((n) => {
|
|
471
560
|
// Boundary frames follow their members: hidden when no member is
|
|
472
561
|
// visible, dimmed when members are dimmed. Never selectable.
|
|
@@ -478,13 +567,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
478
567
|
anyOpened: openedNodeIds != null,
|
|
479
568
|
anySelected: brightNodeIds != null,
|
|
480
569
|
});
|
|
570
|
+
const dimmed = hoverNodeIds
|
|
571
|
+
? !memberIds.some((id) => hoverNodeIds.has(id))
|
|
572
|
+
: vis.dimmed;
|
|
573
|
+
const hidden = hoverNodeIds ? false : vis.hidden;
|
|
481
574
|
return {
|
|
482
575
|
...n,
|
|
483
|
-
hidden
|
|
576
|
+
hidden,
|
|
484
577
|
selectable: false,
|
|
485
578
|
data: {
|
|
486
579
|
...(n.data as object),
|
|
487
|
-
...(
|
|
580
|
+
...(dimmed && { dimmed: true }),
|
|
488
581
|
},
|
|
489
582
|
};
|
|
490
583
|
}
|
|
@@ -497,22 +590,24 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
497
590
|
anyOpened: openedNodeIds != null,
|
|
498
591
|
anySelected: brightNodeIds != null,
|
|
499
592
|
});
|
|
500
|
-
|
|
593
|
+
const dimmed = hoverNodeIds ? !hoverNodeIds.has(n.id) : vis.dimmed;
|
|
594
|
+
const hidden = hoverNodeIds ? false : vis.hidden;
|
|
595
|
+
if (fileMatch === undefined && !isSelected && !dimmed) {
|
|
501
596
|
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
|
|
502
|
-
return { ...n, hidden
|
|
597
|
+
return { ...n, hidden, data: rest };
|
|
503
598
|
}
|
|
504
599
|
return {
|
|
505
600
|
...n,
|
|
506
|
-
hidden
|
|
601
|
+
hidden,
|
|
507
602
|
data: {
|
|
508
603
|
...(n.data as object),
|
|
509
604
|
...(fileMatch !== undefined && { fileMatch }),
|
|
510
605
|
...(isSelected && { isSelected }),
|
|
511
|
-
...(
|
|
606
|
+
...(dimmed && { dimmed: true }),
|
|
512
607
|
},
|
|
513
608
|
};
|
|
514
609
|
});
|
|
515
|
-
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
|
|
610
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, hoverNodeIds]);
|
|
516
611
|
|
|
517
612
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
518
613
|
const baseEdgesKey = useMemo(
|
|
@@ -533,7 +628,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
533
628
|
markerEnd: nextMarker,
|
|
534
629
|
};
|
|
535
630
|
};
|
|
536
|
-
if (openedEdgeIds || focusEdgeIds) {
|
|
631
|
+
if (openedEdgeIds || focusEdgeIds || hoverEdgeIds) {
|
|
537
632
|
return baseEdges.map((e) => {
|
|
538
633
|
const vis = flowElementVisibility({
|
|
539
634
|
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
@@ -541,12 +636,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
541
636
|
anyOpened: openedEdgeIds != null,
|
|
542
637
|
anySelected: focusEdgeIds != null,
|
|
543
638
|
});
|
|
544
|
-
|
|
639
|
+
const dimmed = hoverEdgeIds ? !hoverEdgeIds.has(e.id) : vis.dimmed;
|
|
640
|
+
const hidden = hoverEdgeIds ? false : vis.hidden;
|
|
641
|
+
return { ...paint(e, dimmed), hidden };
|
|
545
642
|
});
|
|
546
643
|
}
|
|
547
644
|
if (!selectedEdgeId) return baseEdges;
|
|
548
645
|
return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
|
|
549
|
-
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
|
|
646
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, hoverEdgeIds]);
|
|
550
647
|
|
|
551
648
|
const onNodesChange = useCallback(
|
|
552
649
|
(changes: NodeChange[]) => {
|
|
@@ -672,6 +769,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
672
769
|
// components the edge attaches to (and therefore the edge line between them).
|
|
673
770
|
const fitFocusBounds = useCallback(
|
|
674
771
|
(ids: ReadonlySet<string>) => {
|
|
772
|
+
if (!zoomOnThroughlineFocus) return;
|
|
675
773
|
const nodeIds = new Set<string>();
|
|
676
774
|
for (const e of baseEdges) {
|
|
677
775
|
if (!ids.has(e.id)) continue;
|
|
@@ -685,7 +783,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
685
783
|
duration: 300,
|
|
686
784
|
});
|
|
687
785
|
},
|
|
688
|
-
[baseEdges, fitView],
|
|
786
|
+
[baseEdges, fitView, zoomOnThroughlineFocus],
|
|
689
787
|
);
|
|
690
788
|
|
|
691
789
|
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
@@ -693,34 +791,54 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
693
791
|
// reads as the narrative. No drawer: the code view only opens on a step
|
|
694
792
|
// click. A stale throughline drawer (from a previously focused flow's step)
|
|
695
793
|
// closes; an explicitly opened file drawer stays.
|
|
794
|
+
// In `dim` mode: clear step highlight and leave the full graph visible
|
|
795
|
+
// (no zoom / hide) — matching "step away from a hovered step".
|
|
696
796
|
const focusThroughlineEdges = useCallback(
|
|
697
797
|
(tl: SubsystemThroughline) => {
|
|
698
798
|
setSelected(null);
|
|
699
799
|
setSelectedEdgeId(null);
|
|
800
|
+
setHoveredThroughlineStep(null);
|
|
801
|
+
if (throughlineStepMode === 'dim') {
|
|
802
|
+
setFocusedStepIndex(null);
|
|
803
|
+
setFocusedThroughlineId(null);
|
|
804
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
700
807
|
setFocusedStepIndex(null);
|
|
701
808
|
setFocusedThroughlineId(tl.id);
|
|
702
809
|
fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
|
|
703
810
|
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
704
811
|
},
|
|
705
|
-
[fitFocusBounds],
|
|
812
|
+
[fitFocusBounds, throughlineStepMode],
|
|
706
813
|
);
|
|
707
814
|
|
|
708
815
|
const clearThroughlineFocus = useCallback(() => {
|
|
709
816
|
setFocusedThroughlineId(null);
|
|
710
817
|
setFocusedStepIndex(null);
|
|
818
|
+
setHoveredThroughlineStep(null);
|
|
711
819
|
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
712
820
|
}, []);
|
|
713
821
|
|
|
714
822
|
// Focus a single step's edge on the canvas and open/scroll the throughline
|
|
715
823
|
// drawer to that step's snippet.
|
|
824
|
+
// In `dim` mode: only dim non-participants (same as hovering a step) —
|
|
825
|
+
// no camera move, no drawer, no hide.
|
|
716
826
|
const focusThroughlineStep = useCallback(
|
|
717
827
|
(tl: SubsystemThroughline, stepIndex: number) => {
|
|
718
828
|
const step = tl.steps[stepIndex];
|
|
719
829
|
if (!step) return;
|
|
720
830
|
setSelected(null);
|
|
721
831
|
setSelectedEdgeId(null);
|
|
832
|
+
if (throughlineStepMode === 'dim') {
|
|
833
|
+
setFocusedStepIndex(null);
|
|
834
|
+
setFocusedThroughlineId(null);
|
|
835
|
+
setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex });
|
|
836
|
+
setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
722
839
|
setFocusedStepIndex(stepIndex);
|
|
723
840
|
setFocusedThroughlineId(tl.id);
|
|
841
|
+
setHoveredThroughlineStep(null);
|
|
724
842
|
fitFocusBounds(new Set([step.edgeId]));
|
|
725
843
|
if (renderThroughlineViewer) {
|
|
726
844
|
setDrawerTarget({
|
|
@@ -736,9 +854,45 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
736
854
|
});
|
|
737
855
|
}
|
|
738
856
|
},
|
|
739
|
-
[fitFocusBounds, renderThroughlineViewer],
|
|
857
|
+
[fitFocusBounds, renderThroughlineViewer, throughlineStepMode],
|
|
740
858
|
);
|
|
741
859
|
|
|
860
|
+
// Graph-only embeds: cycle throughline steps with hover-style dimming.
|
|
861
|
+
useEffect(() => {
|
|
862
|
+
if (!autoPlayThroughlines || !throughlines?.length || !layoutReady) return;
|
|
863
|
+
const playable = throughlines.filter((tl) => tl.steps.length > 0);
|
|
864
|
+
if (playable.length === 0) return;
|
|
865
|
+
|
|
866
|
+
let cancelled = false;
|
|
867
|
+
let tlIdx = 0;
|
|
868
|
+
let stepIdx = 0;
|
|
869
|
+
let timer: number | null = null;
|
|
870
|
+
const interval = Math.max(400, throughlineAutoPlayIntervalMs);
|
|
871
|
+
|
|
872
|
+
const tick = () => {
|
|
873
|
+
if (cancelled) return;
|
|
874
|
+
const tl = playable[tlIdx]!;
|
|
875
|
+
setSelected(null);
|
|
876
|
+
setSelectedEdgeId(null);
|
|
877
|
+
setFocusedThroughlineId(null);
|
|
878
|
+
setFocusedStepIndex(null);
|
|
879
|
+
setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: stepIdx });
|
|
880
|
+
stepIdx += 1;
|
|
881
|
+
if (stepIdx >= tl.steps.length) {
|
|
882
|
+
stepIdx = 0;
|
|
883
|
+
tlIdx = (tlIdx + 1) % playable.length;
|
|
884
|
+
}
|
|
885
|
+
timer = window.setTimeout(tick, interval);
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
tick();
|
|
889
|
+
return () => {
|
|
890
|
+
cancelled = true;
|
|
891
|
+
if (timer != null) window.clearTimeout(timer);
|
|
892
|
+
setHoveredThroughlineStep(null);
|
|
893
|
+
};
|
|
894
|
+
}, [autoPlayThroughlines, throughlines, throughlineAutoPlayIntervalMs, layoutReady]);
|
|
895
|
+
|
|
742
896
|
// Arrow keys step through the focused throughline once a step is active
|
|
743
897
|
// (sidebar click or drawer open). Ignores typing targets and chords.
|
|
744
898
|
useEffect(() => {
|
|
@@ -1045,7 +1199,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1045
1199
|
flex: 1,
|
|
1046
1200
|
minHeight: 0,
|
|
1047
1201
|
overflowY: 'auto',
|
|
1048
|
-
padding: '0
|
|
1202
|
+
padding: '0 0 12px',
|
|
1049
1203
|
}}
|
|
1050
1204
|
>
|
|
1051
1205
|
{throughlines.map((tl) => (
|
|
@@ -1054,11 +1208,21 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1054
1208
|
throughline={tl}
|
|
1055
1209
|
edges={edges}
|
|
1056
1210
|
collapsed={!expandedThroughlines.has(tl.id)}
|
|
1057
|
-
active={
|
|
1211
|
+
active={
|
|
1212
|
+
focusedThroughlineId === tl.id
|
|
1213
|
+
? { stepIndex: focusedStepIndex }
|
|
1214
|
+
: hoveredThroughlineStep?.throughlineId === tl.id
|
|
1215
|
+
? { stepIndex: hoveredThroughlineStep.stepIndex }
|
|
1216
|
+
: null
|
|
1217
|
+
}
|
|
1058
1218
|
onToggleCollapsed={toggleThroughlineCollapsed}
|
|
1059
1219
|
onFocusFlow={focusThroughlineEdges}
|
|
1060
1220
|
onClearFocus={clearThroughlineFocus}
|
|
1061
1221
|
onFocusStep={focusThroughlineStep}
|
|
1222
|
+
onHoverStep={(tl, i) =>
|
|
1223
|
+
setHoveredThroughlineStep({ throughlineId: tl.id, stepIndex: i })
|
|
1224
|
+
}
|
|
1225
|
+
onLeaveStep={() => setHoveredThroughlineStep(null)}
|
|
1062
1226
|
/>
|
|
1063
1227
|
))}
|
|
1064
1228
|
</div>
|
|
@@ -1213,10 +1377,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1213
1377
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
1214
1378
|
<Controls showZoom showFitView showInteractive />
|
|
1215
1379
|
</ReactFlow>
|
|
1216
|
-
{/* Graph
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
{graphTitle && (
|
|
1380
|
+
{/* Graph / throughline / step titles — non-interactive chips at the top
|
|
1381
|
+
of the canvas. Graph-only embeds use these without opening the sidebar. */}
|
|
1382
|
+
{(graphTitle || overlayThroughlineTitle || overlayThroughlineStep) && (
|
|
1220
1383
|
<div
|
|
1221
1384
|
style={{
|
|
1222
1385
|
position: 'absolute',
|
|
@@ -1224,23 +1387,124 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1224
1387
|
left: '50%',
|
|
1225
1388
|
transform: 'translateX(-50%)',
|
|
1226
1389
|
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)',
|
|
1390
|
+
display: 'flex',
|
|
1391
|
+
flexDirection: 'column',
|
|
1392
|
+
alignItems: 'center',
|
|
1393
|
+
gap: 6,
|
|
1394
|
+
maxWidth: '70%',
|
|
1240
1395
|
pointerEvents: 'none',
|
|
1241
1396
|
}}
|
|
1242
1397
|
>
|
|
1243
|
-
{graphTitle
|
|
1398
|
+
{graphTitle && (
|
|
1399
|
+
<div
|
|
1400
|
+
style={{
|
|
1401
|
+
maxWidth: '100%',
|
|
1402
|
+
overflow: 'hidden',
|
|
1403
|
+
textOverflow: 'ellipsis',
|
|
1404
|
+
whiteSpace: 'nowrap',
|
|
1405
|
+
padding: '6px 18px',
|
|
1406
|
+
fontSize: theme.fontSizes[3],
|
|
1407
|
+
fontWeight: 600,
|
|
1408
|
+
fontFamily: theme.fonts.monospace,
|
|
1409
|
+
color: theme.colors.text,
|
|
1410
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1411
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1412
|
+
borderRadius: 6,
|
|
1413
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1414
|
+
}}
|
|
1415
|
+
>
|
|
1416
|
+
{graphTitle}
|
|
1417
|
+
</div>
|
|
1418
|
+
)}
|
|
1419
|
+
{overlayThroughlineTitle && (
|
|
1420
|
+
<div
|
|
1421
|
+
style={{
|
|
1422
|
+
maxWidth: '100%',
|
|
1423
|
+
minWidth: overlayThroughlineStep ? 160 : undefined,
|
|
1424
|
+
display: 'flex',
|
|
1425
|
+
flexDirection: 'column',
|
|
1426
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1427
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1428
|
+
borderRadius: 6,
|
|
1429
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1430
|
+
overflow: 'hidden',
|
|
1431
|
+
opacity: 0.95,
|
|
1432
|
+
}}
|
|
1433
|
+
aria-label={
|
|
1434
|
+
overlayThroughlineStep
|
|
1435
|
+
? `${overlayThroughlineTitle}, step ${overlayThroughlineStep.index} of ${overlayThroughlineStep.total}`
|
|
1436
|
+
: overlayThroughlineTitle
|
|
1437
|
+
}
|
|
1438
|
+
>
|
|
1439
|
+
<div
|
|
1440
|
+
style={{
|
|
1441
|
+
padding: '5px 14px',
|
|
1442
|
+
fontSize: theme.fontSizes[2] ?? theme.fontSizes[1],
|
|
1443
|
+
fontWeight: 600,
|
|
1444
|
+
fontFamily: theme.fonts.monospace,
|
|
1445
|
+
color: theme.colors.text,
|
|
1446
|
+
overflow: 'hidden',
|
|
1447
|
+
textOverflow: 'ellipsis',
|
|
1448
|
+
whiteSpace: 'nowrap',
|
|
1449
|
+
textAlign: 'center',
|
|
1450
|
+
}}
|
|
1451
|
+
>
|
|
1452
|
+
{overlayThroughlineTitle}
|
|
1453
|
+
</div>
|
|
1454
|
+
{overlayThroughlineStep && overlayThroughlineStep.total > 0 && (
|
|
1455
|
+
<div
|
|
1456
|
+
style={{
|
|
1457
|
+
display: 'flex',
|
|
1458
|
+
gap: 3,
|
|
1459
|
+
padding: '0 6px 5px',
|
|
1460
|
+
}}
|
|
1461
|
+
aria-hidden="true"
|
|
1462
|
+
>
|
|
1463
|
+
{Array.from({ length: overlayThroughlineStep.total }, (_, i) => {
|
|
1464
|
+
const n = i + 1;
|
|
1465
|
+
const active = n === overlayThroughlineStep.index;
|
|
1466
|
+
const done = n < overlayThroughlineStep.index;
|
|
1467
|
+
return (
|
|
1468
|
+
<span
|
|
1469
|
+
key={n}
|
|
1470
|
+
style={{
|
|
1471
|
+
flex: 1,
|
|
1472
|
+
height: 2,
|
|
1473
|
+
borderRadius: 1,
|
|
1474
|
+
background: active
|
|
1475
|
+
? (theme.colors.accent ?? theme.colors.primary ?? theme.colors.text)
|
|
1476
|
+
: done
|
|
1477
|
+
? (theme.colors.textSecondary ?? theme.colors.text)
|
|
1478
|
+
: (theme.colors.border ?? 'rgba(127,127,127,0.45)'),
|
|
1479
|
+
opacity: active ? 1 : done ? 0.75 : 0.4,
|
|
1480
|
+
transition: 'opacity 120ms ease, background 120ms ease',
|
|
1481
|
+
}}
|
|
1482
|
+
/>
|
|
1483
|
+
);
|
|
1484
|
+
})}
|
|
1485
|
+
</div>
|
|
1486
|
+
)}
|
|
1487
|
+
</div>
|
|
1488
|
+
)}
|
|
1489
|
+
{overlayThroughlineStep?.annotation && (
|
|
1490
|
+
<div
|
|
1491
|
+
style={{
|
|
1492
|
+
maxWidth: '100%',
|
|
1493
|
+
padding: '6px 14px',
|
|
1494
|
+
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1495
|
+
border: `1px solid ${theme.colors.border}`,
|
|
1496
|
+
borderRadius: 6,
|
|
1497
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
1498
|
+
textAlign: 'center',
|
|
1499
|
+
fontSize: theme.fontSizes[0],
|
|
1500
|
+
fontFamily: theme.fonts.body,
|
|
1501
|
+
color: theme.colors.textMuted ?? theme.colors.textSecondary,
|
|
1502
|
+
lineHeight: 1.35,
|
|
1503
|
+
}}
|
|
1504
|
+
>
|
|
1505
|
+
{overlayThroughlineStep.annotation}
|
|
1506
|
+
</div>
|
|
1507
|
+
)}
|
|
1244
1508
|
</div>
|
|
1245
1509
|
)}
|
|
1246
1510
|
{/* Selected-component declaration — floating card over the canvas
|
|
@@ -1388,6 +1652,8 @@ function ThroughlineFlow({
|
|
|
1388
1652
|
onFocusFlow,
|
|
1389
1653
|
onClearFocus,
|
|
1390
1654
|
onFocusStep,
|
|
1655
|
+
onHoverStep,
|
|
1656
|
+
onLeaveStep,
|
|
1391
1657
|
}: {
|
|
1392
1658
|
throughline: SubsystemThroughline;
|
|
1393
1659
|
edges: SubsystemComponentEdge[];
|
|
@@ -1398,6 +1664,8 @@ function ThroughlineFlow({
|
|
|
1398
1664
|
onFocusFlow: (tl: SubsystemThroughline) => void;
|
|
1399
1665
|
onClearFocus: () => void;
|
|
1400
1666
|
onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1667
|
+
onHoverStep: (tl: SubsystemThroughline, stepIndex: number) => void;
|
|
1668
|
+
onLeaveStep: () => void;
|
|
1401
1669
|
}) {
|
|
1402
1670
|
const { theme } = useTheme();
|
|
1403
1671
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -1406,8 +1674,52 @@ function ThroughlineFlow({
|
|
|
1406
1674
|
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
1407
1675
|
const [headerHover, setHeaderHover] = useState(false);
|
|
1408
1676
|
const [closeHover, setCloseHover] = useState(false);
|
|
1677
|
+
const [playHover, setPlayHover] = useState(false);
|
|
1409
1678
|
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
1410
1679
|
const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
1680
|
+
// Autoplay: stepping through the flow's steps with a pause between each.
|
|
1681
|
+
const [playing, setPlaying] = useState(false);
|
|
1682
|
+
const playTimerRef = useRef<number | null>(null);
|
|
1683
|
+
|
|
1684
|
+
const stopPlaying = useCallback(() => {
|
|
1685
|
+
if (playTimerRef.current != null) {
|
|
1686
|
+
window.clearTimeout(playTimerRef.current);
|
|
1687
|
+
playTimerRef.current = null;
|
|
1688
|
+
}
|
|
1689
|
+
setPlaying(false);
|
|
1690
|
+
}, []);
|
|
1691
|
+
|
|
1692
|
+
// Clear any pending timer on unmount.
|
|
1693
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1694
|
+
useEffect(() => () => stopPlaying(), []);
|
|
1695
|
+
|
|
1696
|
+
const startPlaying = useCallback(() => {
|
|
1697
|
+
if (collapsed) onToggleCollapsed(throughline.id);
|
|
1698
|
+
if (active === null || active.stepIndex !== null) onFocusFlow(throughline);
|
|
1699
|
+
const stepCount = throughline.steps.length;
|
|
1700
|
+
if (stepCount === 0) return;
|
|
1701
|
+
setPlaying(true);
|
|
1702
|
+
let i = 0;
|
|
1703
|
+
const tick = () => {
|
|
1704
|
+
if (i >= stepCount) {
|
|
1705
|
+
playTimerRef.current = null;
|
|
1706
|
+
setPlaying(false);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
onFocusStep(throughline, i);
|
|
1710
|
+
i += 1;
|
|
1711
|
+
playTimerRef.current = window.setTimeout(tick, THROUGHLINE_PLAY_PAUSE_MS);
|
|
1712
|
+
};
|
|
1713
|
+
tick();
|
|
1714
|
+
}, [collapsed, active, onToggleCollapsed, onFocusFlow, throughline, onFocusStep]);
|
|
1715
|
+
|
|
1716
|
+
const togglePlay = useCallback(() => {
|
|
1717
|
+
if (playing) {
|
|
1718
|
+
stopPlaying();
|
|
1719
|
+
} else {
|
|
1720
|
+
startPlaying();
|
|
1721
|
+
}
|
|
1722
|
+
}, [playing, stopPlaying, startPlaying]);
|
|
1411
1723
|
|
|
1412
1724
|
// Keep DOM focus on the active step so the browser focus ring (and
|
|
1413
1725
|
// subsequent arrow keys) follow arrow navigation, not the originally
|
|
@@ -1418,7 +1730,7 @@ function ThroughlineFlow({
|
|
|
1418
1730
|
}, [active?.stepIndex]);
|
|
1419
1731
|
|
|
1420
1732
|
return (
|
|
1421
|
-
<div
|
|
1733
|
+
<div>
|
|
1422
1734
|
<div
|
|
1423
1735
|
onMouseEnter={() => setHeaderHover(true)}
|
|
1424
1736
|
onMouseLeave={() => setHeaderHover(false)}
|
|
@@ -1426,7 +1738,6 @@ function ThroughlineFlow({
|
|
|
1426
1738
|
display: 'flex',
|
|
1427
1739
|
alignItems: 'center',
|
|
1428
1740
|
gap: 4,
|
|
1429
|
-
borderRadius: 6,
|
|
1430
1741
|
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
1431
1742
|
transition: 'background 120ms ease',
|
|
1432
1743
|
}}
|
|
@@ -1449,8 +1760,7 @@ function ThroughlineFlow({
|
|
|
1449
1760
|
display: 'flex',
|
|
1450
1761
|
alignItems: 'center',
|
|
1451
1762
|
minWidth: 0,
|
|
1452
|
-
padding: '
|
|
1453
|
-
borderRadius: 6,
|
|
1763
|
+
padding: '10px 8px',
|
|
1454
1764
|
border: 'none',
|
|
1455
1765
|
background: 'transparent',
|
|
1456
1766
|
textAlign: 'left',
|
|
@@ -1472,6 +1782,35 @@ function ThroughlineFlow({
|
|
|
1472
1782
|
</span>
|
|
1473
1783
|
</button>
|
|
1474
1784
|
{!collapsed && (
|
|
1785
|
+
<>
|
|
1786
|
+
<button
|
|
1787
|
+
type="button"
|
|
1788
|
+
aria-label={playing ? `Pause ${throughline.title} autoplay` : `Play ${throughline.title}`}
|
|
1789
|
+
title={playing ? 'Pause' : 'Play through steps'}
|
|
1790
|
+
onMouseEnter={() => setPlayHover(true)}
|
|
1791
|
+
onMouseLeave={() => setPlayHover(false)}
|
|
1792
|
+
onClick={(e) => {
|
|
1793
|
+
e.stopPropagation();
|
|
1794
|
+
togglePlay();
|
|
1795
|
+
}}
|
|
1796
|
+
style={{
|
|
1797
|
+
display: 'inline-flex',
|
|
1798
|
+
alignItems: 'center',
|
|
1799
|
+
justifyContent: 'center',
|
|
1800
|
+
flexShrink: 0,
|
|
1801
|
+
width: 22,
|
|
1802
|
+
height: 22,
|
|
1803
|
+
padding: 0,
|
|
1804
|
+
border: 'none',
|
|
1805
|
+
borderRadius: 4,
|
|
1806
|
+
background: playing || playHover ? theme.colors.border : 'transparent',
|
|
1807
|
+
color: playing || playHover ? theme.colors.text : muted,
|
|
1808
|
+
cursor: 'pointer',
|
|
1809
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
1810
|
+
}}
|
|
1811
|
+
>
|
|
1812
|
+
{playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
|
|
1813
|
+
</button>
|
|
1475
1814
|
<button
|
|
1476
1815
|
type="button"
|
|
1477
1816
|
aria-label={`Close ${throughline.title}`}
|
|
@@ -1501,6 +1840,7 @@ function ThroughlineFlow({
|
|
|
1501
1840
|
>
|
|
1502
1841
|
<X size={12} strokeWidth={2} />
|
|
1503
1842
|
</button>
|
|
1843
|
+
</>
|
|
1504
1844
|
)}
|
|
1505
1845
|
</div>
|
|
1506
1846
|
{!collapsed && (
|
|
@@ -1517,15 +1857,21 @@ function ThroughlineFlow({
|
|
|
1517
1857
|
stepButtonRefs.current[i] = el;
|
|
1518
1858
|
}}
|
|
1519
1859
|
type="button"
|
|
1520
|
-
onMouseEnter={() =>
|
|
1521
|
-
|
|
1860
|
+
onMouseEnter={() => {
|
|
1861
|
+
setHoveredStep(i);
|
|
1862
|
+
onHoverStep(throughline, i);
|
|
1863
|
+
}}
|
|
1864
|
+
onMouseLeave={() => {
|
|
1865
|
+
setHoveredStep(null);
|
|
1866
|
+
onLeaveStep();
|
|
1867
|
+
}}
|
|
1522
1868
|
onClick={() => onFocusStep(throughline, i)}
|
|
1523
1869
|
style={{
|
|
1524
1870
|
display: 'flex',
|
|
1525
1871
|
alignItems: 'center',
|
|
1526
1872
|
gap: 8,
|
|
1527
1873
|
minWidth: 0,
|
|
1528
|
-
padding: '
|
|
1874
|
+
padding: '8px 8px 8px 12px',
|
|
1529
1875
|
textAlign: 'left',
|
|
1530
1876
|
borderRadius: 6,
|
|
1531
1877
|
border: 'none',
|
|
@@ -1554,9 +1900,9 @@ function ThroughlineFlow({
|
|
|
1554
1900
|
overflow: 'hidden',
|
|
1555
1901
|
textOverflow: 'ellipsis',
|
|
1556
1902
|
whiteSpace: 'nowrap',
|
|
1557
|
-
fontSize: theme.fontSizes[
|
|
1903
|
+
fontSize: theme.fontSizes[1],
|
|
1558
1904
|
fontFamily: theme.fonts.monospace,
|
|
1559
|
-
color:
|
|
1905
|
+
color: theme.colors.text,
|
|
1560
1906
|
}}
|
|
1561
1907
|
>
|
|
1562
1908
|
{step.symbol}
|
|
@@ -1586,7 +1932,7 @@ function ThroughlineFlow({
|
|
|
1586
1932
|
whiteSpace: 'nowrap',
|
|
1587
1933
|
fontSize: theme.fontSizes[0],
|
|
1588
1934
|
fontFamily: theme.fonts.monospace,
|
|
1589
|
-
color:
|
|
1935
|
+
color: theme.colors.text,
|
|
1590
1936
|
}}
|
|
1591
1937
|
>
|
|
1592
1938
|
{step.file.split('/').pop()}
|