@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.
@@ -32,7 +32,7 @@ import {
32
32
  applyNodeChanges,
33
33
  } from '@xyflow/react';
34
34
  import { useTheme } from '@principal-ade/industry-theme';
35
- import { FileText, Pause, Play, X } from 'lucide-react';
35
+ import { FileText, X } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
@@ -63,14 +63,13 @@ import { ComponentDeclaration } from './ComponentDeclaration';
63
63
  import type { ComponentVerificationState } from './ComponentDeclaration';
64
64
  import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
65
65
  import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
66
+ import { WalkthroughsPanel, WALKTHROUGH_PLAY_PAUSE_MS } from './WalkthroughsPanel';
66
67
 
67
68
  /** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
68
69
  const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
69
70
  /** Rough monospace width at fontSize 10 + horizontal padding/border. */
70
71
  const EDGE_LABEL_CHAR_PX = 6.2;
71
72
  const EDGE_LABEL_PAD_PX = 18;
72
- /** Pause (ms) between steps when a walkthrough autoplays. */
73
- const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
74
73
 
75
74
  /** Context passed to `renderWalkthroughViewer` when a flow/step is focused. */
76
75
  export interface WalkthroughViewerContext {
@@ -107,6 +106,13 @@ export interface SubsystemComponentGraphProps {
107
106
  * graph.
108
107
  */
109
108
  initialWalkthroughId?: string | null;
109
+ /**
110
+ * Called with the next walkthrough order after a drag in the sidebar's
111
+ * flows panel. When set, each row grows a drag grip and a drop emits the
112
+ * reordered array — the host owns persisting it (the graph stays controlled
113
+ * and never reorders its own prop).
114
+ */
115
+ onReorderWalkthroughs?: (next: SubsystemWalkthrough[]) => void;
110
116
  onSelect?: (componentAlias: string) => void;
111
117
  /** Called when an edge is clicked (relationship / mechanism + refs seam). */
112
118
  onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
@@ -313,7 +319,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
313
319
  measured: { w: number; h: number } | null;
314
320
  }
315
321
 
316
- function Inner({ components, relations, walkthroughs, initialWalkthroughId, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
322
+ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
317
323
  const { theme } = useTheme();
318
324
  const { fitView } = useReactFlow();
319
325
  const viewport = useViewport();
@@ -1684,40 +1690,25 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onSe
1684
1690
  </div>
1685
1691
  )}
1686
1692
  {sidebarView === 'walkthroughs' && walkthroughs && walkthroughs.length > 0 ? (
1687
- <div
1688
- style={{
1689
- flex: 1,
1690
- minHeight: 0,
1691
- overflowY: 'auto',
1692
- padding: '0 0 12px',
1693
- }}
1694
- >
1695
- {walkthroughs.map((tl) => (
1696
- <WalkthroughFlow
1697
- key={tl.id}
1698
- walkthrough={tl}
1699
- collapsed={!expandedWalkthroughs.has(tl.id)}
1700
- active={
1701
- focusedWalkthroughId === tl.id
1702
- ? { stepIndex: focusedStepIndex }
1703
- : hoveredWalkthroughStep?.walkthroughId === tl.id
1704
- ? { stepIndex: hoveredWalkthroughStep.stepIndex }
1705
- : null
1706
- }
1707
- onToggleCollapsed={toggleWalkthroughCollapsed}
1708
- onFocusFlow={focusWalkthroughEdges}
1709
- onClearFocus={clearWalkthroughFocus}
1710
- onFocusStep={focusWalkthroughStep}
1711
- onHoverStep={(tl, i) =>
1712
- setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: i })
1713
- }
1714
- onHoverFlow={(tl) =>
1715
- setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: null })
1716
- }
1717
- onLeaveStep={() => setHoveredWalkthroughStep(null)}
1718
- />
1719
- ))}
1720
- </div>
1693
+ <WalkthroughsPanel
1694
+ walkthroughs={walkthroughs}
1695
+ expandedWalkthroughs={expandedWalkthroughs}
1696
+ focusedWalkthroughId={focusedWalkthroughId}
1697
+ focusedStepIndex={focusedStepIndex}
1698
+ hoveredWalkthroughStep={hoveredWalkthroughStep}
1699
+ onToggleCollapsed={toggleWalkthroughCollapsed}
1700
+ onFocusFlow={focusWalkthroughEdges}
1701
+ onClearFocus={clearWalkthroughFocus}
1702
+ onFocusStep={focusWalkthroughStep}
1703
+ onHoverStep={(tl, i) =>
1704
+ setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: i })
1705
+ }
1706
+ onHoverFlow={(tl) =>
1707
+ setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: null })
1708
+ }
1709
+ onLeaveStep={() => setHoveredWalkthroughStep(null)}
1710
+ onReorder={onReorderWalkthroughs}
1711
+ />
1721
1712
  ) : treeFilePaths.length > 0 ? (
1722
1713
  <>
1723
1714
  {repoGroups.groups.map((group, i) => {
@@ -2216,287 +2207,6 @@ function RepoGroupHeader({
2216
2207
  );
2217
2208
  }
2218
2209
 
2219
- /** One collapsible walkthrough in the sidebar's flows panel. Clicking the
2220
- * title: closed → open + select; open and unselected → select; open and
2221
- * selected → close + clear focus. The right-aligned close button collapses
2222
- * without selecting. A step row focuses that step's edge. */
2223
- function WalkthroughFlow({
2224
- walkthrough,
2225
- collapsed,
2226
- active,
2227
- onToggleCollapsed,
2228
- onFocusFlow,
2229
- onClearFocus,
2230
- onFocusStep,
2231
- onHoverStep,
2232
- onHoverFlow,
2233
- onLeaveStep,
2234
- }: {
2235
- walkthrough: SubsystemWalkthrough;
2236
- collapsed: boolean;
2237
- /** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
2238
- active: { stepIndex: number | null } | null;
2239
- onToggleCollapsed: (tlId: string) => void;
2240
- onFocusFlow: (tl: SubsystemWalkthrough) => void;
2241
- onClearFocus: () => void;
2242
- onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
2243
- onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
2244
- /** Preview the whole flow on the canvas (used while the row is collapsed). */
2245
- onHoverFlow: (tl: SubsystemWalkthrough) => void;
2246
- onLeaveStep: () => void;
2247
- }) {
2248
- const { theme } = useTheme();
2249
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
2250
- const hoverBg = theme.colors.background;
2251
- const wholeFlowActive = active !== null && active.stepIndex === null;
2252
- const [headerHover, setHeaderHover] = useState(false);
2253
- const [closeHover, setCloseHover] = useState(false);
2254
- const [playHover, setPlayHover] = useState(false);
2255
- const [hoveredStep, setHoveredStep] = useState<number | null>(null);
2256
- const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
2257
- // Autoplay: stepping through the flow's steps with a pause between each.
2258
- const [playing, setPlaying] = useState(false);
2259
- const playTimerRef = useRef<number | null>(null);
2260
-
2261
- const stopPlaying = useCallback(() => {
2262
- if (playTimerRef.current != null) {
2263
- window.clearTimeout(playTimerRef.current);
2264
- playTimerRef.current = null;
2265
- }
2266
- setPlaying(false);
2267
- }, []);
2268
-
2269
- // Clear any pending timer on unmount.
2270
- // eslint-disable-next-line react-hooks/exhaustive-deps
2271
- useEffect(() => () => stopPlaying(), []);
2272
-
2273
- const startPlaying = useCallback(() => {
2274
- if (collapsed) onToggleCollapsed(walkthrough.id);
2275
- if (active === null || active.stepIndex !== null) onFocusFlow(walkthrough);
2276
- const stepCount = walkthrough.steps.length;
2277
- if (stepCount === 0) return;
2278
- setPlaying(true);
2279
- let i = 0;
2280
- const tick = () => {
2281
- if (i >= stepCount) {
2282
- playTimerRef.current = null;
2283
- setPlaying(false);
2284
- return;
2285
- }
2286
- onFocusStep(walkthrough, i);
2287
- i += 1;
2288
- playTimerRef.current = window.setTimeout(tick, WALKTHROUGH_PLAY_PAUSE_MS);
2289
- };
2290
- tick();
2291
- }, [collapsed, active, onToggleCollapsed, onFocusFlow, walkthrough, onFocusStep]);
2292
-
2293
- const togglePlay = useCallback(() => {
2294
- if (playing) {
2295
- stopPlaying();
2296
- } else {
2297
- startPlaying();
2298
- }
2299
- }, [playing, stopPlaying, startPlaying]);
2300
-
2301
- // Keep DOM focus on the active step so the browser focus ring (and
2302
- // subsequent arrow keys) follow arrow navigation, not the originally
2303
- // clicked button.
2304
- useEffect(() => {
2305
- if (active?.stepIndex == null) return;
2306
- stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
2307
- }, [active?.stepIndex]);
2308
-
2309
- return (
2310
- <div>
2311
- <div
2312
- onMouseEnter={() => {
2313
- setHeaderHover(true);
2314
- if (collapsed) onHoverFlow(walkthrough);
2315
- }}
2316
- onMouseLeave={() => {
2317
- setHeaderHover(false);
2318
- if (collapsed) onLeaveStep();
2319
- }}
2320
- style={{
2321
- display: 'flex',
2322
- alignItems: 'center',
2323
- gap: 4,
2324
- padding: '0 16px',
2325
- background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
2326
- transition: 'background 120ms ease',
2327
- }}
2328
- >
2329
- <button
2330
- type="button"
2331
- onClick={() => {
2332
- if (collapsed) {
2333
- onToggleCollapsed(walkthrough.id);
2334
- onFocusFlow(walkthrough);
2335
- } else if (active === null) {
2336
- onFocusFlow(walkthrough);
2337
- } else {
2338
- onToggleCollapsed(walkthrough.id);
2339
- onClearFocus();
2340
- }
2341
- }}
2342
- style={{
2343
- flex: 1,
2344
- display: 'flex',
2345
- alignItems: 'center',
2346
- minWidth: 0,
2347
- padding: '10px 0',
2348
- border: 'none',
2349
- background: 'transparent',
2350
- textAlign: 'left',
2351
- cursor: 'pointer',
2352
- }}
2353
- >
2354
- <span
2355
- style={{
2356
- overflow: 'hidden',
2357
- textOverflow: 'ellipsis',
2358
- whiteSpace: 'nowrap',
2359
- fontSize: theme.fontSizes[1],
2360
- fontFamily: theme.fonts.monospace,
2361
- fontWeight: 600,
2362
- color: theme.colors.text,
2363
- }}
2364
- >
2365
- {walkthrough.title}
2366
- </span>
2367
- </button>
2368
- {!collapsed && (
2369
- <>
2370
- <button
2371
- type="button"
2372
- aria-label={playing ? `Pause ${walkthrough.title} autoplay` : `Play ${walkthrough.title}`}
2373
- title={playing ? 'Pause' : 'Play through steps'}
2374
- onMouseEnter={() => setPlayHover(true)}
2375
- onMouseLeave={() => setPlayHover(false)}
2376
- onClick={(e) => {
2377
- e.stopPropagation();
2378
- togglePlay();
2379
- }}
2380
- style={{
2381
- display: 'inline-flex',
2382
- alignItems: 'center',
2383
- justifyContent: 'center',
2384
- flexShrink: 0,
2385
- width: 22,
2386
- height: 22,
2387
- padding: 0,
2388
- border: 'none',
2389
- borderRadius: 4,
2390
- background: playing || playHover ? theme.colors.border : 'transparent',
2391
- color: playing || playHover ? theme.colors.text : muted,
2392
- cursor: 'pointer',
2393
- transition: 'background 120ms ease, color 120ms ease',
2394
- }}
2395
- >
2396
- {playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
2397
- </button>
2398
- <button
2399
- type="button"
2400
- aria-label={`Close ${walkthrough.title}`}
2401
- onMouseEnter={() => setCloseHover(true)}
2402
- onMouseLeave={() => setCloseHover(false)}
2403
- onClick={(e) => {
2404
- e.stopPropagation();
2405
- onToggleCollapsed(walkthrough.id);
2406
- if (active !== null) onClearFocus();
2407
- }}
2408
- style={{
2409
- display: 'inline-flex',
2410
- alignItems: 'center',
2411
- justifyContent: 'center',
2412
- flexShrink: 0,
2413
- width: 22,
2414
- height: 22,
2415
- padding: 0,
2416
- border: 'none',
2417
- borderRadius: 4,
2418
- background: closeHover ? theme.colors.border : 'transparent',
2419
- color: closeHover ? theme.colors.text : muted,
2420
- cursor: 'pointer',
2421
- transition: 'background 120ms ease, color 120ms ease',
2422
- }}
2423
- >
2424
- <X size={12} strokeWidth={2} />
2425
- </button>
2426
- </>
2427
- )}
2428
- </div>
2429
- {!collapsed && (
2430
- <div
2431
- style={{ display: 'flex', flexDirection: 'column' }}
2432
- onMouseLeave={() => {
2433
- setHoveredStep(null);
2434
- onLeaveStep();
2435
- }}
2436
- >
2437
- {walkthrough.steps.map((step, i) => {
2438
- const stepActive = active !== null && active.stepIndex === i;
2439
- return (
2440
- <button
2441
- key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
2442
- ref={(el) => {
2443
- stepButtonRefs.current[i] = el;
2444
- }}
2445
- type="button"
2446
- onMouseEnter={() => {
2447
- setHoveredStep(i);
2448
- onHoverStep(walkthrough, i);
2449
- }}
2450
- onClick={() => onFocusStep(walkthrough, i)}
2451
- style={{
2452
- display: 'flex',
2453
- alignItems: 'center',
2454
- gap: 8,
2455
- minWidth: 0,
2456
- padding: '8px 8px 8px 12px',
2457
- textAlign: 'left',
2458
- borderRadius: 6,
2459
- border: 'none',
2460
- outline: 'none',
2461
- background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
2462
- cursor: 'pointer',
2463
- transition: 'background 120ms ease',
2464
- }}
2465
- >
2466
- <span
2467
- style={{
2468
- flexShrink: 0,
2469
- width: 14,
2470
- fontSize: theme.fontSizes[0],
2471
- fontFamily: theme.fonts.monospace,
2472
- color: stepActive ? theme.colors.text : muted,
2473
- }}
2474
- >
2475
- {i + 1}
2476
- </span>
2477
- <span
2478
- style={{
2479
- flex: 1,
2480
- minWidth: 0,
2481
- overflow: 'hidden',
2482
- textOverflow: 'ellipsis',
2483
- whiteSpace: 'nowrap',
2484
- fontSize: theme.fontSizes[1],
2485
- fontFamily: theme.fonts.monospace,
2486
- color: theme.colors.text,
2487
- }}
2488
- >
2489
- {step.symbol}
2490
- </span>
2491
- </button>
2492
- );
2493
- })}
2494
- </div>
2495
- )}
2496
- </div>
2497
- );
2498
- }
2499
-
2500
2210
  export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
2501
2211
  const wrapRef = useRef<HTMLDivElement>(null);
2502
2212
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);