@fragments-sdk/viewer 0.2.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.
Files changed (141) hide show
  1. package/LICENSE +84 -0
  2. package/index.html +28 -0
  3. package/package.json +71 -0
  4. package/src/__tests__/a11y-fixes.test.ts +358 -0
  5. package/src/__tests__/jsx-parser.test.ts +502 -0
  6. package/src/__tests__/render-utils.test.ts +232 -0
  7. package/src/__tests__/style-utils.test.ts +404 -0
  8. package/src/app/index.ts +1 -0
  9. package/src/assets/fragments-logo.ts +4 -0
  10. package/src/assets/fragments_logo.png +0 -0
  11. package/src/components/AccessibilityPanel.tsx +1457 -0
  12. package/src/components/ActionCapture.tsx +172 -0
  13. package/src/components/ActionsPanel.tsx +332 -0
  14. package/src/components/AllVariantsPreview.tsx +78 -0
  15. package/src/components/App.tsx +604 -0
  16. package/src/components/BottomPanel.tsx +288 -0
  17. package/src/components/CodePanel.naming.test.tsx +59 -0
  18. package/src/components/CodePanel.tsx +118 -0
  19. package/src/components/CommandPalette.tsx +392 -0
  20. package/src/components/ComponentDocView.tsx +164 -0
  21. package/src/components/ComponentGraph.tsx +380 -0
  22. package/src/components/ComponentHeader.tsx +88 -0
  23. package/src/components/ContractPanel.tsx +241 -0
  24. package/src/components/DeviceMockup.tsx +156 -0
  25. package/src/components/EmptyVariantMessage.tsx +54 -0
  26. package/src/components/ErrorBoundary.tsx +97 -0
  27. package/src/components/FigmaEmbed.tsx +238 -0
  28. package/src/components/FragmentEditor.tsx +525 -0
  29. package/src/components/FragmentRenderer.tsx +61 -0
  30. package/src/components/HeaderSearch.tsx +24 -0
  31. package/src/components/HealthDashboard.tsx +441 -0
  32. package/src/components/HmrStatusIndicator.tsx +61 -0
  33. package/src/components/Icons.tsx +479 -0
  34. package/src/components/InteractionsPanel.tsx +757 -0
  35. package/src/components/IsolatedPreviewFrame.tsx +390 -0
  36. package/src/components/IsolatedRender.tsx +113 -0
  37. package/src/components/KeyboardShortcutsHelp.tsx +53 -0
  38. package/src/components/LandingPage.tsx +420 -0
  39. package/src/components/Layout.tsx +27 -0
  40. package/src/components/LeftSidebar.tsx +472 -0
  41. package/src/components/LoadErrorMessage.tsx +102 -0
  42. package/src/components/MultiViewportPreview.tsx +527 -0
  43. package/src/components/NoVariantsMessage.tsx +59 -0
  44. package/src/components/PanelShell.tsx +161 -0
  45. package/src/components/PerformancePanel.tsx +304 -0
  46. package/src/components/PreviewArea.tsx +254 -0
  47. package/src/components/PreviewAside.tsx +168 -0
  48. package/src/components/PreviewFrameHost.tsx +304 -0
  49. package/src/components/PreviewToolbar.tsx +80 -0
  50. package/src/components/PropsEditor.tsx +506 -0
  51. package/src/components/PropsTable.tsx +111 -0
  52. package/src/components/RelationsSection.tsx +88 -0
  53. package/src/components/ResizablePanel.tsx +271 -0
  54. package/src/components/RightSidebar.tsx +102 -0
  55. package/src/components/RuntimeToolsRegistrar.tsx +17 -0
  56. package/src/components/ScreenshotButton.tsx +90 -0
  57. package/src/components/ShadowPreview.tsx +204 -0
  58. package/src/components/Sidebar.tsx +169 -0
  59. package/src/components/SkeletonLoader.tsx +161 -0
  60. package/src/components/ThemeProvider.tsx +42 -0
  61. package/src/components/Toast.tsx +3 -0
  62. package/src/components/TokenStylePanel.tsx +699 -0
  63. package/src/components/TopToolbar.tsx +159 -0
  64. package/src/components/Untitled +1 -0
  65. package/src/components/UsageSection.tsx +95 -0
  66. package/src/components/VariantMatrix.tsx +391 -0
  67. package/src/components/VariantRenderer.tsx +131 -0
  68. package/src/components/VariantTabs.tsx +40 -0
  69. package/src/components/ViewerHeader.tsx +69 -0
  70. package/src/components/ViewerStateSync.tsx +52 -0
  71. package/src/components/ViewportSelector.tsx +172 -0
  72. package/src/components/WebMCPDevTools.tsx +503 -0
  73. package/src/components/WebMCPIntegration.tsx +47 -0
  74. package/src/components/WebMCPStatusIndicator.tsx +60 -0
  75. package/src/components/_future/CreatePage.tsx +835 -0
  76. package/src/components/viewer-utils.ts +16 -0
  77. package/src/composition-renderer.ts +381 -0
  78. package/src/constants/index.ts +1 -0
  79. package/src/constants/ui.ts +166 -0
  80. package/src/entry.tsx +335 -0
  81. package/src/hooks/index.ts +2 -0
  82. package/src/hooks/useA11yCache.ts +383 -0
  83. package/src/hooks/useA11yService.ts +364 -0
  84. package/src/hooks/useActions.ts +138 -0
  85. package/src/hooks/useAppState.ts +147 -0
  86. package/src/hooks/useCompiledFragments.ts +42 -0
  87. package/src/hooks/useFigmaIntegration.ts +132 -0
  88. package/src/hooks/useHmrStatus.ts +109 -0
  89. package/src/hooks/useKeyboardShortcuts.ts +270 -0
  90. package/src/hooks/usePreviewBridge.ts +347 -0
  91. package/src/hooks/useScrollSpy.ts +78 -0
  92. package/src/hooks/useShadowStyles.ts +221 -0
  93. package/src/hooks/useUrlState.ts +318 -0
  94. package/src/hooks/useViewSettings.ts +111 -0
  95. package/src/intelligence/healthReport.ts +505 -0
  96. package/src/intelligence/styleDrift.ts +340 -0
  97. package/src/intelligence/usageScanner.ts +309 -0
  98. package/src/jsx-parser.ts +486 -0
  99. package/src/preview-frame-entry.tsx +25 -0
  100. package/src/preview-frame.html +148 -0
  101. package/src/render-template.html +68 -0
  102. package/src/render-utils.ts +311 -0
  103. package/src/shared/ComponentDocContent.module.scss +10 -0
  104. package/src/shared/ComponentDocContent.module.scss.d.ts +2 -0
  105. package/src/shared/ComponentDocContent.tsx +274 -0
  106. package/src/shared/DocsHeaderBar.tsx +129 -0
  107. package/src/shared/DocsPageAsideHost.tsx +89 -0
  108. package/src/shared/DocsPageShell.tsx +124 -0
  109. package/src/shared/DocsSearchCommand.tsx +99 -0
  110. package/src/shared/DocsSidebarNav.tsx +66 -0
  111. package/src/shared/PropsTable.module.scss +68 -0
  112. package/src/shared/PropsTable.module.scss.d.ts +2 -0
  113. package/src/shared/PropsTable.tsx +76 -0
  114. package/src/shared/VariantPreviewCard.module.scss +114 -0
  115. package/src/shared/VariantPreviewCard.module.scss.d.ts +2 -0
  116. package/src/shared/VariantPreviewCard.tsx +137 -0
  117. package/src/shared/docs-data/index.ts +32 -0
  118. package/src/shared/docs-data/mcp-configs.ts +72 -0
  119. package/src/shared/docs-data/palettes.ts +75 -0
  120. package/src/shared/docs-data/setup-examples.ts +55 -0
  121. package/src/shared/docs-layout.scss +28 -0
  122. package/src/shared/docs-layout.scss.d.ts +2 -0
  123. package/src/shared/index.ts +34 -0
  124. package/src/shared/types.ts +53 -0
  125. package/src/style-utils.ts +414 -0
  126. package/src/styles/globals.css +278 -0
  127. package/src/types/a11y.ts +197 -0
  128. package/src/utils/a11y-fixes.ts +509 -0
  129. package/src/utils/actionExport.ts +372 -0
  130. package/src/utils/colorSchemes.ts +201 -0
  131. package/src/utils/contrast.ts +246 -0
  132. package/src/utils/detectRelationships.ts +256 -0
  133. package/src/webmcp/__tests__/analytics.test.ts +108 -0
  134. package/src/webmcp/analytics.ts +165 -0
  135. package/src/webmcp/index.ts +3 -0
  136. package/src/webmcp/posthog-bridge.ts +39 -0
  137. package/src/webmcp/runtime-tools.ts +152 -0
  138. package/src/webmcp/scan-utils.ts +135 -0
  139. package/src/webmcp/use-tool-analytics.ts +69 -0
  140. package/src/webmcp/viewer-state.ts +45 -0
  141. package/tsconfig.json +20 -0
@@ -0,0 +1,757 @@
1
+ /**
2
+ * Interactions Panel - Storybook-style interaction testing
3
+ *
4
+ * Runs play functions from Storybook stories and displays:
5
+ * - Step-by-step progress
6
+ * - Assertion results (pass/fail)
7
+ * - Error messages with stack traces
8
+ * - Re-run capabilities
9
+ * - Step-through debugging with breakpoints
10
+ */
11
+
12
+ import { useState, useCallback, useRef, useEffect } from "react";
13
+ import { Button, Stack, Text, Badge, CodeBlock, Alert, Box } from "@fragments-sdk/ui";
14
+ import type { PlayFunction, PlayFunctionContext, FragmentVariant } from '@fragments-sdk/core';
15
+ import { Play } from "@phosphor-icons/react";
16
+ import {
17
+ PlayIcon,
18
+ CheckIcon,
19
+ XIcon,
20
+ LoadingIcon,
21
+ ChevronDownIcon,
22
+ ChevronRightIcon,
23
+ RefreshIcon,
24
+ BugIcon,
25
+ PauseIcon,
26
+ StepOverIcon,
27
+ ContinueIcon,
28
+ BreakpointIcon,
29
+ BreakpointEmptyIcon,
30
+ } from "./Icons.js";
31
+ import { PanelShell } from "./PanelShell.js";
32
+
33
+ // Step execution state
34
+ interface StepResult {
35
+ name: string;
36
+ status: "pending" | "running" | "passed" | "failed" | "paused";
37
+ error?: string;
38
+ duration?: number;
39
+ }
40
+
41
+ interface InteractionResult {
42
+ status: "idle" | "running" | "passed" | "failed" | "paused";
43
+ steps: StepResult[];
44
+ error?: string;
45
+ duration?: number;
46
+ startTime?: number;
47
+ }
48
+
49
+ // Debug state
50
+ interface DebugState {
51
+ mode: "normal" | "debug";
52
+ isPaused: boolean;
53
+ currentStepIndex: number;
54
+ breakpoints: Set<number>;
55
+ }
56
+
57
+ interface InteractionsPanelProps {
58
+ /** The current variant being displayed */
59
+ variant: FragmentVariant | null;
60
+ /** Selector for the preview container element */
61
+ previewSelector?: string;
62
+ /** Key that changes when the preview updates */
63
+ previewKey?: number;
64
+ /** Args currently being used for the preview */
65
+ currentArgs?: Record<string, unknown>;
66
+ }
67
+
68
+ // Status color helpers
69
+ const STATUS_COLORS: Record<string, { bg: string; border: string; text: string; icon: string }> = {
70
+ running: {
71
+ bg: 'color-mix(in srgb, #3b82f6 8%, transparent)',
72
+ border: '1px solid color-mix(in srgb, #3b82f6 25%, transparent)',
73
+ text: '#1d4ed8',
74
+ icon: '#2563eb',
75
+ },
76
+ paused: {
77
+ bg: 'color-mix(in srgb, #f97316 8%, transparent)',
78
+ border: '1px solid color-mix(in srgb, #f97316 25%, transparent)',
79
+ text: '#c2410c',
80
+ icon: '#ea580c',
81
+ },
82
+ passed: {
83
+ bg: 'color-mix(in srgb, #22c55e 8%, transparent)',
84
+ border: '1px solid color-mix(in srgb, #22c55e 25%, transparent)',
85
+ text: '#15803d',
86
+ icon: '#16a34a',
87
+ },
88
+ failed: {
89
+ bg: 'color-mix(in srgb, #ef4444 8%, transparent)',
90
+ border: '1px solid color-mix(in srgb, #ef4444 25%, transparent)',
91
+ text: '#b91c1c',
92
+ icon: '#dc2626',
93
+ },
94
+ pending: {
95
+ bg: 'var(--bg-secondary)',
96
+ border: '1px solid var(--border)',
97
+ text: 'var(--text-muted)',
98
+ icon: 'var(--text-muted)',
99
+ },
100
+ };
101
+
102
+ function getStatusColors(status: string) {
103
+ return STATUS_COLORS[status] || STATUS_COLORS.pending;
104
+ }
105
+
106
+ export function InteractionsPanel({
107
+ variant,
108
+ previewSelector = '[data-preview-container="true"]',
109
+ previewKey = 0,
110
+ currentArgs = {},
111
+ }: InteractionsPanelProps) {
112
+ const [result, setResult] = useState<InteractionResult>({
113
+ status: "idle",
114
+ steps: [],
115
+ });
116
+ const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
117
+ const [debugState, setDebugState] = useState<DebugState>({
118
+ mode: "normal",
119
+ isPaused: false,
120
+ currentStepIndex: -1,
121
+ breakpoints: new Set(),
122
+ });
123
+ const [hoveredBreakpoint, setHoveredBreakpoint] = useState<number | null>(null);
124
+ const abortControllerRef = useRef<AbortController | null>(null);
125
+ const resumeResolverRef = useRef<(() => void) | null>(null);
126
+
127
+ // Reset state when variant changes
128
+ useEffect(() => {
129
+ setResult({ status: "idle", steps: [] });
130
+ setExpandedSteps(new Set());
131
+ setDebugState((prev) => ({
132
+ ...prev,
133
+ isPaused: false,
134
+ currentStepIndex: -1,
135
+ }));
136
+ }, [variant?.name, previewKey]);
137
+
138
+ // Keyboard shortcuts
139
+ useEffect(() => {
140
+ const handleKeyDown = (e: KeyboardEvent) => {
141
+ // Only handle if this panel is focused or no specific element is focused
142
+ if (document.activeElement && document.activeElement.tagName !== 'BODY') {
143
+ const isInteractionsPanelFocused = (document.activeElement as HTMLElement).closest('[data-interactions-panel]');
144
+ if (!isInteractionsPanelFocused) return;
145
+ }
146
+
147
+ switch (e.key) {
148
+ case 'F5':
149
+ e.preventDefault();
150
+ if (debugState.mode === 'debug' && !result.status.includes('running')) {
151
+ runInteractions(true);
152
+ }
153
+ break;
154
+ case 'F8':
155
+ e.preventDefault();
156
+ if (debugState.isPaused) {
157
+ handleResume();
158
+ }
159
+ break;
160
+ case 'F9':
161
+ e.preventDefault();
162
+ if (debugState.currentStepIndex >= 0) {
163
+ toggleBreakpoint(debugState.currentStepIndex);
164
+ }
165
+ break;
166
+ case 'F10':
167
+ e.preventDefault();
168
+ if (debugState.isPaused) {
169
+ handleStepOver();
170
+ }
171
+ break;
172
+ }
173
+ };
174
+
175
+ window.addEventListener('keydown', handleKeyDown);
176
+ return () => window.removeEventListener('keydown', handleKeyDown);
177
+ }, [debugState, result.status]);
178
+
179
+ const hasPlayFunction = variant?.hasPlayFunction && variant?.play;
180
+
181
+ const toggleBreakpoint = (index: number) => {
182
+ setDebugState((prev) => {
183
+ const next = new Set(prev.breakpoints);
184
+ if (next.has(index)) {
185
+ next.delete(index);
186
+ } else {
187
+ next.add(index);
188
+ }
189
+ return { ...prev, breakpoints: next };
190
+ });
191
+ };
192
+
193
+ const toggleDebugMode = () => {
194
+ setDebugState((prev) => ({
195
+ ...prev,
196
+ mode: prev.mode === 'normal' ? 'debug' : 'normal',
197
+ }));
198
+ };
199
+
200
+ const handleResume = () => {
201
+ if (resumeResolverRef.current) {
202
+ resumeResolverRef.current();
203
+ resumeResolverRef.current = null;
204
+ }
205
+ setDebugState((prev) => ({ ...prev, isPaused: false }));
206
+ setResult((prev) => ({ ...prev, status: 'running' }));
207
+ };
208
+
209
+ const handleStepOver = () => {
210
+ // Mark current step to not pause, then resume
211
+ if (resumeResolverRef.current) {
212
+ resumeResolverRef.current();
213
+ resumeResolverRef.current = null;
214
+ }
215
+ setDebugState((prev) => ({ ...prev, isPaused: false }));
216
+ setResult((prev) => ({ ...prev, status: 'running' }));
217
+ };
218
+
219
+ // Run the play function
220
+ const runInteractions = useCallback(async (withDebugger = false) => {
221
+ if (!variant?.play) return;
222
+
223
+ // Cancel any existing run
224
+ if (abortControllerRef.current) {
225
+ abortControllerRef.current.abort();
226
+ }
227
+ abortControllerRef.current = new AbortController();
228
+
229
+ const startTime = performance.now();
230
+ const steps: StepResult[] = [];
231
+ const isDebugMode = withDebugger || debugState.mode === 'debug';
232
+ let stepIndex = 0;
233
+
234
+ setResult({
235
+ status: "running",
236
+ steps: [],
237
+ startTime,
238
+ });
239
+
240
+ setDebugState((prev) => ({
241
+ ...prev,
242
+ isPaused: false,
243
+ currentStepIndex: -1,
244
+ }));
245
+
246
+ // Find the preview container
247
+ const canvasElement = document.querySelector(previewSelector) as HTMLElement;
248
+ if (!canvasElement) {
249
+ setResult({
250
+ status: "failed",
251
+ steps: [],
252
+ error: `Preview container not found: ${previewSelector}`,
253
+ duration: performance.now() - startTime,
254
+ });
255
+ return;
256
+ }
257
+
258
+ // Create the step function that tracks execution
259
+ const step = async (name: string, fn: () => Promise<void>): Promise<void> => {
260
+ const currentIndex = stepIndex++;
261
+ const stepStartTime = performance.now();
262
+
263
+ // Check for breakpoint or debug mode pause
264
+ if (isDebugMode && (debugState.breakpoints.has(currentIndex) || currentIndex === 0)) {
265
+ // Pause at breakpoint
266
+ steps.push({ name, status: "paused" });
267
+ setResult((prev) => ({
268
+ ...prev,
269
+ status: "paused",
270
+ steps: [...steps],
271
+ }));
272
+ setDebugState((prev) => ({
273
+ ...prev,
274
+ isPaused: true,
275
+ currentStepIndex: currentIndex,
276
+ }));
277
+
278
+ // Wait for resume
279
+ await new Promise<void>((resolve) => {
280
+ resumeResolverRef.current = resolve;
281
+ });
282
+
283
+ // Update to running
284
+ steps[currentIndex] = { name, status: "running" };
285
+ setResult((prev) => ({
286
+ ...prev,
287
+ status: "running",
288
+ steps: [...steps],
289
+ }));
290
+ } else {
291
+ // Add step as running
292
+ steps.push({ name, status: "running" });
293
+ setResult((prev) => ({
294
+ ...prev,
295
+ steps: [...steps],
296
+ }));
297
+ }
298
+
299
+ setDebugState((prev) => ({
300
+ ...prev,
301
+ currentStepIndex: currentIndex,
302
+ }));
303
+
304
+ try {
305
+ await fn();
306
+ steps[currentIndex] = {
307
+ name,
308
+ status: "passed",
309
+ duration: performance.now() - stepStartTime,
310
+ };
311
+ setResult((prev) => ({
312
+ ...prev,
313
+ steps: [...steps],
314
+ }));
315
+ } catch (error) {
316
+ const errorMessage = error instanceof Error ? error.message : String(error);
317
+ steps[currentIndex] = {
318
+ name,
319
+ status: "failed",
320
+ error: errorMessage,
321
+ duration: performance.now() - stepStartTime,
322
+ };
323
+ setResult((prev) => ({
324
+ ...prev,
325
+ steps: [...steps],
326
+ }));
327
+ throw error; // Re-throw to stop execution
328
+ }
329
+
330
+ // In debug mode, pause after each step if stepping
331
+ if (isDebugMode && debugState.breakpoints.has(currentIndex + 1)) {
332
+ setResult((prev) => ({
333
+ ...prev,
334
+ status: "paused",
335
+ }));
336
+ setDebugState((prev) => ({
337
+ ...prev,
338
+ isPaused: true,
339
+ }));
340
+
341
+ await new Promise<void>((resolve) => {
342
+ resumeResolverRef.current = resolve;
343
+ });
344
+ }
345
+ };
346
+
347
+ // Create the context for the play function
348
+ const context: PlayFunctionContext = {
349
+ canvasElement,
350
+ args: currentArgs,
351
+ step,
352
+ };
353
+
354
+ try {
355
+ await variant.play(context);
356
+
357
+ // If no explicit steps were defined, the whole play function is one step
358
+ if (steps.length === 0) {
359
+ steps.push({
360
+ name: "Play function",
361
+ status: "passed",
362
+ duration: performance.now() - startTime,
363
+ });
364
+ }
365
+
366
+ setResult({
367
+ status: "passed",
368
+ steps: [...steps],
369
+ duration: performance.now() - startTime,
370
+ });
371
+
372
+ setDebugState((prev) => ({
373
+ ...prev,
374
+ isPaused: false,
375
+ currentStepIndex: -1,
376
+ }));
377
+ } catch (error) {
378
+ const errorMessage = error instanceof Error ? error.message : String(error);
379
+
380
+ // If no steps failed explicitly, mark as failed at top level
381
+ if (steps.length === 0 || !steps.some((s) => s.status === "failed")) {
382
+ steps.push({
383
+ name: "Play function",
384
+ status: "failed",
385
+ error: errorMessage,
386
+ duration: performance.now() - startTime,
387
+ });
388
+ }
389
+
390
+ setResult({
391
+ status: "failed",
392
+ steps: [...steps],
393
+ error: errorMessage,
394
+ duration: performance.now() - startTime,
395
+ });
396
+
397
+ // Auto-expand failed steps
398
+ const failedIndices = steps
399
+ .map((s, i) => (s.status === "failed" ? i : -1))
400
+ .filter((i) => i >= 0);
401
+ setExpandedSteps(new Set(failedIndices));
402
+
403
+ setDebugState((prev) => ({
404
+ ...prev,
405
+ isPaused: false,
406
+ currentStepIndex: -1,
407
+ }));
408
+ }
409
+ }, [variant, previewSelector, currentArgs, debugState.mode, debugState.breakpoints]);
410
+
411
+ const toggleStep = (index: number) => {
412
+ setExpandedSteps((prev) => {
413
+ const next = new Set(prev);
414
+ if (next.has(index)) {
415
+ next.delete(index);
416
+ } else {
417
+ next.add(index);
418
+ }
419
+ return next;
420
+ });
421
+ };
422
+
423
+ const formatDuration = (ms?: number) => {
424
+ if (ms === undefined) return "";
425
+ if (ms < 1000) return `${Math.round(ms)}ms`;
426
+ return `${(ms / 1000).toFixed(2)}s`;
427
+ };
428
+
429
+ // Empty state for no play function
430
+ const emptyConfig = !hasPlayFunction ? {
431
+ icon: <Play size={24} weight="regular" style={{ color: 'var(--text-tertiary)' }} />,
432
+ title: "No interactions defined",
433
+ description: (
434
+ <>
435
+ This variant doesn't have a play function. Add a <Box as="code" padding="xs" background="secondary" rounded="sm" style={{ fontSize: '12px', display: 'inline' }}>play</Box> function to your Storybook story to enable interaction testing.
436
+ </>
437
+ ),
438
+ action: (
439
+ <CodeBlock language="typescript">{`export const Default = {
440
+ play: async ({ canvasElement, step }) => {
441
+ const canvas = within(canvasElement);
442
+
443
+ await step('Click the button', async () => {
444
+ await userEvent.click(
445
+ canvas.getByRole('button')
446
+ );
447
+ });
448
+
449
+ await expect(
450
+ canvas.getByText('Clicked!')
451
+ ).toBeInTheDocument();
452
+ }
453
+ };`}</CodeBlock>
454
+ ),
455
+ } : undefined;
456
+
457
+ // Toolbar with duration, debug controls, and run button
458
+ const toolbar = hasPlayFunction ? (
459
+ <Stack direction="row" align="center" justify="between" style={{ width: '100%' }}>
460
+ <Stack direction="row" align="center" gap="sm">
461
+ {result.duration !== undefined && (
462
+ <Text size="xs" color="tertiary">{formatDuration(result.duration)}</Text>
463
+ )}
464
+ </Stack>
465
+ <Stack direction="row" align="center" gap="sm">
466
+ {/* Debug mode toggle */}
467
+ <Button
468
+ onClick={toggleDebugMode}
469
+ variant="ghost"
470
+ size="sm"
471
+ title={debugState.mode === 'debug' ? "Exit debug mode" : "Enable debug mode (F5 to run with debugger)"}
472
+ style={{
473
+ color: debugState.mode === 'debug' ? '#ea580c' : undefined,
474
+ background: debugState.mode === 'debug' ? 'color-mix(in srgb, #f97316 10%, transparent)' : undefined,
475
+ }}
476
+ >
477
+ <BugIcon style={{ width: 16, height: 16 }} />
478
+ </Button>
479
+
480
+ {/* Debug controls when paused */}
481
+ {debugState.isPaused && (
482
+ <>
483
+ <Button
484
+ onClick={handleResume}
485
+ variant="ghost"
486
+ size="sm"
487
+ title="Continue (F8)"
488
+ style={{ color: '#16a34a' }}
489
+ >
490
+ <ContinueIcon style={{ width: 16, height: 16 }} />
491
+ </Button>
492
+ <Button
493
+ onClick={handleStepOver}
494
+ variant="ghost"
495
+ size="sm"
496
+ title="Step over (F10)"
497
+ style={{ color: '#2563eb' }}
498
+ >
499
+ <StepOverIcon style={{ width: 16, height: 16 }} />
500
+ </Button>
501
+ </>
502
+ )}
503
+
504
+ <Button
505
+ onClick={() => runInteractions(debugState.mode === 'debug')}
506
+ disabled={result.status === "running"}
507
+ variant={result.status === "running" || result.status === "paused" ? "outline" : "solid"}
508
+ size="sm"
509
+ style={
510
+ result.status === "running" || result.status === "paused"
511
+ ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)', cursor: 'not-allowed' }
512
+ : { background: 'var(--color-accent)', color: '#fff' }
513
+ }
514
+ >
515
+ {result.status === "running" ? (
516
+ <>
517
+ <LoadingIcon style={{ width: 16, height: 16, animation: 'spin 1s linear infinite' }} />
518
+ Running...
519
+ </>
520
+ ) : result.status === "paused" ? (
521
+ <>
522
+ <PauseIcon style={{ width: 16, height: 16 }} />
523
+ Paused
524
+ </>
525
+ ) : result.status === "idle" ? (
526
+ <>
527
+ <PlayIcon style={{ width: 16, height: 16 }} />
528
+ {debugState.mode === 'debug' ? 'Debug' : 'Run'}
529
+ </>
530
+ ) : (
531
+ <>
532
+ <RefreshIcon style={{ width: 16, height: 16 }} />
533
+ Rerun
534
+ </>
535
+ )}
536
+ </Button>
537
+ </Stack>
538
+ </Stack>
539
+ ) : undefined;
540
+
541
+ return (
542
+ <div data-interactions-panel style={{ height: '100%' }}>
543
+ <PanelShell toolbar={toolbar} empty={emptyConfig} bodyPadding="none">
544
+ {result.status === "idle" ? (
545
+ <Box padding="lg" style={{ textAlign: 'center' }}>
546
+ <Text size="sm" color="tertiary">
547
+ Click "Run" to execute the interaction tests
548
+ </Text>
549
+ </Box>
550
+ ) : (
551
+ <Stack direction="column" gap="sm" style={{ padding: '16px' }}>
552
+ {/* Overall status */}
553
+ {(() => {
554
+ const colors = getStatusColors(result.status);
555
+ const alertVariant = result.status === 'passed' ? 'success'
556
+ : result.status === 'failed' ? 'error'
557
+ : result.status === 'paused' ? 'warning'
558
+ : 'info';
559
+ return (
560
+ <Alert variant={alertVariant}>
561
+ <Alert.Body>
562
+ <Stack direction="row" align="center" justify="between" style={{ width: '100%' }}>
563
+ <Stack direction="row" align="center" gap="sm">
564
+ {result.status === "running" && (
565
+ <LoadingIcon style={{ width: 20, height: 20, animation: 'spin 1s linear infinite', color: colors.icon }} />
566
+ )}
567
+ {result.status === "paused" && (
568
+ <PauseIcon style={{ width: 20, height: 20, color: colors.icon }} />
569
+ )}
570
+ {result.status === "passed" && (
571
+ <CheckIcon style={{ width: 20, height: 20, color: colors.icon }} />
572
+ )}
573
+ {result.status === "failed" && (
574
+ <XIcon style={{ width: 20, height: 20, color: colors.icon }} />
575
+ )}
576
+ <Text weight="medium" style={{ color: colors.text }}>
577
+ {result.status === "running" && "Running interactions..."}
578
+ {result.status === "paused" && "Paused at breakpoint"}
579
+ {result.status === "passed" && "All interactions passed"}
580
+ {result.status === "failed" && "Interactions failed"}
581
+ </Text>
582
+ </Stack>
583
+ <Text size="xs" color="tertiary">
584
+ {result.steps.filter((s) => s.status === "passed").length}/{result.steps.length} steps
585
+ </Text>
586
+ </Stack>
587
+ </Alert.Body>
588
+ </Alert>
589
+ );
590
+ })()}
591
+
592
+ {/* Steps */}
593
+ {result.steps.length > 0 && (
594
+ <Stack direction="column" gap="xs" style={{ marginTop: '16px' }}>
595
+ <Stack direction="row" align="center" justify="between" style={{ marginBottom: '8px' }}>
596
+ <Text size="xs" weight="medium" color="tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
597
+ Steps
598
+ </Text>
599
+ {debugState.mode === 'debug' && (
600
+ <Text size="xs" style={{ color: '#ea580c' }}>
601
+ Debug mode - click gutter to set breakpoints
602
+ </Text>
603
+ )}
604
+ </Stack>
605
+ {result.steps.map((step, index) => {
606
+ const stepColors = getStatusColors(step.status);
607
+ return (
608
+ <div
609
+ key={index}
610
+ style={{
611
+ borderRadius: '8px',
612
+ border: step.status === 'passed' ? '1px solid var(--border)' : stepColors.border,
613
+ transition: 'background 0.15s',
614
+ display: 'flex',
615
+ background: step.status === 'passed' ? 'var(--bg-primary)' : stepColors.bg,
616
+ outline: debugState.currentStepIndex === index ? '2px solid #f97316' : undefined,
617
+ outlineOffset: debugState.currentStepIndex === index ? '0px' : undefined,
618
+ }}
619
+ >
620
+ {/* Breakpoint gutter - only show in debug mode */}
621
+ {debugState.mode === 'debug' && (
622
+ <button
623
+ onClick={(e) => {
624
+ e.stopPropagation();
625
+ toggleBreakpoint(index);
626
+ }}
627
+ style={{
628
+ width: '24px',
629
+ flexShrink: 0,
630
+ display: 'flex',
631
+ alignItems: 'center',
632
+ justifyContent: 'center',
633
+ border: 'none',
634
+ borderRight: '1px solid var(--border)',
635
+ background: hoveredBreakpoint === index ? 'var(--bg-hover)' : 'transparent',
636
+ transition: 'background 0.15s',
637
+ cursor: 'pointer',
638
+ padding: 0,
639
+ }}
640
+ onMouseEnter={() => setHoveredBreakpoint(index)}
641
+ onMouseLeave={() => setHoveredBreakpoint(null)}
642
+ title={debugState.breakpoints.has(index) ? "Remove breakpoint (F9)" : "Add breakpoint (F9)"}
643
+ >
644
+ {debugState.breakpoints.has(index) ? (
645
+ <BreakpointIcon style={{ width: 12, height: 12, color: '#ef4444' }} />
646
+ ) : (
647
+ <BreakpointEmptyIcon style={{ width: 12, height: 12, color: hoveredBreakpoint === index ? '#f87171' : 'var(--text-muted)' }} />
648
+ )}
649
+ </button>
650
+ )}
651
+
652
+ <div style={{ flex: 1 }}>
653
+ <button
654
+ onClick={() => step.error && toggleStep(index)}
655
+ style={{
656
+ width: '100%',
657
+ padding: '12px',
658
+ display: 'flex',
659
+ alignItems: 'center',
660
+ gap: '8px',
661
+ textAlign: 'left',
662
+ cursor: step.error ? 'pointer' : 'default',
663
+ border: 'none',
664
+ background: 'transparent',
665
+ color: 'inherit',
666
+ }}
667
+ disabled={!step.error}
668
+ >
669
+ {/* Current step indicator */}
670
+ {debugState.mode === 'debug' && debugState.currentStepIndex === index && (
671
+ <span style={{ color: '#f97316', fontWeight: 700 }}>&#9654;</span>
672
+ )}
673
+
674
+ {/* Status icon */}
675
+ {step.status === "pending" && (
676
+ <div style={{ width: '16px', height: '16px', borderRadius: '9999px', border: '2px solid var(--text-muted)' }} />
677
+ )}
678
+ {step.status === "running" && (
679
+ <LoadingIcon style={{ width: 16, height: 16, animation: 'spin 1s linear infinite', color: stepColors.icon }} />
680
+ )}
681
+ {step.status === "paused" && (
682
+ <PauseIcon style={{ width: 16, height: 16, color: stepColors.icon }} />
683
+ )}
684
+ {step.status === "passed" && (
685
+ <CheckIcon style={{ width: 16, height: 16, color: stepColors.icon }} />
686
+ )}
687
+ {step.status === "failed" && (
688
+ <XIcon style={{ width: 16, height: 16, color: stepColors.icon }} />
689
+ )}
690
+
691
+ {/* Step name */}
692
+ <Text size="sm" style={{ flex: 1, color: stepColors.text }}>
693
+ {step.name}
694
+ </Text>
695
+
696
+ {/* Duration */}
697
+ {step.duration !== undefined && (
698
+ <Text size="xs" color="tertiary">
699
+ {formatDuration(step.duration)}
700
+ </Text>
701
+ )}
702
+
703
+ {/* Expand icon for errors */}
704
+ {step.error && (
705
+ expandedSteps.has(index) ? (
706
+ <ChevronDownIcon style={{ width: 16, height: 16, color: 'var(--text-muted)' }} />
707
+ ) : (
708
+ <ChevronRightIcon style={{ width: 16, height: 16, color: 'var(--text-muted)' }} />
709
+ )
710
+ )}
711
+ </button>
712
+
713
+ {/* Error details */}
714
+ {step.error && expandedSteps.has(index) && (
715
+ <div style={{ padding: '0 12px 12px 36px' }}>
716
+ <pre style={{ fontSize: '12px', color: '#dc2626', background: 'color-mix(in srgb, #ef4444 10%, transparent)', padding: '8px', borderRadius: '4px', overflowX: 'auto', whiteSpace: 'pre-wrap' }}>
717
+ {step.error}
718
+ </pre>
719
+ </div>
720
+ )}
721
+ </div>
722
+ </div>
723
+ );
724
+ })}
725
+ </Stack>
726
+ )}
727
+
728
+ {/* Keyboard shortcuts help in debug mode */}
729
+ {debugState.mode === 'debug' && (
730
+ <Box padding="sm" background="secondary" rounded="lg" style={{ marginTop: '16px' }}>
731
+ <Text size="xs" weight="medium" color="tertiary" style={{ marginBottom: '4px' }}>Keyboard shortcuts:</Text>
732
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
733
+ <Text size="xs" color="tertiary"><Badge size="sm" variant="default">F5</Badge> Run with debugger</Text>
734
+ <Text size="xs" color="tertiary"><Badge size="sm" variant="default">F8</Badge> Continue</Text>
735
+ <Text size="xs" color="tertiary"><Badge size="sm" variant="default">F9</Badge> Toggle breakpoint</Text>
736
+ <Text size="xs" color="tertiary"><Badge size="sm" variant="default">F10</Badge> Step over</Text>
737
+ </div>
738
+ </Box>
739
+ )}
740
+
741
+ {/* Top-level error (if no steps failed) */}
742
+ {result.error && !result.steps.some((s) => s.status === "failed") && (
743
+ <Box style={{ marginTop: '16px' }}>
744
+ <Text size="xs" weight="medium" color="tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px' }}>
745
+ Error
746
+ </Text>
747
+ <pre style={{ fontSize: '12px', color: '#dc2626', background: 'color-mix(in srgb, #ef4444 10%, transparent)', padding: '12px', borderRadius: '4px', overflowX: 'auto', whiteSpace: 'pre-wrap' }}>
748
+ {result.error}
749
+ </pre>
750
+ </Box>
751
+ )}
752
+ </Stack>
753
+ )}
754
+ </PanelShell>
755
+ </div>
756
+ );
757
+ }