@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,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;