@principal-ai/subsystems-react 0.28.1 → 0.28.2

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,569 @@
1
+ /**
2
+ * WalkthroughsPanel — the sidebar's flows panel: the ordered list of a
3
+ * subsystem's walkthroughs, each rendered as a collapsible `WalkthroughFlow`
4
+ * row. Extracted from `SubsystemComponentGraph`'s `Inner` so the panel (and the
5
+ * drag-to-reorder controller it hosts) is a component boundary of its own.
6
+ *
7
+ * Ordering is authored order: the list renders `walkthroughs` in array order.
8
+ * When `onReorder` is supplied, each row grows a grip in its header and dropping
9
+ * a row onto another emits the reordered array (see `reorderWalkthroughs`); the
10
+ * host owns persistence.
11
+ */
12
+
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import type { DragEvent as ReactDragEvent } from 'react';
15
+ import { useTheme } from '@principal-ade/industry-theme';
16
+ import { GripVertical, Pause, Play, X } from 'lucide-react';
17
+ import {
18
+ reorderTargetIndex,
19
+ reorderWalkthroughs,
20
+ walkthroughStepGraphEdgeId,
21
+ type SubsystemWalkthrough,
22
+ } from './model';
23
+
24
+ /** Pause (ms) between steps when a walkthrough autoplays. */
25
+ export const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
26
+
27
+ /** Drag state handed to a row's grip while a reorder is in flight. */
28
+ interface WalkthroughReorderHandle {
29
+ index: number;
30
+ isDragging: boolean;
31
+ onDragStart: (index: number, e: ReactDragEvent) => void;
32
+ onDragEnd: () => void;
33
+ }
34
+
35
+ /** One collapsible walkthrough in the sidebar's flows panel. Clicking the
36
+ * title: closed → open + select; open and unselected → select; open and
37
+ * selected → close + clear focus. The right-aligned close button collapses
38
+ * without selecting. A step row focuses that step's edge. */
39
+ function WalkthroughFlow({
40
+ walkthrough,
41
+ collapsed,
42
+ active,
43
+ onToggleCollapsed,
44
+ onFocusFlow,
45
+ onClearFocus,
46
+ onFocusStep,
47
+ onHoverStep,
48
+ onHoverFlow,
49
+ onLeaveStep,
50
+ reorder,
51
+ dragActive,
52
+ isHoverSuppressed,
53
+ }: {
54
+ walkthrough: SubsystemWalkthrough;
55
+ collapsed: boolean;
56
+ /** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
57
+ active: { stepIndex: number | null } | null;
58
+ onToggleCollapsed: (tlId: string) => void;
59
+ onFocusFlow: (tl: SubsystemWalkthrough) => void;
60
+ onClearFocus: () => void;
61
+ onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
62
+ onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
63
+ /** Preview the whole flow on the canvas (used while the row is collapsed). */
64
+ onHoverFlow: (tl: SubsystemWalkthrough) => void;
65
+ onLeaveStep: () => void;
66
+ /** When set, renders a grip in the header that starts a reorder drag. */
67
+ reorder?: WalkthroughReorderHandle;
68
+ /** True while any row is mid-drag; used to reset stale hover styling. */
69
+ dragActive?: boolean;
70
+ /** True just after a drop; ignores the browser's synthetic hover until the pointer moves. */
71
+ isHoverSuppressed?: () => boolean;
72
+ }) {
73
+ const { theme } = useTheme();
74
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
75
+ const hoverBg = theme.colors.background;
76
+ const wholeFlowActive = active !== null && active.stepIndex === null;
77
+ const [headerHover, setHeaderHover] = useState(false);
78
+ const [closeHover, setCloseHover] = useState(false);
79
+ const [playHover, setPlayHover] = useState(false);
80
+ const [gripHover, setGripHover] = useState(false);
81
+ const [hoveredStep, setHoveredStep] = useState<number | null>(null);
82
+ const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
83
+ // Autoplay: stepping through the flow's steps with a pause between each.
84
+ const [playing, setPlaying] = useState(false);
85
+ const playTimerRef = useRef<number | null>(null);
86
+
87
+ const stopPlaying = useCallback(() => {
88
+ if (playTimerRef.current != null) {
89
+ window.clearTimeout(playTimerRef.current);
90
+ playTimerRef.current = null;
91
+ }
92
+ setPlaying(false);
93
+ }, []);
94
+
95
+ // Clear any pending timer on unmount.
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ useEffect(() => () => stopPlaying(), []);
98
+
99
+ // Native HTML5 drag suppresses mouse events, so a row hovered when the drag
100
+ // began never gets its mouseleave — its hover styling would stick after the
101
+ // drop. Reset every row's hover whenever a drag starts or ends.
102
+ useEffect(() => {
103
+ setHeaderHover(false);
104
+ setCloseHover(false);
105
+ setPlayHover(false);
106
+ setGripHover(false);
107
+ setHoveredStep(null);
108
+ }, [dragActive]);
109
+
110
+ const startPlaying = useCallback(() => {
111
+ if (collapsed) onToggleCollapsed(walkthrough.id);
112
+ if (active === null || active.stepIndex !== null) onFocusFlow(walkthrough);
113
+ const stepCount = walkthrough.steps.length;
114
+ if (stepCount === 0) return;
115
+ setPlaying(true);
116
+ let i = 0;
117
+ const tick = () => {
118
+ if (i >= stepCount) {
119
+ playTimerRef.current = null;
120
+ setPlaying(false);
121
+ return;
122
+ }
123
+ onFocusStep(walkthrough, i);
124
+ i += 1;
125
+ playTimerRef.current = window.setTimeout(tick, WALKTHROUGH_PLAY_PAUSE_MS);
126
+ };
127
+ tick();
128
+ }, [collapsed, active, onToggleCollapsed, onFocusFlow, walkthrough, onFocusStep]);
129
+
130
+ const togglePlay = useCallback(() => {
131
+ if (playing) {
132
+ stopPlaying();
133
+ } else {
134
+ startPlaying();
135
+ }
136
+ }, [playing, stopPlaying, startPlaying]);
137
+
138
+ // Keep DOM focus on the active step so the browser focus ring (and
139
+ // subsequent arrow keys) follow arrow navigation, not the originally
140
+ // clicked button.
141
+ useEffect(() => {
142
+ if (active?.stepIndex == null) return;
143
+ stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
144
+ }, [active?.stepIndex]);
145
+
146
+ return (
147
+ <div>
148
+ <div
149
+ onMouseEnter={() => {
150
+ if (isHoverSuppressed?.()) return;
151
+ setHeaderHover(true);
152
+ if (collapsed) onHoverFlow(walkthrough);
153
+ }}
154
+ onMouseLeave={() => {
155
+ setHeaderHover(false);
156
+ if (collapsed) onLeaveStep();
157
+ }}
158
+ style={{
159
+ display: 'flex',
160
+ alignItems: 'center',
161
+ gap: 4,
162
+ padding: '0 16px',
163
+ background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
164
+ transition: 'background 120ms ease',
165
+ }}
166
+ >
167
+ <button
168
+ type="button"
169
+ onClick={() => {
170
+ if (collapsed) {
171
+ onToggleCollapsed(walkthrough.id);
172
+ onFocusFlow(walkthrough);
173
+ } else if (active === null) {
174
+ onFocusFlow(walkthrough);
175
+ } else {
176
+ onToggleCollapsed(walkthrough.id);
177
+ onClearFocus();
178
+ }
179
+ }}
180
+ style={{
181
+ flex: 1,
182
+ display: 'flex',
183
+ alignItems: 'center',
184
+ minWidth: 0,
185
+ padding: '10px 0',
186
+ border: 'none',
187
+ background: 'transparent',
188
+ textAlign: 'left',
189
+ cursor: 'pointer',
190
+ }}
191
+ >
192
+ <span
193
+ style={{
194
+ overflow: 'hidden',
195
+ textOverflow: 'ellipsis',
196
+ whiteSpace: 'nowrap',
197
+ fontSize: theme.fontSizes[1],
198
+ fontFamily: theme.fonts.monospace,
199
+ fontWeight: 600,
200
+ color: theme.colors.text,
201
+ }}
202
+ >
203
+ {walkthrough.title}
204
+ </span>
205
+ </button>
206
+ {!collapsed && (
207
+ <>
208
+ <button
209
+ type="button"
210
+ aria-label={playing ? `Pause ${walkthrough.title} autoplay` : `Play ${walkthrough.title}`}
211
+ title={playing ? 'Pause' : 'Play through steps'}
212
+ onMouseEnter={() => setPlayHover(true)}
213
+ onMouseLeave={() => setPlayHover(false)}
214
+ onClick={(e) => {
215
+ e.stopPropagation();
216
+ togglePlay();
217
+ }}
218
+ style={{
219
+ display: 'inline-flex',
220
+ alignItems: 'center',
221
+ justifyContent: 'center',
222
+ flexShrink: 0,
223
+ width: 22,
224
+ height: 22,
225
+ padding: 0,
226
+ border: 'none',
227
+ borderRadius: 4,
228
+ background: playing || playHover ? theme.colors.border : 'transparent',
229
+ color: playing || playHover ? theme.colors.text : muted,
230
+ cursor: 'pointer',
231
+ transition: 'background 120ms ease, color 120ms ease',
232
+ }}
233
+ >
234
+ {playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
235
+ </button>
236
+ <button
237
+ type="button"
238
+ aria-label={`Close ${walkthrough.title}`}
239
+ onMouseEnter={() => setCloseHover(true)}
240
+ onMouseLeave={() => setCloseHover(false)}
241
+ onClick={(e) => {
242
+ e.stopPropagation();
243
+ onToggleCollapsed(walkthrough.id);
244
+ if (active !== null) onClearFocus();
245
+ }}
246
+ style={{
247
+ display: 'inline-flex',
248
+ alignItems: 'center',
249
+ justifyContent: 'center',
250
+ flexShrink: 0,
251
+ width: 22,
252
+ height: 22,
253
+ padding: 0,
254
+ border: 'none',
255
+ borderRadius: 4,
256
+ background: closeHover ? theme.colors.border : 'transparent',
257
+ color: closeHover ? theme.colors.text : muted,
258
+ cursor: 'pointer',
259
+ transition: 'background 120ms ease, color 120ms ease',
260
+ }}
261
+ >
262
+ <X size={12} strokeWidth={2} />
263
+ </button>
264
+ </>
265
+ )}
266
+ {reorder && (
267
+ <span
268
+ role="button"
269
+ tabIndex={-1}
270
+ draggable
271
+ aria-label={`Reorder ${walkthrough.title}`}
272
+ title="Drag to reorder"
273
+ onMouseEnter={() => {
274
+ if (!isHoverSuppressed?.()) setGripHover(true);
275
+ }}
276
+ onMouseLeave={() => setGripHover(false)}
277
+ onDragStart={(e) => reorder.onDragStart(reorder.index, e)}
278
+ onDragEnd={reorder.onDragEnd}
279
+ onClick={(e) => e.stopPropagation()}
280
+ style={{
281
+ display: 'inline-flex',
282
+ alignItems: 'center',
283
+ flexShrink: 0,
284
+ marginRight: -8,
285
+ padding: '2px 0',
286
+ color: gripHover || reorder.isDragging ? theme.colors.text : muted,
287
+ cursor: reorder.isDragging ? 'grabbing' : 'grab',
288
+ transition: 'color 120ms ease',
289
+ }}
290
+ >
291
+ <GripVertical size={14} strokeWidth={2} />
292
+ </span>
293
+ )}
294
+ </div>
295
+ {!collapsed && (
296
+ <div
297
+ style={{ display: 'flex', flexDirection: 'column' }}
298
+ onMouseLeave={() => {
299
+ setHoveredStep(null);
300
+ onLeaveStep();
301
+ }}
302
+ >
303
+ {walkthrough.steps.map((step, i) => {
304
+ const stepActive = active !== null && active.stepIndex === i;
305
+ return (
306
+ <button
307
+ key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
308
+ ref={(el) => {
309
+ stepButtonRefs.current[i] = el;
310
+ }}
311
+ type="button"
312
+ onMouseEnter={() => {
313
+ setHoveredStep(i);
314
+ onHoverStep(walkthrough, i);
315
+ }}
316
+ onClick={() => onFocusStep(walkthrough, i)}
317
+ style={{
318
+ display: 'flex',
319
+ alignItems: 'center',
320
+ gap: 8,
321
+ minWidth: 0,
322
+ padding: '8px 8px 8px 12px',
323
+ textAlign: 'left',
324
+ borderRadius: 6,
325
+ border: 'none',
326
+ outline: 'none',
327
+ background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
328
+ cursor: 'pointer',
329
+ transition: 'background 120ms ease',
330
+ }}
331
+ >
332
+ <span
333
+ style={{
334
+ flexShrink: 0,
335
+ width: 14,
336
+ fontSize: theme.fontSizes[0],
337
+ fontFamily: theme.fonts.monospace,
338
+ color: stepActive ? theme.colors.text : muted,
339
+ }}
340
+ >
341
+ {i + 1}
342
+ </span>
343
+ <span
344
+ style={{
345
+ flex: 1,
346
+ minWidth: 0,
347
+ overflow: 'hidden',
348
+ textOverflow: 'ellipsis',
349
+ whiteSpace: 'nowrap',
350
+ fontSize: theme.fontSizes[1],
351
+ fontFamily: theme.fonts.monospace,
352
+ color: theme.colors.text,
353
+ }}
354
+ >
355
+ {step.symbol}
356
+ </span>
357
+ </button>
358
+ );
359
+ })}
360
+ </div>
361
+ )}
362
+ </div>
363
+ );
364
+ }
365
+
366
+ export interface WalkthroughsPanelProps {
367
+ /** Ordered walkthroughs — the list renders them in array order. */
368
+ walkthroughs: SubsystemWalkthrough[];
369
+ /** Ids of the rows whose step lists are expanded. */
370
+ expandedWalkthroughs: Set<string>;
371
+ focusedWalkthroughId: string | null;
372
+ focusedStepIndex: number | null;
373
+ hoveredWalkthroughStep: {
374
+ walkthroughId: string;
375
+ stepIndex: number | null;
376
+ } | null;
377
+ onToggleCollapsed: (tlId: string) => void;
378
+ onFocusFlow: (tl: SubsystemWalkthrough) => void;
379
+ onClearFocus: () => void;
380
+ onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
381
+ onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
382
+ onHoverFlow: (tl: SubsystemWalkthrough) => void;
383
+ onLeaveStep: () => void;
384
+ /**
385
+ * When set, rows grow a drag grip (and become drop targets) so a drop emits
386
+ * the reordered array. Grips show only while every row is collapsed. Omit
387
+ * for a read-only panel.
388
+ */
389
+ onReorder?: (next: SubsystemWalkthrough[]) => void;
390
+ }
391
+
392
+ /**
393
+ * The flows panel: a scroll container of `WalkthroughFlow` rows, optionally
394
+ * drag-reorderable. The host owns the array; a drop calls `onReorder` with the
395
+ * next order and the host re-renders with it.
396
+ */
397
+ export function WalkthroughsPanel({
398
+ walkthroughs,
399
+ expandedWalkthroughs,
400
+ focusedWalkthroughId,
401
+ focusedStepIndex,
402
+ hoveredWalkthroughStep,
403
+ onToggleCollapsed,
404
+ onFocusFlow,
405
+ onClearFocus,
406
+ onFocusStep,
407
+ onHoverStep,
408
+ onHoverFlow,
409
+ onLeaveStep,
410
+ onReorder,
411
+ }: WalkthroughsPanelProps) {
412
+ const { theme } = useTheme();
413
+ // Reordering is offered only while every row is collapsed: the compact list
414
+ // is what you drag, and expanded step lists would make drop targets tall and
415
+ // the landing boundary ambiguous.
416
+ const allCollapsed = walkthroughs.every((w) => !expandedWalkthroughs.has(w.id));
417
+ const reorderable = onReorder != null && allCollapsed;
418
+ // Row being dragged, and the gap the pointer is over (boundaries 0..n
419
+ // between rows). The boundary is where the dragged row will be inserted.
420
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
421
+ const [dropBoundary, setDropBoundary] = useState<number | null>(null);
422
+ // After a native drag ends the browser fires a mouseenter on whatever sits
423
+ // under the cursor, which would light up a row the user never moved onto.
424
+ // Ignore hover until the pointer actually moves. A ref (not state) so the
425
+ // guard is synchronous — the synthetic event can beat a re-render.
426
+ const suppressHoverRef = useRef(false);
427
+ const hoverClearRef = useRef<(() => void) | null>(null);
428
+
429
+ const suppressHoverUntilMove = useCallback(() => {
430
+ suppressHoverRef.current = true;
431
+ if (hoverClearRef.current) return;
432
+ const clear = () => {
433
+ suppressHoverRef.current = false;
434
+ window.removeEventListener('mousemove', clear);
435
+ hoverClearRef.current = null;
436
+ };
437
+ hoverClearRef.current = clear;
438
+ window.addEventListener('mousemove', clear);
439
+ }, []);
440
+
441
+ useEffect(
442
+ () => () => {
443
+ if (hoverClearRef.current) {
444
+ window.removeEventListener('mousemove', hoverClearRef.current);
445
+ hoverClearRef.current = null;
446
+ }
447
+ },
448
+ [],
449
+ );
450
+
451
+ const resetDrag = useCallback(() => {
452
+ setDragIndex(null);
453
+ setDropBoundary(null);
454
+ suppressHoverUntilMove();
455
+ }, [suppressHoverUntilMove]);
456
+
457
+ const handleDragStart = useCallback((index: number, e: ReactDragEvent) => {
458
+ suppressHoverRef.current = false;
459
+ setDragIndex(index);
460
+ e.dataTransfer.effectAllowed = 'move';
461
+ // Firefox requires data to be set for a drag to begin.
462
+ e.dataTransfer.setData('text/plain', String(index));
463
+ }, []);
464
+
465
+ // Pick the boundary nearest the pointer: above the row when over its top
466
+ // half, below it when over the bottom half. The line marks that gap, and the
467
+ // drop inserts there — so the indicator always matches the result.
468
+ const handleDragOver = useCallback(
469
+ (e: ReactDragEvent, index: number) => {
470
+ if (dragIndex == null) return;
471
+ e.preventDefault();
472
+ e.dataTransfer.dropEffect = 'move';
473
+ const rect = e.currentTarget.getBoundingClientRect();
474
+ const below = e.clientY - rect.top > rect.height / 2;
475
+ setDropBoundary(index + (below ? 1 : 0));
476
+ },
477
+ [dragIndex],
478
+ );
479
+
480
+ const handleDrop = useCallback(
481
+ (e: ReactDragEvent) => {
482
+ e.preventDefault();
483
+ const boundary = dropBoundary;
484
+ const from = dragIndex ?? Number(e.dataTransfer.getData('text/plain'));
485
+ resetDrag();
486
+ if (!onReorder || boundary == null || !Number.isInteger(from)) return;
487
+ // Removing the dragged row shifts every boundary after it down one.
488
+ const target = reorderTargetIndex(boundary, from);
489
+ if (target === from) return;
490
+ onReorder(reorderWalkthroughs(walkthroughs, from, target));
491
+ },
492
+ [dragIndex, dropBoundary, onReorder, resetDrag, walkthroughs],
493
+ );
494
+
495
+ const accent = theme.colors.accent ?? theme.colors.primary ?? theme.colors.text;
496
+ const isHoverSuppressed = useCallback(() => suppressHoverRef.current, []);
497
+
498
+ return (
499
+ <div
500
+ style={{
501
+ flex: 1,
502
+ minHeight: 0,
503
+ overflowY: 'auto',
504
+ padding: '0 0 12px',
505
+ }}
506
+ onDragEnd={reorderable ? resetDrag : undefined}
507
+ >
508
+ {walkthroughs.map((tl, i) => {
509
+ const isDragging = dragIndex === i;
510
+ // One indicator per boundary: a row's top edge for boundaries above it,
511
+ // the last row's bottom edge for the boundary at the very end.
512
+ const showTop = reorderable && dropBoundary === i;
513
+ const showBottom =
514
+ reorderable &&
515
+ i === walkthroughs.length - 1 &&
516
+ dropBoundary === walkthroughs.length;
517
+ return (
518
+ <div
519
+ key={tl.id}
520
+ data-walkthrough-row={tl.id}
521
+ data-reorder-index={i}
522
+ onDragOver={reorderable ? (e) => handleDragOver(e, i) : undefined}
523
+ onDrop={reorderable ? handleDrop : undefined}
524
+ style={{
525
+ opacity: isDragging ? 0.45 : 1,
526
+ boxShadow: showTop
527
+ ? `inset 0 2px 0 ${accent}`
528
+ : showBottom
529
+ ? `inset 0 -2px 0 ${accent}`
530
+ : undefined,
531
+ transition: 'opacity 120ms ease, box-shadow 120ms ease',
532
+ }}
533
+ >
534
+ <WalkthroughFlow
535
+ walkthrough={tl}
536
+ collapsed={!expandedWalkthroughs.has(tl.id)}
537
+ active={
538
+ focusedWalkthroughId === tl.id
539
+ ? { stepIndex: focusedStepIndex }
540
+ : hoveredWalkthroughStep?.walkthroughId === tl.id
541
+ ? { stepIndex: hoveredWalkthroughStep.stepIndex }
542
+ : null
543
+ }
544
+ onToggleCollapsed={onToggleCollapsed}
545
+ onFocusFlow={onFocusFlow}
546
+ onClearFocus={onClearFocus}
547
+ onFocusStep={onFocusStep}
548
+ onHoverStep={onHoverStep}
549
+ onHoverFlow={onHoverFlow}
550
+ onLeaveStep={onLeaveStep}
551
+ reorder={
552
+ reorderable
553
+ ? {
554
+ index: i,
555
+ isDragging,
556
+ onDragStart: handleDragStart,
557
+ onDragEnd: resetDrag,
558
+ }
559
+ : undefined
560
+ }
561
+ dragActive={dragIndex != null}
562
+ isHoverSuppressed={isHoverSuppressed}
563
+ />
564
+ </div>
565
+ );
566
+ })}
567
+ </div>
568
+ );
569
+ }
@@ -29,8 +29,10 @@ import {
29
29
  packageColor,
30
30
  subsystemGraphLayoutKey,
31
31
  describeConstructBreakdown,
32
+ reorderWalkthroughs,
33
+ reorderTargetIndex,
32
34
  } from './model';
33
- import type { SubsystemComponent, SubsystemComponentEdge } from './model';
35
+ import type { SubsystemComponent, SubsystemComponentEdge, SubsystemWalkthrough } from './model';
34
36
 
35
37
  const comps: SubsystemComponent[] = [
36
38
  { alias: 'reader', name: 'SessionReader', construct: 'class', file: 'SessionReader.ts', purl: 'pkg:github/principal-ai/agent-monitoring' },
@@ -793,3 +795,76 @@ describe('module badge labels', () => {
793
795
  expect(moduleBadgeWidth('src/…/nodes.tsx')).toBeLessThan(moduleBadgeWidth('packages/…/nodes.tsx'));
794
796
  });
795
797
  });
798
+
799
+ describe('reorderWalkthroughs', () => {
800
+ const wts: SubsystemWalkthrough[] = [
801
+ { id: 'a', title: 'A', steps: [] },
802
+ { id: 'b', title: 'B', steps: [] },
803
+ { id: 'c', title: 'C', steps: [] },
804
+ ];
805
+ const ids = (list: SubsystemWalkthrough[]) => list.map((w) => w.id);
806
+
807
+ test('moves an item forward to the target index', () => {
808
+ expect(ids(reorderWalkthroughs(wts, 0, 2))).toEqual(['b', 'c', 'a']);
809
+ });
810
+
811
+ test('moves an item backward to the target index', () => {
812
+ expect(ids(reorderWalkthroughs(wts, 2, 0))).toEqual(['c', 'a', 'b']);
813
+ });
814
+
815
+ test('returns a new array and leaves the input untouched', () => {
816
+ const next = reorderWalkthroughs(wts, 1, 2);
817
+ expect(next).not.toBe(wts);
818
+ expect(ids(wts)).toEqual(['a', 'b', 'c']);
819
+ expect(ids(next)).toEqual(['a', 'c', 'b']);
820
+ });
821
+
822
+ test('no-op when source and target match', () => {
823
+ expect(ids(reorderWalkthroughs(wts, 1, 1))).toEqual(['a', 'b', 'c']);
824
+ });
825
+
826
+ test('out-of-range source returns a shallow copy unchanged', () => {
827
+ const next = reorderWalkthroughs(wts, 5, 0);
828
+ expect(next).not.toBe(wts);
829
+ expect(ids(next)).toEqual(['a', 'b', 'c']);
830
+ });
831
+
832
+ test('clamps a target beyond the end to the last slot', () => {
833
+ expect(ids(reorderWalkthroughs(wts, 0, 99))).toEqual(['b', 'c', 'a']);
834
+ });
835
+ });
836
+
837
+ describe('reorderTargetIndex', () => {
838
+ // Boundaries are the n+1 gaps between rows: 0 is above row 0, n is below
839
+ // the last row. Dragging row `from` to boundary `b` lands it at b, minus one
840
+ // when b is past from (removing the row shifts later gaps down).
841
+ test('dragging down past a row lands after it', () => {
842
+ expect(reorderTargetIndex(2, 0)).toBe(1);
843
+ expect(reorderTargetIndex(3, 0)).toBe(2);
844
+ });
845
+
846
+ test('dragging up lands at the boundary', () => {
847
+ expect(reorderTargetIndex(0, 2)).toBe(0);
848
+ expect(reorderTargetIndex(1, 2)).toBe(1);
849
+ });
850
+
851
+ test('the boundaries around the dragged row are no-ops', () => {
852
+ expect(reorderTargetIndex(1, 1)).toBe(1);
853
+ expect(reorderTargetIndex(2, 1)).toBe(1);
854
+ });
855
+
856
+ test('dropping the first row above itself is a no-op', () => {
857
+ expect(reorderTargetIndex(0, 0)).toBe(0);
858
+ });
859
+
860
+ test('composes with reorderWalkthroughs to match the indicator', () => {
861
+ const wts: SubsystemWalkthrough[] = [
862
+ { id: 'a', title: 'A', steps: [] },
863
+ { id: 'b', title: 'B', steps: [] },
864
+ { id: 'c', title: 'C', steps: [] },
865
+ ];
866
+ // Drag A onto the gap below B (boundary 2) → A lands between B and C.
867
+ const target = reorderTargetIndex(2, 0);
868
+ expect(reorderWalkthroughs(wts, 0, target).map((w) => w.id)).toEqual(['b', 'a', 'c']);
869
+ });
870
+ });
@@ -349,6 +349,37 @@ export function walkthroughStepGraphEdgeId(
349
349
  return derivedGraphEdgeId(step.from, step.to, step.mechanism);
350
350
  }
351
351
 
352
+ /**
353
+ * Move one walkthrough from `from` to `to`, returning a new array. Used by the
354
+ * flows panel's drag-to-reorder: the array order is the walkthroughs' display
355
+ * order, so a reorder is just an array splice. Out-of-range `from` returns a
356
+ * shallow copy unchanged; `to` is clamped into range.
357
+ */
358
+ export function reorderWalkthroughs(
359
+ walkthroughs: readonly SubsystemWalkthrough[],
360
+ from: number,
361
+ to: number,
362
+ ): SubsystemWalkthrough[] {
363
+ const next = [...walkthroughs];
364
+ if (!Number.isInteger(from) || from < 0 || from >= next.length) return next;
365
+ const target = Math.max(0, Math.min(to, next.length - 1));
366
+ if (target === from) return next;
367
+ const [moved] = next.splice(from, 1);
368
+ if (moved) next.splice(target, 0, moved);
369
+ return next;
370
+ }
371
+
372
+ /**
373
+ * Final index for a drag-reorder given the insertion `boundary` (one of the
374
+ * `n + 1` gaps between rows) and the dragged row's `from` index. Removing the
375
+ * dragged row shifts every boundary after it down one, so a boundary past
376
+ * `from` maps to `boundary - 1`. Feed the result to `reorderWalkthroughs` as
377
+ * `to`; a result equal to `from` is a no-op.
378
+ */
379
+ export function reorderTargetIndex(boundary: number, from: number): number {
380
+ return boundary - (boundary > from ? 1 : 0);
381
+ }
382
+
352
383
  export function deriveGraphEdges(doc: {
353
384
  relations?: readonly SubsystemRelation[];
354
385
  walkthroughs?: readonly SubsystemWalkthrough[];