@fragments-sdk/cli 0.7.13 → 0.7.15

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 (47) hide show
  1. package/dist/bin.js +7 -7
  2. package/dist/{chunk-CRTN6BIW.js → chunk-QLTLLQBI.js} +2 -2
  3. package/dist/{chunk-TQOGBAOZ.js → chunk-WLXFE6XW.js} +91 -2
  4. package/dist/chunk-WLXFE6XW.js.map +1 -0
  5. package/dist/core/index.d.ts +44 -3
  6. package/dist/core/index.js +11 -3
  7. package/dist/{defineFragment-C6PFzZyo.d.ts → defineFragment-BI9KoPrs.d.ts} +1 -1
  8. package/dist/{generate-ZPERYZLF.js → generate-ICIPKCKV.js} +2 -2
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/{init-GID2DXB3.js → init-V42FFMUJ.js} +3 -3
  12. package/dist/mcp-bin.js +2 -2
  13. package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
  14. package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
  15. package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
  16. package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
  17. package/dist/{viewer-CNLZQUFO.js → viewer-7I4WGVU3.js} +60 -12
  18. package/dist/viewer-7I4WGVU3.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/core/__tests__/preview-runtime.test.tsx +111 -0
  21. package/src/core/index.ts +13 -0
  22. package/src/core/preview-runtime.tsx +144 -0
  23. package/src/viewer/components/App.tsx +6 -8
  24. package/src/viewer/components/FragmentRenderer.tsx +61 -0
  25. package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
  26. package/src/viewer/components/PreviewFrameHost.tsx +27 -60
  27. package/src/viewer/components/SkeletonLoader.tsx +114 -125
  28. package/src/viewer/components/ThemeProvider.tsx +24 -78
  29. package/src/viewer/components/VariantMatrix.tsx +3 -3
  30. package/src/viewer/entry.tsx +26 -2
  31. package/src/viewer/index.html +1 -1
  32. package/src/viewer/public/favicon.ico +0 -0
  33. package/src/viewer/server.ts +1 -0
  34. package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
  35. package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
  36. package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
  37. package/src/viewer/vite-plugin.ts +58 -1
  38. package/dist/chunk-TQOGBAOZ.js.map +0 -1
  39. package/dist/viewer-CNLZQUFO.js.map +0 -1
  40. package/src/viewer/components/StoryRenderer.tsx +0 -121
  41. /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
  42. /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
  43. /package/dist/{init-GID2DXB3.js.map → init-V42FFMUJ.js.map} +0 -0
  44. /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
  45. /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
  46. /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
  47. /package/dist/{tokens-A3BZIQPB.js.map → tokens-K2AGUUOJ.js.map} +0 -0
@@ -0,0 +1,144 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import type { VariantLoader, VariantRenderOptions } from "./types.js";
3
+
4
+ /**
5
+ * Minimal contract for rendering a preview variant.
6
+ * Compatible with fragment variants and Storybook-adapted variants.
7
+ */
8
+ export interface PreviewVariantLike {
9
+ render: (options?: VariantRenderOptions) => ReactNode;
10
+ loaders?: VariantLoader[];
11
+ }
12
+
13
+ export interface PreviewRuntimeState {
14
+ content: ReactNode | null;
15
+ isLoading: boolean;
16
+ error: Error | null;
17
+ loadedData?: Record<string, unknown>;
18
+ }
19
+
20
+ export interface PreviewRuntimeOptions {
21
+ variant?: PreviewVariantLike | null;
22
+ loadedData?: Record<string, unknown>;
23
+ }
24
+
25
+ const EMPTY_STATE: PreviewRuntimeState = {
26
+ content: null,
27
+ isLoading: false,
28
+ error: null,
29
+ loadedData: undefined,
30
+ };
31
+
32
+ function toError(error: unknown): Error {
33
+ return error instanceof Error ? error : new Error(String(error));
34
+ }
35
+
36
+ /**
37
+ * Execute all variant loaders and merge their payloads.
38
+ * `loadedData` is applied last so host-level overrides win.
39
+ */
40
+ export async function executeVariantLoaders(
41
+ loaders: VariantLoader[] | undefined,
42
+ loadedData?: Record<string, unknown>,
43
+ ): Promise<Record<string, unknown> | undefined> {
44
+ const hasLoaders = !!loaders && loaders.length > 0;
45
+ if (!hasLoaders) {
46
+ return loadedData;
47
+ }
48
+
49
+ const results = await Promise.all(loaders.map((loader) => loader()));
50
+ const mergedFromLoaders = results.reduce<Record<string, unknown>>(
51
+ (acc, result) => ({ ...acc, ...result }),
52
+ {},
53
+ );
54
+
55
+ return loadedData ? { ...mergedFromLoaders, ...loadedData } : mergedFromLoaders;
56
+ }
57
+
58
+ /**
59
+ * Resolve a full runtime state (loader execution + render) in one async call.
60
+ * This is useful for testing and for hook/component orchestration.
61
+ */
62
+ export async function resolvePreviewRuntimeState(
63
+ options: PreviewRuntimeOptions,
64
+ ): Promise<PreviewRuntimeState> {
65
+ const { variant, loadedData } = options;
66
+ if (!variant) {
67
+ return EMPTY_STATE;
68
+ }
69
+
70
+ try {
71
+ const mergedLoadedData = await executeVariantLoaders(variant.loaders, loadedData);
72
+ const content = variant.render({ loadedData: mergedLoadedData });
73
+ return {
74
+ content,
75
+ isLoading: false,
76
+ error: null,
77
+ loadedData: mergedLoadedData,
78
+ };
79
+ } catch (error) {
80
+ return {
81
+ content: null,
82
+ isLoading: false,
83
+ error: toError(error),
84
+ loadedData: undefined,
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Hook for rendering a preview variant with loader support.
91
+ */
92
+ export function usePreviewVariantRuntime(
93
+ options: PreviewRuntimeOptions,
94
+ ): PreviewRuntimeState {
95
+ const { variant, loadedData } = options;
96
+ const [state, setState] = useState<PreviewRuntimeState>(EMPTY_STATE);
97
+
98
+ useEffect(() => {
99
+ let cancelled = false;
100
+
101
+ if (!variant) {
102
+ setState(EMPTY_STATE);
103
+ return () => {
104
+ cancelled = true;
105
+ };
106
+ }
107
+
108
+ const hasLoaders = !!variant.loaders && variant.loaders.length > 0;
109
+ setState({
110
+ content: null,
111
+ isLoading: hasLoaders,
112
+ error: null,
113
+ loadedData: undefined,
114
+ });
115
+
116
+ resolvePreviewRuntimeState({ variant, loadedData }).then((nextState) => {
117
+ if (!cancelled) {
118
+ setState(nextState);
119
+ }
120
+ });
121
+
122
+ return () => {
123
+ cancelled = true;
124
+ };
125
+ }, [variant, loadedData]);
126
+
127
+ return state;
128
+ }
129
+
130
+ interface PreviewVariantRuntimeProps extends PreviewRuntimeOptions {
131
+ children: (state: PreviewRuntimeState) => ReactNode;
132
+ }
133
+
134
+ /**
135
+ * Render-prop component wrapper around `usePreviewVariantRuntime`.
136
+ */
137
+ export function PreviewVariantRuntime({
138
+ variant,
139
+ loadedData,
140
+ children,
141
+ }: PreviewVariantRuntimeProps) {
142
+ const state = usePreviewVariantRuntime({ variant, loadedData });
143
+ return <>{children(state)}</>;
144
+ }
@@ -20,20 +20,18 @@ import { PreviewToolbar } from "./PreviewToolbar.js";
20
20
  import { PreviewArea } from "./PreviewArea.js";
21
21
  import { BottomPanel } from "./BottomPanel.js";
22
22
  import { IsolatedRender } from "./IsolatedRender.js";
23
- import { StoryRenderer, LoaderIndicator } from "./StoryRenderer.js";
23
+ import { FragmentRenderer, LoaderIndicator } from "./FragmentRenderer.js";
24
24
  import { HealthDashboard } from "./HealthDashboard.js";
25
25
  import { useAllFigmaUrls } from "./FigmaEmbed.js";
26
26
  import { ActionCapture } from "./ActionCapture.js";
27
27
 
28
28
  // Fragments UI
29
- import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, Input, ThemeToggle } from "@fragments-sdk/ui";
29
+ import { Header, Stack, Text, Separator, Tooltip, Button, EmptyState, Box, Alert, Input, ThemeToggle, FragmentsLogo } from "@fragments-sdk/ui";
30
30
  import { DeviceMobile, GridFour, Rows } from "@phosphor-icons/react";
31
31
 
32
32
  // Icons
33
33
  import { EmptyIcon, FigmaIcon, CompareIcon } from "./Icons.js";
34
34
 
35
- // Logo
36
- import { fragmentsLogo } from "../assets/fragments-logo.js";
37
35
 
38
36
  function GitHubIcon() {
39
37
  return (
@@ -342,14 +340,14 @@ export function App({ fragments }: AppProps) {
342
340
 
343
341
  return (
344
342
  <ActionCapture onAction={useActionsRef.current.logAction}>
345
- <StoryRenderer variant={variant}>
343
+ <FragmentRenderer variant={variant}>
346
344
  {(content, isLoading, error) => {
347
345
  if (isLoading) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px' }}><LoaderIndicator /></div>;
348
346
  if (error) return <EmptyVariantMessage reason={`Error: ${error.message}`} variantName={variant.name} hint="Check the console for the full error stack trace." />;
349
347
  if (content === null || content === undefined) return <EmptyVariantMessage reason="render() returned null or undefined" variantName={variant.name} hint="The variant's render function didn't return any JSX." />;
350
348
  return content;
351
349
  }}
352
- </StoryRenderer>
350
+ </FragmentRenderer>
353
351
  </ActionCapture>
354
352
  );
355
353
  }, []);
@@ -605,7 +603,7 @@ function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef
605
603
  <Header.Trigger />
606
604
  <Header.Brand>
607
605
  <Stack direction="row" gap="sm" align="center">
608
- <img src={fragmentsLogo} alt="" width={20} height={20} style={{ display: 'block' }} />
606
+ <FragmentsLogo size={20} />
609
607
  <Text weight="medium" size="sm">{BRAND.name}</Text>
610
608
  <Text size="xs" color="tertiary">{showHealth ? 'health dashboard' : 'preview'}</Text>
611
609
  </Stack>
@@ -761,7 +759,7 @@ function TopToolbar({
761
759
  <Header.Trigger />
762
760
  <Header.Brand>
763
761
  <Stack direction="row" align="center" gap="sm">
764
- <img src={fragmentsLogo} alt="" width={20} height={20} style={{ display: 'block' }} />
762
+ <FragmentsLogo size={20} />
765
763
  <Text weight="medium" size="sm">{fragment.fragment.meta.name}</Text>
766
764
  <Text size="xs" color="tertiary">{fragment.fragment.meta.category}</Text>
767
765
  </Stack>
@@ -0,0 +1,61 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ PreviewVariantRuntime,
4
+ type FragmentVariant,
5
+ } from "../../core/index.js";
6
+
7
+ interface FragmentRendererProps {
8
+ /** The variant to render */
9
+ variant: FragmentVariant;
10
+ /** Children render function - receives rendered content */
11
+ children: (content: ReactNode | null, isLoading: boolean, error: Error | null) => ReactNode;
12
+ }
13
+
14
+ /**
15
+ * Component that handles async loader execution before rendering a fragment variant.
16
+ *
17
+ * If the variant has loaders:
18
+ * 1. Shows loading state while loaders execute
19
+ * 2. Merges all loaded data
20
+ * 3. Passes loaded data to render function
21
+ *
22
+ * If no loaders, renders immediately.
23
+ */
24
+ export function FragmentRenderer({ variant, children }: FragmentRendererProps) {
25
+ return (
26
+ <PreviewVariantRuntime variant={variant}>
27
+ {({ content, isLoading, error }) => children(content, isLoading, error)}
28
+ </PreviewVariantRuntime>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Loading indicator for fragment variants with loaders
34
+ */
35
+ export function LoaderIndicator() {
36
+ return (
37
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', fontSize: '14px' }}>
38
+ <svg
39
+ style={{ animation: 'spin 1s linear infinite', width: '16px', height: '16px' }}
40
+ xmlns="http://www.w3.org/2000/svg"
41
+ fill="none"
42
+ viewBox="0 0 24 24"
43
+ >
44
+ <circle
45
+ style={{ opacity: 0.25 }}
46
+ cx="12"
47
+ cy="12"
48
+ r="10"
49
+ stroke="currentColor"
50
+ strokeWidth="4"
51
+ />
52
+ <path
53
+ style={{ opacity: 0.75 }}
54
+ fill="currentColor"
55
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
56
+ />
57
+ </svg>
58
+ <span>Running loaders...</span>
59
+ </div>
60
+ );
61
+ }
@@ -70,9 +70,9 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
70
70
  const [frameError, setFrameError] = useState<string | null>(null);
71
71
  const [retryCount, setRetryCount] = useState(0);
72
72
  const [iframeKey, setIframeKey] = useState(0);
73
+ const [hasRenderedOnce, setHasRenderedOnce] = useState(false);
73
74
  const { isReady, isRendering, lastError, contentSize, render, setTheme, clearError } = usePreviewBridge(iframeRef);
74
75
  const lastRenderRef = useRef<string>('');
75
- const isFirstLoad = useRef(true);
76
76
  const [isHovered, setIsHovered] = useState(false);
77
77
 
78
78
  // Build the preview URL
@@ -82,7 +82,6 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
82
82
  const handleLoad = useCallback(() => {
83
83
  setIsLoading(false);
84
84
  setFrameError(null);
85
- isFirstLoad.current = false;
86
85
  }, []);
87
86
 
88
87
  // Handle iframe error
@@ -100,6 +99,7 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
100
99
  clearError();
101
100
  setRetryCount(c => c + 1);
102
101
  setIsLoading(true);
102
+ setHasRenderedOnce(false);
103
103
  lastRenderRef.current = ''; // Force re-render
104
104
  setIframeKey(k => k + 1); // Force iframe reload
105
105
  }, [retryCount, clearError]);
@@ -125,6 +125,7 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
125
125
  // Report content size changes
126
126
  useEffect(() => {
127
127
  if (contentSize) {
128
+ setHasRenderedOnce(true);
128
129
  onContentSize?.(contentSize);
129
130
  }
130
131
  }, [contentSize, onContentSize]);
@@ -142,11 +143,12 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
142
143
  const frameHeight = typeof height === 'number' ? `${height}px` : height;
143
144
  const frameMinHeight = typeof minHeight === 'number' ? `${minHeight}px` : minHeight;
144
145
 
145
- // Determine if we should show skeleton vs spinner
146
- // Skeleton for initial load, spinner for subsequent renders
147
- const showSkeleton = isLoading && isFirstLoad.current;
148
- const showSpinner = !showSkeleton && (isLoading || isRendering);
149
- const showContent = isReady && !isRendering && !frameError;
146
+ // Keep first-load skeleton visible until the first successful render signal.
147
+ // After first render, preserve content visibility and show spinner overlays
148
+ // for later renders to avoid white/blank flashes.
149
+ const showSkeleton = !hasRenderedOnce && !frameError;
150
+ const showSpinner = hasRenderedOnce && !frameError && (isLoading || isRendering);
151
+ const showContent = !frameError && (hasRenderedOnce || (isReady && !isRendering));
150
152
 
151
153
  return (
152
154
  <div
@@ -259,7 +261,7 @@ export const IsolatedPreviewFrame = memo(function IsolatedPreviewFrame({
259
261
  border: 'none',
260
262
  display: 'block',
261
263
  background: 'transparent',
262
- transition: 'opacity 150ms',
264
+ transition: 'opacity 120ms ease',
263
265
  opacity: showContent ? 1 : 0,
264
266
  }}
265
267
  // Security attributes
@@ -8,19 +8,15 @@
8
8
  * 4. Reports render status back to parent
9
9
  */
10
10
 
11
- import { useState, useEffect, useRef, type ReactNode } from 'react';
11
+ import { useState, useEffect, useRef } from 'react';
12
+ import {
13
+ usePreviewVariantRuntime,
14
+ type FragmentVariant,
15
+ } from '../../core/index.js';
12
16
  import { useFrameBridge } from '../hooks/usePreviewBridge.js';
13
17
 
14
18
  // Types for fragment data
15
- interface FragmentVariant {
16
- name: string;
17
- render: (options?: { loadedData?: Record<string, unknown> }) => ReactNode;
18
- loaders?: Array<() => Promise<Record<string, unknown>>>;
19
- props?: Record<string, unknown>;
20
- hasPlayFunction?: boolean;
21
- }
22
-
23
- interface FragmentDefinition {
19
+ interface PreviewFragmentDefinition {
24
20
  meta: {
25
21
  name: string;
26
22
  description?: string;
@@ -31,7 +27,7 @@ interface FragmentDefinition {
31
27
 
32
28
  interface FragmentItem {
33
29
  path: string;
34
- fragment: FragmentDefinition;
30
+ fragment: PreviewFragmentDefinition;
35
31
  }
36
32
 
37
33
  // Cached fragments
@@ -79,13 +75,13 @@ function findFragmentByPath(fragments: FragmentItem[], path: string): FragmentIt
79
75
  /**
80
76
  * Find a variant by name within a fragment
81
77
  */
82
- function findVariant(fragment: FragmentDefinition, variantName: string): FragmentVariant | undefined {
78
+ function findVariant(fragment: PreviewFragmentDefinition, variantName: string): FragmentVariant | undefined {
83
79
  return fragment.variants?.find(v => v.name === variantName);
84
80
  }
85
81
 
86
82
  type PreviewMode = 'centered' | 'full-bleed';
87
83
 
88
- function resolvePreviewMode(fragment: FragmentDefinition): PreviewMode {
84
+ function resolvePreviewMode(fragment: PreviewFragmentDefinition): PreviewMode {
89
85
  const name = fragment.meta.name.toLowerCase();
90
86
  const category = (fragment.meta.category || '').toLowerCase();
91
87
 
@@ -142,65 +138,36 @@ function VariantRenderer({
142
138
  onRendered: (width: number, height: number) => void;
143
139
  onError: (message: string, stack?: string) => void;
144
140
  }) {
145
- const [content, setContent] = useState<ReactNode | null>(null);
146
- const [isLoading, setIsLoading] = useState(false);
147
- const [error, setError] = useState<{ message: string; stack?: string } | null>(null);
141
+ const { content, isLoading, error } = usePreviewVariantRuntime({
142
+ variant,
143
+ loadedData: props,
144
+ });
148
145
  const containerRef = useRef<HTMLDivElement>(null);
149
146
  const hasReported = useRef(false);
147
+ const lastReportedError = useRef<string | null>(null);
150
148
 
151
149
  useEffect(() => {
152
150
  hasReported.current = false;
153
- setContent(null);
154
- setError(null);
155
-
156
- const hasLoaders = variant.loaders && variant.loaders.length > 0;
157
-
158
- if (!hasLoaders) {
159
- // No loaders - render immediately
160
- try {
161
- const rendered = variant.render({ loadedData: props });
162
- setContent(rendered);
163
- } catch (err) {
164
- const errorObj = err instanceof Error ? err : new Error(String(err));
165
- setError({ message: errorObj.message, stack: errorObj.stack });
166
- onError(errorObj.message, errorObj.stack);
167
- }
151
+ }, [variant, props, mode]);
152
+
153
+ useEffect(() => {
154
+ if (!error) {
155
+ lastReportedError.current = null;
168
156
  return;
169
157
  }
170
158
 
171
- // Has loaders - execute them first
172
- setIsLoading(true);
173
- let cancelled = false;
174
-
175
- async function executeLoaders() {
176
- try {
177
- const results = await Promise.all(variant.loaders!.map(loader => loader()));
178
- if (cancelled) return;
179
-
180
- const merged = results.reduce((acc, result) => ({ ...acc, ...result }), {});
181
- const rendered = variant.render({ loadedData: { ...merged, ...props } });
182
-
183
- setContent(rendered);
184
- setIsLoading(false);
185
- } catch (err) {
186
- if (cancelled) return;
187
- const errorObj = err instanceof Error ? err : new Error(String(err));
188
- setError({ message: errorObj.message, stack: errorObj.stack });
189
- setIsLoading(false);
190
- onError(errorObj.message, errorObj.stack);
191
- }
159
+ const signature = `${error.message}:${error.stack ?? ''}`;
160
+ if (lastReportedError.current === signature) {
161
+ return;
192
162
  }
193
163
 
194
- executeLoaders();
195
-
196
- return () => {
197
- cancelled = true;
198
- };
199
- }, [variant, props, onError]);
164
+ lastReportedError.current = signature;
165
+ onError(error.message, error.stack);
166
+ }, [error, onError]);
200
167
 
201
168
  // Report rendered size after content renders
202
169
  useEffect(() => {
203
- if (!content || hasReported.current) return;
170
+ if (!content || hasReported.current || isLoading || error) return;
204
171
 
205
172
  // Wait for next frame to ensure DOM has updated
206
173
  requestAnimationFrame(() => {
@@ -210,7 +177,7 @@ function VariantRenderer({
210
177
  onRendered(rect.width, rect.height);
211
178
  }
212
179
  });
213
- }, [content, onRendered]);
180
+ }, [content, error, isLoading, onRendered]);
214
181
 
215
182
  if (isLoading) {
216
183
  return <LoadingIndicator />;