@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,159 @@
1
+ import type { RefObject } from "react";
2
+ import type { FragmentDefinition } from '@fragments-sdk/core';
3
+ import type { useViewSettings } from "../hooks/useViewSettings.js";
4
+ import type { useAppState } from "../hooks/useAppState.js";
5
+ import {
6
+ Header,
7
+ Stack,
8
+ Text,
9
+ Separator,
10
+ Tooltip,
11
+ Button,
12
+ ThemeToggle,
13
+ FragmentsLogo,
14
+ } from "@fragments-sdk/ui";
15
+ import { DeviceMobile, GridFour, SidebarSimple } from "@phosphor-icons/react";
16
+ import { GitHubIcon, FigmaIcon, CompareIcon } from "./Icons.js";
17
+ import { PreviewToolbar } from "./PreviewToolbar.js";
18
+ import { HeaderSearch } from "./HeaderSearch.js";
19
+ import { useTheme } from "./ThemeProvider.js";
20
+ import { WebMCPStatusIndicator } from "./WebMCPStatusIndicator.js";
21
+
22
+ /** Normalize category to Title Case for display */
23
+ function titleCase(str: string): string {
24
+ return str.replace(/\b\w/g, (c) => c.toUpperCase());
25
+ }
26
+
27
+ interface TopToolbarProps {
28
+ fragment: { path: string; fragment: FragmentDefinition };
29
+ viewSettings: ReturnType<typeof useViewSettings>;
30
+ uiState: ReturnType<typeof useAppState>["state"];
31
+ uiActions: ReturnType<typeof useAppState>["actions"];
32
+ figmaUrl?: string;
33
+ searchQuery: string;
34
+ onSearchChange: (value: string) => void;
35
+ searchInputRef: RefObject<HTMLInputElement>;
36
+ }
37
+
38
+ export function TopToolbar({
39
+ fragment,
40
+ viewSettings,
41
+ uiState,
42
+ uiActions,
43
+ figmaUrl,
44
+ searchQuery,
45
+ onSearchChange,
46
+ searchInputRef,
47
+ }: TopToolbarProps) {
48
+ const { setTheme, resolvedTheme } = useTheme();
49
+ return (
50
+ <Header aria-label="Component preview toolbar">
51
+ <Header.Trigger />
52
+ <Header.Brand>
53
+ <Stack direction="row" align="center" gap="sm">
54
+ <FragmentsLogo size={20} />
55
+ <Text weight="medium" size="sm">
56
+ {fragment.fragment.meta.name}
57
+ </Text>
58
+ <Text size="xs" color="tertiary">
59
+ {titleCase(fragment.fragment.meta.category || '')}
60
+ </Text>
61
+ </Stack>
62
+ </Header.Brand>
63
+ <HeaderSearch value={searchQuery} onChange={onSearchChange} inputRef={searchInputRef} />
64
+ <Header.Spacer />
65
+ <Header.Actions>
66
+ <PreviewToolbar zoom={viewSettings.zoom} onZoomChange={viewSettings.setZoom} />
67
+ <Separator orientation="vertical" style={{ height: "16px" }} />
68
+ <Tooltip content={uiState.showMatrixView ? "Disable matrix view" : "Enable matrix view"}>
69
+ <Button
70
+ variant={uiState.showMatrixView ? "secondary" : "ghost"}
71
+ size="sm"
72
+ icon
73
+ aria-pressed={uiState.showMatrixView}
74
+ aria-label="Toggle matrix view"
75
+ onClick={() => uiActions.setMatrixView(!uiState.showMatrixView)}
76
+ >
77
+ <GridFour size={16} />
78
+ </Button>
79
+ </Tooltip>
80
+ <Tooltip
81
+ content={uiState.showMultiViewport ? "Disable responsive view" : "Enable responsive view"}
82
+ >
83
+ <Button
84
+ variant={uiState.showMultiViewport ? "secondary" : "ghost"}
85
+ size="sm"
86
+ icon
87
+ aria-pressed={uiState.showMultiViewport}
88
+ aria-label="Toggle responsive view"
89
+ onClick={() => uiActions.setMultiViewport(!uiState.showMultiViewport)}
90
+ >
91
+ <DeviceMobile size={16} />
92
+ </Button>
93
+ </Tooltip>
94
+ <Separator orientation="vertical" style={{ height: "16px" }} />
95
+ {figmaUrl && (
96
+ <>
97
+ <Tooltip
98
+ content={
99
+ uiState.showComparison ? "Hide Figma comparison" : "Compare with Figma design"
100
+ }
101
+ >
102
+ <Button
103
+ variant={uiState.showComparison ? "secondary" : "ghost"}
104
+ size="sm"
105
+ icon
106
+ onClick={uiActions.toggleComparison}
107
+ >
108
+ <CompareIcon style={{ width: "16px", height: "16px" }} />
109
+ </Button>
110
+ </Tooltip>
111
+ <Tooltip content="View in Figma">
112
+ <Button
113
+ onClick={() => window.open(figmaUrl, "_blank", "noopener,noreferrer")}
114
+ variant="ghost"
115
+ size="sm"
116
+ icon
117
+ >
118
+ <FigmaIcon style={{ width: "16px", height: "16px" }} />
119
+ </Button>
120
+ </Tooltip>
121
+ <Separator orientation="vertical" style={{ height: "16px" }} />
122
+ </>
123
+ )}
124
+ <WebMCPStatusIndicator />
125
+ <Tooltip content={uiState.showAside ? "Hide side panel" : "Show side panel"}>
126
+ <Button
127
+ variant={uiState.showAside ? "secondary" : "ghost"}
128
+ size="sm"
129
+ icon
130
+ aria-pressed={uiState.showAside}
131
+ aria-label="Toggle side panel"
132
+ onClick={uiActions.toggleAside}
133
+ >
134
+ <SidebarSimple size={16} style={{ transform: "scaleX(-1)" }} />
135
+ </Button>
136
+ </Tooltip>
137
+ <Separator orientation="vertical" style={{ height: "16px" }} />
138
+ <ThemeToggle
139
+ size="sm"
140
+ value={resolvedTheme}
141
+ onValueChange={(value) => setTheme(value)}
142
+ aria-label={`Theme: ${resolvedTheme}`}
143
+ />
144
+ <Button
145
+ as="a"
146
+ variant="ghost"
147
+ size="sm"
148
+ icon
149
+ href="https://github.com/ConanMcN/fragments"
150
+ target="_blank"
151
+ rel="noopener noreferrer"
152
+ aria-label="View on GitHub"
153
+ >
154
+ <GitHubIcon />
155
+ </Button>
156
+ </Header.Actions>
157
+ </Header>
158
+ );
159
+ }
@@ -0,0 +1 @@
1
+ 400px
@@ -0,0 +1,95 @@
1
+ import type { FragmentUsage } from '@fragments-sdk/core';
2
+ import { CheckIcon, XIcon, AccessibilityIcon } from './Icons.js';
3
+
4
+ interface UsageSectionProps {
5
+ usage: FragmentUsage;
6
+ }
7
+
8
+ export function UsageSection({ usage }: UsageSectionProps) {
9
+ const hasWhen = usage.when && usage.when.length > 0;
10
+ const hasWhenNot = usage.whenNot && usage.whenNot.length > 0;
11
+ const hasGuidelines = usage.guidelines && usage.guidelines.length > 0;
12
+ const hasAccessibility = usage.accessibility && usage.accessibility.length > 0;
13
+
14
+ if (!hasWhen && !hasWhenNot && !hasGuidelines && !hasAccessibility) {
15
+ return null;
16
+ }
17
+
18
+ return (
19
+ <section id="usage" style={{ scrollMarginTop: '96px' }}>
20
+ <h2 style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '20px' }}>Usage</h2>
21
+
22
+ {/* When to use / When not to use */}
23
+ {(hasWhen || hasWhenNot) && (
24
+ <div style={{ display: 'grid', gridTemplateColumns: hasWhen && hasWhenNot ? '1fr 1fr' : '1fr', gap: '32px', marginBottom: '32px' }}>
25
+ {hasWhen && (
26
+ <div style={{ padding: '16px', borderRadius: '12px', background: 'var(--color-success-bg)', border: '1px solid rgba(16, 163, 127, 0.2)' }}>
27
+ <h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-success)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
28
+ <CheckIcon style={{ width: '16px', height: '16px' }} />
29
+ When to use
30
+ </h3>
31
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
32
+ {usage.when!.map((item, index) => (
33
+ <li key={index} style={{ fontSize: '13px', color: 'var(--text-primary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
34
+ <span style={{ color: 'var(--color-success)', marginTop: '6px', fontSize: '12px' }}>&#8226;</span>
35
+ <span>{item}</span>
36
+ </li>
37
+ ))}
38
+ </ul>
39
+ </div>
40
+ )}
41
+
42
+ {hasWhenNot && (
43
+ <div style={{ padding: '16px', borderRadius: '12px', background: 'var(--color-danger-bg)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
44
+ <h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-danger)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
45
+ <XIcon style={{ width: '16px', height: '16px' }} />
46
+ When not to use
47
+ </h3>
48
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
49
+ {usage.whenNot!.map((item, index) => (
50
+ <li key={index} style={{ fontSize: '13px', color: 'var(--text-primary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
51
+ <span style={{ color: 'var(--color-danger)', marginTop: '6px', fontSize: '12px' }}>&#8226;</span>
52
+ <span>{item}</span>
53
+ </li>
54
+ ))}
55
+ </ul>
56
+ </div>
57
+ )}
58
+ </div>
59
+ )}
60
+
61
+ {/* Guidelines */}
62
+ {hasGuidelines && (
63
+ <div style={{ marginBottom: '24px' }}>
64
+ <h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--text-primary)', marginBottom: '12px' }}>Guidelines</h3>
65
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
66
+ {usage.guidelines!.map((item, index) => (
67
+ <li key={index} style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
68
+ <span style={{ color: 'var(--color-accent)', marginTop: '6px', fontSize: '12px' }}>&#8226;</span>
69
+ <span>{item}</span>
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ </div>
74
+ )}
75
+
76
+ {/* Accessibility */}
77
+ {hasAccessibility && (
78
+ <div style={{ padding: '16px', borderRadius: '12px', border: '1px solid var(--border)', background: 'var(--bg-secondary)' }}>
79
+ <h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--text-primary)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
80
+ <AccessibilityIcon style={{ width: '16px', height: '16px', color: 'var(--text-secondary)' }} />
81
+ Accessibility
82
+ </h3>
83
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
84
+ {usage.accessibility!.map((item, index) => (
85
+ <li key={index} style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
86
+ <span style={{ color: 'var(--text-tertiary)', marginTop: '6px', fontSize: '12px' }}>&#8226;</span>
87
+ <span>{item}</span>
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ </div>
92
+ )}
93
+ </section>
94
+ );
95
+ }
@@ -0,0 +1,391 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Variant Matrix View - Display all variants in a grid
4
+ *
5
+ * Shows all variants of a component simultaneously, making it easy to:
6
+ * - Compare states/variants at a glance
7
+ * - Spot visual inconsistencies
8
+ * - Review all component states quickly
9
+ *
10
+ * Uses virtualization to only render visible variants for better performance.
11
+ */
12
+
13
+ import { useState, useMemo, useRef, useCallback } from "react";
14
+ import { useVirtualizer } from "@tanstack/react-virtual";
15
+ import { Box, Stack, Text, Button, Badge, EmptyState } from "@fragments-sdk/ui";
16
+ import type { FragmentVariant } from '@fragments-sdk/core';
17
+ import { ErrorBoundary } from "./ErrorBoundary.js";
18
+ import { FragmentRenderer, LoaderIndicator } from "./FragmentRenderer.js";
19
+ import { IsolatedPreviewFrame } from "./IsolatedPreviewFrame.js";
20
+ import { ShadowPreview } from "./ShadowPreview.js";
21
+ import { ChevronDownIcon } from "./Icons.js";
22
+
23
+ interface VariantMatrixProps {
24
+ /** All variants to display */
25
+ variants: FragmentVariant[];
26
+ /** Component name for error display */
27
+ componentName: string;
28
+ /** Fragment path for iframe rendering */
29
+ fragmentPath: string;
30
+ /** Current zoom level */
31
+ zoom: number;
32
+ /** Preview theme */
33
+ previewTheme: "light" | "dark";
34
+ /** Whether to use iframe isolation */
35
+ useIframeIsolation?: boolean;
36
+ /** Callback when a variant is clicked to focus on it */
37
+ onSelectVariant?: (index: number) => void;
38
+ }
39
+
40
+ type GridSize = "small" | "medium" | "large";
41
+
42
+ interface GridConfig {
43
+ gridTemplateColumns: string;
44
+ minHeight: string;
45
+ heightPx: number; // For virtualization
46
+ scale: number;
47
+ colCount: number; // Default column count for virtualization
48
+ }
49
+
50
+ const GRID_SIZES: Record<GridSize, GridConfig> = {
51
+ small: { gridTemplateColumns: "repeat(4, 1fr)", minHeight: "150px", heightPx: 150, scale: 0.5, colCount: 4 },
52
+ medium: { gridTemplateColumns: "repeat(3, 1fr)", minHeight: "200px", heightPx: 200, scale: 0.75, colCount: 3 },
53
+ large: { gridTemplateColumns: "repeat(2, 1fr)", minHeight: "300px", heightPx: 300, scale: 1, colCount: 2 },
54
+ };
55
+
56
+ /** Threshold for enabling virtualization */
57
+ const VIRTUALIZATION_THRESHOLD = 12;
58
+
59
+ export function VariantMatrix({
60
+ variants,
61
+ componentName,
62
+ fragmentPath,
63
+ zoom,
64
+ previewTheme,
65
+ useIframeIsolation = true,
66
+ onSelectVariant,
67
+ }: VariantMatrixProps) {
68
+ const [gridSize, setGridSize] = useState<GridSize>("medium");
69
+ const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
70
+ const scrollRef = useRef<HTMLDivElement>(null);
71
+
72
+ const gridConfig = GRID_SIZES[gridSize];
73
+ const effectiveScale = (zoom / 100) * gridConfig.scale;
74
+
75
+ // Determine if we should use virtualization
76
+ const useVirtualization = variants.length > VIRTUALIZATION_THRESHOLD;
77
+
78
+ // Calculate number of rows for virtualization
79
+ const columns = gridConfig.colCount;
80
+ const rowCount = Math.ceil(variants.length / columns);
81
+
82
+ // Row height includes card height + gap
83
+ const rowHeight = gridConfig.heightPx + 16; // 16px gap
84
+
85
+ const rowVirtualizer = useVirtualizer({
86
+ count: rowCount,
87
+ getScrollElement: () => scrollRef.current,
88
+ estimateSize: () => rowHeight,
89
+ overscan: 2, // Render 2 extra rows above/below for smooth scrolling
90
+ });
91
+
92
+ if (variants.length === 0) {
93
+ return (
94
+ <EmptyState style={{ height: '100%' }}>
95
+ <EmptyState.Title>No variants to display</EmptyState.Title>
96
+ </EmptyState>
97
+ );
98
+ }
99
+
100
+ return (
101
+ <Stack style={{ height: '100%' }}>
102
+ {/* Toolbar */}
103
+ <Box paddingX="md" paddingY="sm" borderBottom background="secondary" style={{ flexShrink: 0 }}>
104
+ <Stack direction="row" align="center" justify="between">
105
+ <Text size="sm" color="secondary">
106
+ {variants.length} variant{variants.length !== 1 ? "s" : ""}
107
+ {useVirtualization && <Text as="span" size="xs" color="tertiary"> (virtualized)</Text>}
108
+ </Text>
109
+ <Stack direction="row" align="center" gap="sm">
110
+ <Text size="xs" color="tertiary">Grid size:</Text>
111
+ <Stack direction="row" style={{ borderRadius: 6, border: '1px solid var(--border)', overflow: 'hidden' }}>
112
+ {(["small", "medium", "large"] as GridSize[]).map((size) => (
113
+ <Button
114
+ key={size}
115
+ variant={gridSize === size ? "secondary" : "ghost"}
116
+ size="sm"
117
+ onClick={() => setGridSize(size)}
118
+ style={{ textTransform: 'capitalize', borderRadius: 0 }}
119
+ >
120
+ {size}
121
+ </Button>
122
+ ))}
123
+ </Stack>
124
+ </Stack>
125
+ </Stack>
126
+ </Box>
127
+
128
+ {/* Grid - Virtualized or Regular */}
129
+ {useVirtualization ? (
130
+ <Box ref={scrollRef} overflow="auto" padding="md" style={{ flex: 1 }}>
131
+ <div
132
+ style={{
133
+ height: `${rowVirtualizer.getTotalSize()}px`,
134
+ width: "100%",
135
+ position: "relative",
136
+ }}
137
+ >
138
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
139
+ const startIndex = virtualRow.index * columns;
140
+ const rowVariants = variants.slice(startIndex, startIndex + columns);
141
+
142
+ return (
143
+ <div
144
+ key={virtualRow.key}
145
+ style={{
146
+ position: "absolute",
147
+ top: 0,
148
+ left: 0,
149
+ width: "100%",
150
+ height: `${virtualRow.size}px`,
151
+ transform: `translateY(${virtualRow.start}px)`,
152
+ }}
153
+ >
154
+ <div style={{
155
+ display: 'grid',
156
+ gap: 16,
157
+ gridTemplateColumns: gridConfig.gridTemplateColumns,
158
+ height: gridConfig.minHeight,
159
+ }}>
160
+ {rowVariants.map((variant, colIndex) => {
161
+ const index = startIndex + colIndex;
162
+ return (
163
+ <VariantCard
164
+ key={variant.name}
165
+ variant={variant}
166
+ index={index}
167
+ componentName={componentName}
168
+ fragmentPath={fragmentPath}
169
+ scale={effectiveScale}
170
+ minHeight={gridConfig.minHeight}
171
+ previewTheme={previewTheme}
172
+ useIframeIsolation={useIframeIsolation}
173
+ isHovered={hoveredIndex === index}
174
+ onHover={() => setHoveredIndex(index)}
175
+ onLeave={() => setHoveredIndex(null)}
176
+ onClick={() => onSelectVariant?.(index)}
177
+ />
178
+ );
179
+ })}
180
+ </div>
181
+ </div>
182
+ );
183
+ })}
184
+ </div>
185
+ </Box>
186
+ ) : (
187
+ <Box overflow="auto" padding="md" style={{ flex: 1 }}>
188
+ <div style={{
189
+ display: 'grid',
190
+ gap: 16,
191
+ gridTemplateColumns: gridConfig.gridTemplateColumns,
192
+ }}>
193
+ {variants.map((variant, index) => (
194
+ <VariantCard
195
+ key={variant.name}
196
+ variant={variant}
197
+ index={index}
198
+ componentName={componentName}
199
+ fragmentPath={fragmentPath}
200
+ scale={effectiveScale}
201
+ minHeight={gridConfig.minHeight}
202
+ previewTheme={previewTheme}
203
+ useIframeIsolation={useIframeIsolation}
204
+ isHovered={hoveredIndex === index}
205
+ onHover={() => setHoveredIndex(index)}
206
+ onLeave={() => setHoveredIndex(null)}
207
+ onClick={() => onSelectVariant?.(index)}
208
+ />
209
+ ))}
210
+ </div>
211
+ </Box>
212
+ )}
213
+ </Stack>
214
+ );
215
+ }
216
+
217
+ interface VariantCardProps {
218
+ variant: FragmentVariant;
219
+ index: number;
220
+ componentName: string;
221
+ fragmentPath: string;
222
+ scale: number;
223
+ minHeight: string;
224
+ previewTheme: "light" | "dark";
225
+ useIframeIsolation: boolean;
226
+ isHovered: boolean;
227
+ onHover: () => void;
228
+ onLeave: () => void;
229
+ onClick: () => void;
230
+ }
231
+
232
+ function VariantCard({
233
+ variant,
234
+ index,
235
+ componentName,
236
+ fragmentPath,
237
+ scale,
238
+ minHeight,
239
+ previewTheme,
240
+ useIframeIsolation,
241
+ isHovered,
242
+ onHover,
243
+ onLeave,
244
+ onClick,
245
+ }: VariantCardProps) {
246
+ return (
247
+ <div
248
+ style={{
249
+ position: 'relative',
250
+ borderRadius: 8,
251
+ border: isHovered
252
+ ? '2px solid #3b82f6'
253
+ : '1px solid var(--border)',
254
+ overflow: 'hidden',
255
+ transition: 'border-color 0.2s, box-shadow 0.2s',
256
+ cursor: 'pointer',
257
+ minHeight,
258
+ boxShadow: isHovered
259
+ ? '0 10px 15px -3px rgba(0,0,0,0.1), 0 0 0 3px rgba(59,130,246,0.2)'
260
+ : 'none',
261
+ }}
262
+ onMouseEnter={onHover}
263
+ onMouseLeave={onLeave}
264
+ onClick={onClick}
265
+ >
266
+ {/* Header overlay - keep inline styles (CSS art) */}
267
+ <div style={{
268
+ position: 'absolute',
269
+ top: 0,
270
+ left: 0,
271
+ right: 0,
272
+ zIndex: 10,
273
+ padding: '4px 8px',
274
+ background: 'linear-gradient(to bottom, rgba(0,0,0,0.6), transparent)',
275
+ }}>
276
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
277
+ <span style={{
278
+ fontSize: 12,
279
+ fontWeight: 500,
280
+ color: '#ffffff',
281
+ overflow: 'hidden',
282
+ textOverflow: 'ellipsis',
283
+ whiteSpace: 'nowrap',
284
+ }}>
285
+ {variant.name}
286
+ </span>
287
+ <span style={{ fontSize: 10, color: 'rgba(255,255,255,0.7)' }}>
288
+ #{index + 1}
289
+ </span>
290
+ </div>
291
+ </div>
292
+
293
+ {/* Click to view overlay */}
294
+ <div
295
+ style={{
296
+ position: 'absolute',
297
+ inset: 0,
298
+ zIndex: 10,
299
+ display: 'flex',
300
+ alignItems: 'center',
301
+ justifyContent: 'center',
302
+ background: 'rgba(0,0,0,0.4)',
303
+ transition: 'opacity 0.2s',
304
+ opacity: isHovered ? 1 : 0,
305
+ pointerEvents: isHovered ? 'auto' : 'none',
306
+ }}
307
+ >
308
+ <span style={{
309
+ padding: '6px 12px',
310
+ background: '#2563eb',
311
+ color: '#ffffff',
312
+ fontSize: 12,
313
+ fontWeight: 500,
314
+ borderRadius: 9999,
315
+ boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)',
316
+ }}>
317
+ Click to focus
318
+ </span>
319
+ </div>
320
+
321
+ {/* Preview content */}
322
+ <div
323
+ data-theme={previewTheme}
324
+ style={{
325
+ height: '100%',
326
+ width: '100%',
327
+ overflow: 'hidden',
328
+ display: 'flex',
329
+ alignItems: 'center',
330
+ justifyContent: 'center',
331
+ }}
332
+ >
333
+ {useIframeIsolation ? (
334
+ <IsolatedPreviewFrame
335
+ fragmentPath={fragmentPath}
336
+ variantName={variant.name}
337
+ theme={previewTheme}
338
+ width="100%"
339
+ height="100%"
340
+ minHeight={minHeight}
341
+ />
342
+ ) : (
343
+ <ShadowPreview theme={previewTheme} width="100%" height="100%" minHeight={minHeight}>
344
+ <div
345
+ style={{
346
+ padding: 16,
347
+ transform: `scale(${scale})`,
348
+ }}
349
+ >
350
+ <ErrorBoundary
351
+ componentName={componentName}
352
+ fallback={
353
+ <Text size="xs" style={{ color: 'var(--color-danger)' }}>
354
+ Error rendering variant
355
+ </Text>
356
+ }
357
+ >
358
+ <FragmentRenderer variant={variant}>
359
+ {(content, isLoading, error) => {
360
+ if (isLoading) {
361
+ return (
362
+ <Stack align="center" justify="center" style={{ padding: 16 }}>
363
+ <LoaderIndicator />
364
+ </Stack>
365
+ );
366
+ }
367
+ if (error) {
368
+ return (
369
+ <Text size="xs" style={{ color: 'var(--color-danger)', padding: 8 }}>
370
+ {error.message}
371
+ </Text>
372
+ );
373
+ }
374
+ return content;
375
+ }}
376
+ </FragmentRenderer>
377
+ </ErrorBoundary>
378
+ </div>
379
+ </ShadowPreview>
380
+ )}
381
+ </div>
382
+
383
+ {/* Tags/badges */}
384
+ {variant.hasPlayFunction && (
385
+ <div style={{ position: 'absolute', bottom: 8, right: 8, zIndex: 10 }}>
386
+ <Badge variant="info" size="sm">play</Badge>
387
+ </div>
388
+ )}
389
+ </div>
390
+ );
391
+ }