@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,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IsolatedPreviewFrame - Parent-side iframe wrapper for CSS isolation
|
|
3
|
+
*
|
|
4
|
+
* Renders component previews inside an iframe for complete CSS isolation.
|
|
5
|
+
* This prevents CSS conflicts between:
|
|
6
|
+
* - The viewer shell and the user's component library
|
|
7
|
+
* - Global styles from user components
|
|
8
|
+
* - Theme variables with same names
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { memo, useRef, useEffect, useState, useCallback } from "react";
|
|
12
|
+
import { Loading } from "@fragments-sdk/ui";
|
|
13
|
+
import { usePreviewBridge, type ParentMessage } from "../hooks/usePreviewBridge.js";
|
|
14
|
+
|
|
15
|
+
/** Maximum number of retry attempts */
|
|
16
|
+
const MAX_RETRIES = 3;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Module-level load queue — limits concurrent iframe loads so they don't
|
|
20
|
+
* all compete for the main thread parsing the same heavy preview bundle.
|
|
21
|
+
* With MAX_CONCURRENT=1, iframes load sequentially: the first preview
|
|
22
|
+
* appears in ~1-2s instead of all waiting ~5s.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_CONCURRENT_LOADS = 1;
|
|
25
|
+
let activeLoads = 0;
|
|
26
|
+
const pendingLoads: Array<() => void> = [];
|
|
27
|
+
|
|
28
|
+
function enqueueLoad(start: () => void): () => void {
|
|
29
|
+
if (activeLoads < MAX_CONCURRENT_LOADS) {
|
|
30
|
+
activeLoads++;
|
|
31
|
+
start();
|
|
32
|
+
} else {
|
|
33
|
+
pendingLoads.push(start);
|
|
34
|
+
}
|
|
35
|
+
// Return a cancel function for cleanup
|
|
36
|
+
return () => {
|
|
37
|
+
const idx = pendingLoads.indexOf(start);
|
|
38
|
+
if (idx !== -1) pendingLoads.splice(idx, 1);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function dequeueLoad() {
|
|
43
|
+
activeLoads--;
|
|
44
|
+
if (pendingLoads.length > 0 && activeLoads < MAX_CONCURRENT_LOADS) {
|
|
45
|
+
activeLoads++;
|
|
46
|
+
const next = pendingLoads.shift()!;
|
|
47
|
+
next();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IsolatedPreviewFrameProps {
|
|
52
|
+
/** Fragment path (file path) to render */
|
|
53
|
+
fragmentPath: string;
|
|
54
|
+
/** Variant name to render */
|
|
55
|
+
variantName: string;
|
|
56
|
+
/** Props to pass to the variant render function */
|
|
57
|
+
props?: Record<string, unknown>;
|
|
58
|
+
/** Theme for the preview */
|
|
59
|
+
theme: "light" | "dark";
|
|
60
|
+
/** Width of the preview (CSS value) */
|
|
61
|
+
width?: number | string;
|
|
62
|
+
/** Height of the preview (CSS value) */
|
|
63
|
+
height?: number | string;
|
|
64
|
+
/** Minimum height of the preview (only for fixed-size contexts like device mockups/grids) */
|
|
65
|
+
minHeight?: number | string;
|
|
66
|
+
/** Background style for the preview container */
|
|
67
|
+
background?: React.CSSProperties;
|
|
68
|
+
/** Additional class name for the container */
|
|
69
|
+
className?: string;
|
|
70
|
+
/** Additional styles for the container */
|
|
71
|
+
style?: React.CSSProperties;
|
|
72
|
+
/** Called when content size is reported */
|
|
73
|
+
onContentSize?: (size: { width: number; height: number }) => void;
|
|
74
|
+
/** Called when an error occurs */
|
|
75
|
+
onError?: (error: string) => void;
|
|
76
|
+
/** Unique key to force re-render on variant changes */
|
|
77
|
+
previewKey?: string;
|
|
78
|
+
/** Show size indicator on hover */
|
|
79
|
+
showSizeIndicator?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* IsolatedPreviewFrame renders a component preview inside an iframe
|
|
84
|
+
* for complete CSS isolation from the viewer shell.
|
|
85
|
+
*/
|
|
86
|
+
export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
|
|
87
|
+
fragmentPath,
|
|
88
|
+
variantName,
|
|
89
|
+
props,
|
|
90
|
+
theme,
|
|
91
|
+
width = "100%",
|
|
92
|
+
height = "auto",
|
|
93
|
+
minHeight,
|
|
94
|
+
background,
|
|
95
|
+
className = "",
|
|
96
|
+
style,
|
|
97
|
+
onContentSize,
|
|
98
|
+
onError,
|
|
99
|
+
previewKey,
|
|
100
|
+
showSizeIndicator = false,
|
|
101
|
+
}: IsolatedPreviewFrameProps) {
|
|
102
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
103
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
104
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
105
|
+
const [frameError, setFrameError] = useState<string | null>(null);
|
|
106
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
107
|
+
const [iframeKey, setIframeKey] = useState(0);
|
|
108
|
+
const [hasRenderedOnce, setHasRenderedOnce] = useState(false);
|
|
109
|
+
const [isInView, setIsInView] = useState(false);
|
|
110
|
+
const [canLoad, setCanLoad] = useState(false);
|
|
111
|
+
const hasDequeued = useRef(false);
|
|
112
|
+
const { isReady, isRendering, lastError, contentSize, render, setTheme, clearError } =
|
|
113
|
+
usePreviewBridge(iframeRef);
|
|
114
|
+
const lastRenderRef = useRef<string>("");
|
|
115
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
116
|
+
|
|
117
|
+
// Build the preview URL
|
|
118
|
+
const previewUrl = "/fragments/preview/";
|
|
119
|
+
|
|
120
|
+
// Step 1: IntersectionObserver detects when container is near viewport
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const el = containerRef.current;
|
|
123
|
+
if (!el) return;
|
|
124
|
+
|
|
125
|
+
const observer = new IntersectionObserver(
|
|
126
|
+
([entry]) => {
|
|
127
|
+
if (entry.isIntersecting) {
|
|
128
|
+
setIsInView(true);
|
|
129
|
+
observer.disconnect();
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{ rootMargin: "200px" }
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
observer.observe(el);
|
|
136
|
+
return () => observer.disconnect();
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
// Step 2: When in view, enter the load queue — iframe renders when slot opens
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!isInView) return;
|
|
142
|
+
const cancel = enqueueLoad(() => setCanLoad(true));
|
|
143
|
+
return cancel;
|
|
144
|
+
}, [isInView]);
|
|
145
|
+
|
|
146
|
+
// Handle iframe load — release queue slot so next iframe can start
|
|
147
|
+
const handleLoad = useCallback(() => {
|
|
148
|
+
setIsLoading(false);
|
|
149
|
+
setFrameError(null);
|
|
150
|
+
if (!hasDequeued.current) {
|
|
151
|
+
hasDequeued.current = true;
|
|
152
|
+
dequeueLoad();
|
|
153
|
+
}
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
// Handle iframe error — also release queue slot
|
|
157
|
+
const handleError = useCallback(() => {
|
|
158
|
+
setIsLoading(false);
|
|
159
|
+
setFrameError("Failed to load preview frame");
|
|
160
|
+
onError?.("Failed to load preview frame");
|
|
161
|
+
if (!hasDequeued.current) {
|
|
162
|
+
hasDequeued.current = true;
|
|
163
|
+
dequeueLoad();
|
|
164
|
+
}
|
|
165
|
+
}, [onError]);
|
|
166
|
+
|
|
167
|
+
// Handle retry
|
|
168
|
+
const handleRetry = useCallback(() => {
|
|
169
|
+
if (retryCount >= MAX_RETRIES) return;
|
|
170
|
+
|
|
171
|
+
setFrameError(null);
|
|
172
|
+
clearError();
|
|
173
|
+
setRetryCount((c) => c + 1);
|
|
174
|
+
setIsLoading(true);
|
|
175
|
+
setHasRenderedOnce(false);
|
|
176
|
+
hasDequeued.current = false;
|
|
177
|
+
lastRenderRef.current = ""; // Force re-render
|
|
178
|
+
setIframeKey((k) => k + 1); // Force iframe reload
|
|
179
|
+
}, [retryCount, clearError]);
|
|
180
|
+
|
|
181
|
+
// Send render request when ready or when render params change
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!isReady) return;
|
|
184
|
+
|
|
185
|
+
// Create a render key to detect changes
|
|
186
|
+
const renderKey = `${fragmentPath}:${variantName}:${JSON.stringify(props)}:${previewKey || ""}`;
|
|
187
|
+
if (renderKey === lastRenderRef.current) return;
|
|
188
|
+
lastRenderRef.current = renderKey;
|
|
189
|
+
|
|
190
|
+
render(fragmentPath, variantName, props);
|
|
191
|
+
}, [isReady, fragmentPath, variantName, props, previewKey, render]);
|
|
192
|
+
|
|
193
|
+
// Sync theme when it changes
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!isReady) return;
|
|
196
|
+
setTheme(theme);
|
|
197
|
+
}, [isReady, theme, setTheme]);
|
|
198
|
+
|
|
199
|
+
// Report content size changes
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (contentSize) {
|
|
202
|
+
setHasRenderedOnce(true);
|
|
203
|
+
onContentSize?.(contentSize);
|
|
204
|
+
}
|
|
205
|
+
}, [contentSize, onContentSize]);
|
|
206
|
+
|
|
207
|
+
// Report errors
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (lastError) {
|
|
210
|
+
setFrameError(lastError);
|
|
211
|
+
onError?.(lastError);
|
|
212
|
+
}
|
|
213
|
+
}, [lastError, onError]);
|
|
214
|
+
|
|
215
|
+
// Calculate iframe dimensions
|
|
216
|
+
const frameWidth = typeof width === "number" ? `${width}px` : width;
|
|
217
|
+
const frameHeight = typeof height === "number" ? `${height}px` : height;
|
|
218
|
+
const frameMinHeight =
|
|
219
|
+
minHeight != null ? (typeof minHeight === "number" ? `${minHeight}px` : minHeight) : undefined;
|
|
220
|
+
|
|
221
|
+
// Keep first-load skeleton visible until the first successful render signal.
|
|
222
|
+
// After first render, preserve content visibility and show spinner overlays
|
|
223
|
+
// for later renders to avoid white/blank flashes.
|
|
224
|
+
const showSkeleton = !hasRenderedOnce && !frameError;
|
|
225
|
+
const showSpinner = hasRenderedOnce && !frameError && (isLoading || isRendering);
|
|
226
|
+
const showContent = !frameError && (hasRenderedOnce || (isReady && !isRendering));
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div
|
|
230
|
+
ref={containerRef}
|
|
231
|
+
style={{
|
|
232
|
+
position: "relative",
|
|
233
|
+
width: frameWidth,
|
|
234
|
+
height: frameHeight,
|
|
235
|
+
minHeight: frameMinHeight,
|
|
236
|
+
...style,
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{/* Skeleton loading overlay (initial load, not in view, or queued) */}
|
|
240
|
+
{(showSkeleton || !canLoad) && (
|
|
241
|
+
<div
|
|
242
|
+
style={{
|
|
243
|
+
position: "absolute",
|
|
244
|
+
inset: 0,
|
|
245
|
+
zIndex: 10,
|
|
246
|
+
display: "flex",
|
|
247
|
+
alignItems: "center",
|
|
248
|
+
justifyContent: "center",
|
|
249
|
+
background: "var(--bg-primary, rgba(255, 255, 255, 0.95))",
|
|
250
|
+
padding: 32,
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<Loading size="md" />
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Spinner overlay (subsequent renders) */}
|
|
258
|
+
{showSpinner && (
|
|
259
|
+
<div
|
|
260
|
+
style={{
|
|
261
|
+
position: "absolute",
|
|
262
|
+
inset: 0,
|
|
263
|
+
zIndex: 10,
|
|
264
|
+
display: "flex",
|
|
265
|
+
alignItems: "center",
|
|
266
|
+
justifyContent: "center",
|
|
267
|
+
background: "color-mix(in srgb, var(--bg-primary, white) 80%, transparent)",
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
<div
|
|
271
|
+
style={{
|
|
272
|
+
display: "flex",
|
|
273
|
+
alignItems: "center",
|
|
274
|
+
gap: 8,
|
|
275
|
+
color: "var(--text-tertiary, #6b7280)",
|
|
276
|
+
fontSize: 14,
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
<Loading size="sm" />
|
|
280
|
+
<span>Rendering...</span>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* Error overlay */}
|
|
286
|
+
{frameError && !isLoading && (
|
|
287
|
+
<div
|
|
288
|
+
style={{
|
|
289
|
+
position: "absolute",
|
|
290
|
+
inset: 0,
|
|
291
|
+
zIndex: 10,
|
|
292
|
+
display: "flex",
|
|
293
|
+
alignItems: "center",
|
|
294
|
+
justifyContent: "center",
|
|
295
|
+
padding: "16px",
|
|
296
|
+
background: "rgba(254, 242, 242, 0.95)",
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
<div
|
|
300
|
+
style={{
|
|
301
|
+
background: "white",
|
|
302
|
+
border: "1px solid #fecaca",
|
|
303
|
+
borderRadius: 8,
|
|
304
|
+
padding: 16,
|
|
305
|
+
maxWidth: 400,
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
<div style={{ color: "#dc2626", fontWeight: 500, marginBottom: 8 }}>Preview Error</div>
|
|
309
|
+
<div
|
|
310
|
+
style={{
|
|
311
|
+
color: "#991b1b",
|
|
312
|
+
fontSize: 13,
|
|
313
|
+
marginBottom: retryCount < MAX_RETRIES ? 12 : 0,
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
{frameError}
|
|
317
|
+
</div>
|
|
318
|
+
{retryCount < MAX_RETRIES && (
|
|
319
|
+
<button
|
|
320
|
+
onClick={handleRetry}
|
|
321
|
+
style={{
|
|
322
|
+
padding: "6px 12px",
|
|
323
|
+
fontSize: 13,
|
|
324
|
+
fontWeight: 500,
|
|
325
|
+
color: "white",
|
|
326
|
+
background: "#dc2626",
|
|
327
|
+
border: "none",
|
|
328
|
+
borderRadius: 6,
|
|
329
|
+
cursor: "pointer",
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
Retry ({MAX_RETRIES - retryCount} remaining)
|
|
333
|
+
</button>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{/* The iframe — only loads when in view AND queue slot is available */}
|
|
340
|
+
{canLoad && (
|
|
341
|
+
<iframe
|
|
342
|
+
key={iframeKey}
|
|
343
|
+
ref={iframeRef}
|
|
344
|
+
src={previewUrl}
|
|
345
|
+
title={`Preview: ${variantName}`}
|
|
346
|
+
onLoad={handleLoad}
|
|
347
|
+
onError={handleError}
|
|
348
|
+
style={{
|
|
349
|
+
width: frameWidth,
|
|
350
|
+
height: frameHeight,
|
|
351
|
+
minHeight: frameMinHeight,
|
|
352
|
+
border: "none",
|
|
353
|
+
display: "block",
|
|
354
|
+
background: "transparent",
|
|
355
|
+
transition: "opacity 120ms ease",
|
|
356
|
+
opacity: showContent ? 1 : 0,
|
|
357
|
+
}}
|
|
358
|
+
// Security attributes
|
|
359
|
+
sandbox="allow-scripts allow-same-origin"
|
|
360
|
+
className={className}
|
|
361
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
362
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
365
|
+
|
|
366
|
+
{/* Size indicator */}
|
|
367
|
+
{showSizeIndicator && contentSize && (
|
|
368
|
+
<div
|
|
369
|
+
style={{
|
|
370
|
+
position: "absolute",
|
|
371
|
+
bottom: "4px",
|
|
372
|
+
right: "4px",
|
|
373
|
+
padding: "2px 6px",
|
|
374
|
+
borderRadius: "4px",
|
|
375
|
+
fontFamily: "monospace",
|
|
376
|
+
opacity: isHovered ? 1 : 0,
|
|
377
|
+
transition: "opacity 150ms",
|
|
378
|
+
background: "rgba(0, 0, 0, 0.5)",
|
|
379
|
+
color: "white",
|
|
380
|
+
fontSize: 10,
|
|
381
|
+
}}
|
|
382
|
+
>
|
|
383
|
+
{contentSize.width} × {contentSize.height}px
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
export default IsolatedPreviewFrame;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useMemo, useEffect, useState } from "react";
|
|
2
|
+
import type { FragmentDefinition } from '@fragments-sdk/core';
|
|
3
|
+
import { VariantRenderer } from "./VariantRenderer.js";
|
|
4
|
+
import { type ZoomLevel } from "../constants/ui.js";
|
|
5
|
+
|
|
6
|
+
interface IsolatedRenderProps {
|
|
7
|
+
fragments: Array<{ path: string; fragment: FragmentDefinition }>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Isolated render component for screenshot capture and standalone viewing.
|
|
12
|
+
* Renders a single variant with minimal UI for visual testing.
|
|
13
|
+
* URL params: ?isolated=true&component=Name&variant=VariantName&zoom=100&theme=light
|
|
14
|
+
*/
|
|
15
|
+
export function IsolatedRender({ fragments }: IsolatedRenderProps) {
|
|
16
|
+
const [ready, setReady] = useState(false);
|
|
17
|
+
|
|
18
|
+
// Parse query parameters
|
|
19
|
+
const params = useMemo(() => {
|
|
20
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
21
|
+
const zoomParam = parseInt(searchParams.get("zoom") || "100", 10);
|
|
22
|
+
return {
|
|
23
|
+
component: searchParams.get("component"),
|
|
24
|
+
variant: searchParams.get("variant"),
|
|
25
|
+
theme: searchParams.get("theme") || "light",
|
|
26
|
+
zoom: [50, 75, 100, 150, 200].includes(zoomParam) ? zoomParam as ZoomLevel : 100 as ZoomLevel,
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
// Find the matching fragment and variant
|
|
31
|
+
const match = useMemo(() => {
|
|
32
|
+
if (!params.component || !params.variant) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fragment = fragments.find(
|
|
37
|
+
(s) => s.fragment.meta.name === params.component
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (!fragment) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const variant = fragment.fragment.variants.find(
|
|
45
|
+
(v) => v.name === params.variant
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!variant) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { fragment: fragment.fragment, variant };
|
|
53
|
+
}, [fragments, params]);
|
|
54
|
+
|
|
55
|
+
// Apply theme
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
document.documentElement.setAttribute("data-theme", params.theme);
|
|
58
|
+
|
|
59
|
+
// Signal ready after a short delay for fonts and styles to settle
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
setReady(true);
|
|
62
|
+
}, 50);
|
|
63
|
+
|
|
64
|
+
return () => clearTimeout(timer);
|
|
65
|
+
}, [params.theme]);
|
|
66
|
+
|
|
67
|
+
// Error state - missing component or variant
|
|
68
|
+
if (!params.component || !params.variant) {
|
|
69
|
+
return (
|
|
70
|
+
<div style={{ padding: '16px', color: '#ef4444', fontFamily: 'monospace', fontSize: '14px' }}>
|
|
71
|
+
Error: Missing component or variant parameter
|
|
72
|
+
<pre style={{ marginTop: '8px', fontSize: '12px' }}>
|
|
73
|
+
Required: ?component=ComponentName&variant=VariantName
|
|
74
|
+
</pre>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Error state - component/variant not found
|
|
80
|
+
if (!match) {
|
|
81
|
+
return (
|
|
82
|
+
<div style={{ padding: '16px', color: '#ef4444', fontFamily: 'monospace', fontSize: '14px' }}>
|
|
83
|
+
Error: Component "{params.component}" variant "{params.variant}" not
|
|
84
|
+
found
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Render the variant in isolation
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
id="isolated-render"
|
|
93
|
+
data-ready={ready}
|
|
94
|
+
style={{
|
|
95
|
+
minHeight: '100vh',
|
|
96
|
+
padding: '32px',
|
|
97
|
+
display: 'flex',
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
justifyContent: 'center',
|
|
100
|
+
backgroundColor: 'var(--bg-primary)',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<div
|
|
104
|
+
style={{
|
|
105
|
+
transform: `scale(${params.zoom / 100})`,
|
|
106
|
+
transformOrigin: 'center center',
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<VariantRenderer variant={match.variant} />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard Shortcuts Help Modal
|
|
3
|
+
*
|
|
4
|
+
* Displays all available keyboard shortcuts in a modal overlay.
|
|
5
|
+
* Uses Fragments UI Dialog compound component.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Dialog, Stack, Text, Badge } from '@fragments-sdk/ui';
|
|
9
|
+
import { SHORTCUTS } from "../hooks/useKeyboardShortcuts.js";
|
|
10
|
+
|
|
11
|
+
interface KeyboardShortcutsHelpProps {
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function KeyboardShortcutsHelp({ isOpen, onClose }: KeyboardShortcutsHelpProps) {
|
|
17
|
+
return (
|
|
18
|
+
<Dialog open={isOpen} onOpenChange={(open: boolean) => { if (!open) onClose(); }}>
|
|
19
|
+
<Dialog.Content size="md">
|
|
20
|
+
<Dialog.Header>
|
|
21
|
+
<Dialog.Title>Keyboard Shortcuts</Dialog.Title>
|
|
22
|
+
<Dialog.Close />
|
|
23
|
+
</Dialog.Header>
|
|
24
|
+
<Dialog.Body>
|
|
25
|
+
<Stack direction="column" gap="sm">
|
|
26
|
+
{SHORTCUTS.map((shortcut, index) => (
|
|
27
|
+
<Stack key={index} direction="row" align="center" justify="between">
|
|
28
|
+
<Text size="sm" color="secondary">{shortcut.description}</Text>
|
|
29
|
+
<Stack direction="row" align="center" gap="xs">
|
|
30
|
+
{shortcut.keys.map((key, keyIndex) => (
|
|
31
|
+
<span key={keyIndex} style={{ display: 'flex', alignItems: 'center' }}>
|
|
32
|
+
{keyIndex > 0 && (
|
|
33
|
+
<Text size="xs" color="tertiary" style={{ margin: '0 4px' }}>or</Text>
|
|
34
|
+
)}
|
|
35
|
+
<Badge variant="default" size="sm">{key}</Badge>
|
|
36
|
+
</span>
|
|
37
|
+
))}
|
|
38
|
+
</Stack>
|
|
39
|
+
</Stack>
|
|
40
|
+
))}
|
|
41
|
+
</Stack>
|
|
42
|
+
</Dialog.Body>
|
|
43
|
+
<Dialog.Footer>
|
|
44
|
+
<Text size="xs" color="tertiary" style={{ textAlign: 'center', width: '100%' }}>
|
|
45
|
+
Press <Badge variant="default" size="sm">?</Badge> to toggle this help
|
|
46
|
+
</Text>
|
|
47
|
+
</Dialog.Footer>
|
|
48
|
+
</Dialog.Content>
|
|
49
|
+
</Dialog>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default KeyboardShortcutsHelp;
|