@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.
- package/LICENSE +84 -0
- package/index.html +28 -0
- package/package.json +71 -0
- package/src/__tests__/a11y-fixes.test.ts +358 -0
- package/src/__tests__/jsx-parser.test.ts +502 -0
- package/src/__tests__/render-utils.test.ts +232 -0
- package/src/__tests__/style-utils.test.ts +404 -0
- package/src/app/index.ts +1 -0
- package/src/assets/fragments-logo.ts +4 -0
- package/src/assets/fragments_logo.png +0 -0
- package/src/components/AccessibilityPanel.tsx +1457 -0
- package/src/components/ActionCapture.tsx +172 -0
- package/src/components/ActionsPanel.tsx +332 -0
- package/src/components/AllVariantsPreview.tsx +78 -0
- package/src/components/App.tsx +604 -0
- package/src/components/BottomPanel.tsx +288 -0
- package/src/components/CodePanel.naming.test.tsx +59 -0
- package/src/components/CodePanel.tsx +118 -0
- package/src/components/CommandPalette.tsx +392 -0
- package/src/components/ComponentDocView.tsx +164 -0
- package/src/components/ComponentGraph.tsx +380 -0
- package/src/components/ComponentHeader.tsx +88 -0
- package/src/components/ContractPanel.tsx +241 -0
- package/src/components/DeviceMockup.tsx +156 -0
- package/src/components/EmptyVariantMessage.tsx +54 -0
- package/src/components/ErrorBoundary.tsx +97 -0
- package/src/components/FigmaEmbed.tsx +238 -0
- package/src/components/FragmentEditor.tsx +525 -0
- package/src/components/FragmentRenderer.tsx +61 -0
- package/src/components/HeaderSearch.tsx +24 -0
- package/src/components/HealthDashboard.tsx +441 -0
- package/src/components/HmrStatusIndicator.tsx +61 -0
- package/src/components/Icons.tsx +479 -0
- package/src/components/InteractionsPanel.tsx +757 -0
- package/src/components/IsolatedPreviewFrame.tsx +390 -0
- package/src/components/IsolatedRender.tsx +113 -0
- package/src/components/KeyboardShortcutsHelp.tsx +53 -0
- package/src/components/LandingPage.tsx +420 -0
- package/src/components/Layout.tsx +27 -0
- package/src/components/LeftSidebar.tsx +472 -0
- package/src/components/LoadErrorMessage.tsx +102 -0
- package/src/components/MultiViewportPreview.tsx +527 -0
- package/src/components/NoVariantsMessage.tsx +59 -0
- package/src/components/PanelShell.tsx +161 -0
- package/src/components/PerformancePanel.tsx +304 -0
- package/src/components/PreviewArea.tsx +254 -0
- package/src/components/PreviewAside.tsx +168 -0
- package/src/components/PreviewFrameHost.tsx +304 -0
- package/src/components/PreviewToolbar.tsx +80 -0
- package/src/components/PropsEditor.tsx +506 -0
- package/src/components/PropsTable.tsx +111 -0
- package/src/components/RelationsSection.tsx +88 -0
- package/src/components/ResizablePanel.tsx +271 -0
- package/src/components/RightSidebar.tsx +102 -0
- package/src/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/components/ScreenshotButton.tsx +90 -0
- package/src/components/ShadowPreview.tsx +204 -0
- package/src/components/Sidebar.tsx +169 -0
- package/src/components/SkeletonLoader.tsx +161 -0
- package/src/components/ThemeProvider.tsx +42 -0
- package/src/components/Toast.tsx +3 -0
- package/src/components/TokenStylePanel.tsx +699 -0
- package/src/components/TopToolbar.tsx +159 -0
- package/src/components/Untitled +1 -0
- package/src/components/UsageSection.tsx +95 -0
- package/src/components/VariantMatrix.tsx +391 -0
- package/src/components/VariantRenderer.tsx +131 -0
- package/src/components/VariantTabs.tsx +40 -0
- package/src/components/ViewerHeader.tsx +69 -0
- package/src/components/ViewerStateSync.tsx +52 -0
- package/src/components/ViewportSelector.tsx +172 -0
- package/src/components/WebMCPDevTools.tsx +503 -0
- package/src/components/WebMCPIntegration.tsx +47 -0
- package/src/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/components/_future/CreatePage.tsx +835 -0
- package/src/components/viewer-utils.ts +16 -0
- package/src/composition-renderer.ts +381 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/ui.ts +166 -0
- package/src/entry.tsx +335 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useA11yCache.ts +383 -0
- package/src/hooks/useA11yService.ts +364 -0
- package/src/hooks/useActions.ts +138 -0
- package/src/hooks/useAppState.ts +147 -0
- package/src/hooks/useCompiledFragments.ts +42 -0
- package/src/hooks/useFigmaIntegration.ts +132 -0
- package/src/hooks/useHmrStatus.ts +109 -0
- package/src/hooks/useKeyboardShortcuts.ts +270 -0
- package/src/hooks/usePreviewBridge.ts +347 -0
- package/src/hooks/useScrollSpy.ts +78 -0
- package/src/hooks/useShadowStyles.ts +221 -0
- package/src/hooks/useUrlState.ts +318 -0
- package/src/hooks/useViewSettings.ts +111 -0
- package/src/intelligence/healthReport.ts +505 -0
- package/src/intelligence/styleDrift.ts +340 -0
- package/src/intelligence/usageScanner.ts +309 -0
- package/src/jsx-parser.ts +486 -0
- package/src/preview-frame-entry.tsx +25 -0
- package/src/preview-frame.html +148 -0
- package/src/render-template.html +68 -0
- package/src/render-utils.ts +311 -0
- package/src/shared/ComponentDocContent.module.scss +10 -0
- package/src/shared/ComponentDocContent.module.scss.d.ts +2 -0
- package/src/shared/ComponentDocContent.tsx +274 -0
- package/src/shared/DocsHeaderBar.tsx +129 -0
- package/src/shared/DocsPageAsideHost.tsx +89 -0
- package/src/shared/DocsPageShell.tsx +124 -0
- package/src/shared/DocsSearchCommand.tsx +99 -0
- package/src/shared/DocsSidebarNav.tsx +66 -0
- package/src/shared/PropsTable.module.scss +68 -0
- package/src/shared/PropsTable.module.scss.d.ts +2 -0
- package/src/shared/PropsTable.tsx +76 -0
- package/src/shared/VariantPreviewCard.module.scss +114 -0
- package/src/shared/VariantPreviewCard.module.scss.d.ts +2 -0
- package/src/shared/VariantPreviewCard.tsx +137 -0
- package/src/shared/docs-data/index.ts +32 -0
- package/src/shared/docs-data/mcp-configs.ts +72 -0
- package/src/shared/docs-data/palettes.ts +75 -0
- package/src/shared/docs-data/setup-examples.ts +55 -0
- package/src/shared/docs-layout.scss +28 -0
- package/src/shared/docs-layout.scss.d.ts +2 -0
- package/src/shared/index.ts +34 -0
- package/src/shared/types.ts +53 -0
- package/src/style-utils.ts +414 -0
- package/src/styles/globals.css +278 -0
- package/src/types/a11y.ts +197 -0
- package/src/utils/a11y-fixes.ts +509 -0
- package/src/utils/actionExport.ts +372 -0
- package/src/utils/colorSchemes.ts +201 -0
- package/src/utils/contrast.ts +246 -0
- package/src/utils/detectRelationships.ts +256 -0
- package/src/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/webmcp/analytics.ts +165 -0
- package/src/webmcp/index.ts +3 -0
- package/src/webmcp/posthog-bridge.ts +39 -0
- package/src/webmcp/runtime-tools.ts +152 -0
- package/src/webmcp/scan-utils.ts +135 -0
- package/src/webmcp/use-tool-analytics.ts +69 -0
- package/src/webmcp/viewer-state.ts +45 -0
- 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 }}>▶</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
|
+
}
|