@fragments-sdk/cli 0.7.14 → 0.7.16

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 (67) 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-DIZ6UNBL.js +806 -0
  12. package/dist/init-DIZ6UNBL.js.map +1 -0
  13. package/dist/mcp-bin.js +2 -2
  14. package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
  15. package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
  16. package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
  17. package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
  18. package/dist/{viewer-CNLZQUFO.js → viewer-QKIAPTPG.js} +126 -15
  19. package/dist/viewer-QKIAPTPG.js.map +1 -0
  20. package/package.json +3 -2
  21. package/src/commands/init-framework.ts +414 -0
  22. package/src/commands/init.ts +41 -1
  23. package/src/core/__tests__/preview-runtime.test.tsx +111 -0
  24. package/src/core/index.ts +13 -0
  25. package/src/core/preview-runtime.tsx +144 -0
  26. package/src/viewer/components/App.tsx +8 -3
  27. package/src/viewer/components/FragmentRenderer.tsx +61 -0
  28. package/src/viewer/components/HealthDashboard.tsx +1 -1
  29. package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
  30. package/src/viewer/components/PreviewFrameHost.tsx +27 -60
  31. package/src/viewer/components/PropsTable.tsx +2 -2
  32. package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
  33. package/src/viewer/components/SkeletonLoader.tsx +114 -125
  34. package/src/viewer/components/VariantMatrix.tsx +3 -3
  35. package/src/viewer/components/ViewerStateSync.tsx +52 -0
  36. package/src/viewer/components/WebMCPDevTools.tsx +509 -0
  37. package/src/viewer/components/WebMCPIntegration.tsx +47 -0
  38. package/src/viewer/components/WebMCPStatusIndicator.tsx +60 -0
  39. package/src/viewer/entry.tsx +32 -5
  40. package/src/viewer/hooks/useA11yService.ts +1 -135
  41. package/src/viewer/hooks/useCompiledFragments.ts +42 -0
  42. package/src/viewer/index.html +1 -1
  43. package/src/viewer/public/favicon.ico +0 -0
  44. package/src/viewer/server.ts +59 -3
  45. package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
  46. package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
  47. package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
  48. package/src/viewer/vite-plugin.ts +76 -1
  49. package/src/viewer/webmcp/__tests__/analytics.test.ts +108 -0
  50. package/src/viewer/webmcp/analytics.ts +165 -0
  51. package/src/viewer/webmcp/index.ts +3 -0
  52. package/src/viewer/webmcp/posthog-bridge.ts +39 -0
  53. package/src/viewer/webmcp/runtime-tools.ts +152 -0
  54. package/src/viewer/webmcp/scan-utils.ts +135 -0
  55. package/src/viewer/webmcp/use-tool-analytics.ts +69 -0
  56. package/src/viewer/webmcp/viewer-state.ts +45 -0
  57. package/dist/chunk-TQOGBAOZ.js.map +0 -1
  58. package/dist/init-GID2DXB3.js +0 -498
  59. package/dist/init-GID2DXB3.js.map +0 -1
  60. package/dist/viewer-CNLZQUFO.js.map +0 -1
  61. package/src/viewer/components/StoryRenderer.tsx +0 -121
  62. /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
  63. /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
  64. /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
  65. /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
  66. /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
  67. /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,7 +20,7 @@ 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";
@@ -50,6 +50,8 @@ import { useActions } from "../hooks/useActions.js";
50
50
  import { useUrlState, findFragmentByName, findVariantIndex } from "../hooks/useUrlState.js";
51
51
  import { usePanelDock } from "./ResizablePanel.js";
52
52
  import { useTheme } from "./ThemeProvider.js";
53
+ import { WebMCPStatusIndicator } from "./WebMCPStatusIndicator.js";
54
+ import { ViewerStateSync } from "./ViewerStateSync.js";
53
55
 
54
56
  interface AppProps {
55
57
  fragments: Array<{ path: string; fragment: FragmentDefinition }>;
@@ -340,14 +342,14 @@ export function App({ fragments }: AppProps) {
340
342
 
341
343
  return (
342
344
  <ActionCapture onAction={useActionsRef.current.logAction}>
343
- <StoryRenderer variant={variant}>
345
+ <FragmentRenderer variant={variant}>
344
346
  {(content, isLoading, error) => {
345
347
  if (isLoading) return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px' }}><LoaderIndicator /></div>;
346
348
  if (error) return <EmptyVariantMessage reason={`Error: ${error.message}`} variantName={variant.name} hint="Check the console for the full error stack trace." />;
347
349
  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." />;
348
350
  return content;
349
351
  }}
350
- </StoryRenderer>
352
+ </FragmentRenderer>
351
353
  </ActionCapture>
352
354
  );
353
355
  }, []);
@@ -364,6 +366,7 @@ export function App({ fragments }: AppProps) {
364
366
 
365
367
  return (
366
368
  <>
369
+ <ViewerStateSync fragments={fragments} activeVariantIndex={safeVariantIndex} />
367
370
  <KeyboardShortcutsHelp isOpen={uiState.showShortcutsHelp} onClose={() => uiActions.setShortcutsHelp(false)} />
368
371
  <CommandPalette
369
372
  isOpen={uiState.showCommandPalette}
@@ -611,6 +614,7 @@ function ViewerHeader({ showHealth, searchQuery, onSearchChange, searchInputRef
611
614
  <HeaderSearch value={searchQuery} onChange={onSearchChange} inputRef={searchInputRef} />
612
615
  <Header.Spacer />
613
616
  <Header.Actions>
617
+ <WebMCPStatusIndicator />
614
618
  <ThemeToggle
615
619
  size="sm"
616
620
  value={resolvedTheme}
@@ -833,6 +837,7 @@ function TopToolbar({
833
837
  <Separator orientation="vertical" style={{ height: '16px' }} />
834
838
  </>
835
839
  )}
840
+ <WebMCPStatusIndicator />
836
841
  <ThemeToggle
837
842
  size="sm"
838
843
  value={resolvedTheme}
@@ -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
+ }
@@ -7,7 +7,7 @@
7
7
  import { useMemo, useState, useCallback, useEffect } from 'react';
8
8
  import type { FragmentDefinition } from '../../core/index.js';
9
9
  import type { ImpactValue } from 'axe-core';
10
- import { Badge, Progress, Stack, Text, Card, EmptyState, Table } from '@fragments-sdk/ui';
10
+ import { Badge, Progress, Stack, Text, Card, EmptyState } from '@fragments-sdk/ui';
11
11
  import {
12
12
  getAllA11yData,
13
13
  getA11ySummary,
@@ -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 />;
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
  import type { PropDefinition } from '../../core/index.js';
3
- import { Table, createColumns, Badge, Text, Stack } from '@fragments-sdk/ui';
3
+ import { DataTable, createColumns, Badge, Text, Stack } from '@fragments-sdk/ui';
4
4
  import { WarningIcon } from './Icons.js';
5
5
 
6
6
  interface PropsTableProps {
@@ -99,7 +99,7 @@ export function PropsTable({ props }: PropsTableProps) {
99
99
  return (
100
100
  <section id="props" style={{ scrollMarginTop: '96px' }}>
101
101
  <Text as="h2" size="md" weight="semibold" style={{ marginBottom: '16px' }}>Props</Text>
102
- <Table
102
+ <DataTable
103
103
  columns={columns}
104
104
  data={data}
105
105
  size="sm"
@@ -0,0 +1,17 @@
1
+ import { useMemo } from 'react';
2
+ import { useWebMCPTools } from '@fragments-sdk/webmcp/react';
3
+ import { useCompiledFragments } from '../hooks/useCompiledFragments.js';
4
+ import { createRuntimeWebMCPTools } from '../webmcp/runtime-tools.js';
5
+
6
+ export function RuntimeToolsRegistrar() {
7
+ const { data } = useCompiledFragments();
8
+
9
+ const tools = useMemo(() => {
10
+ if (!data) return [];
11
+ return createRuntimeWebMCPTools({ compiledData: data });
12
+ }, [data]);
13
+
14
+ useWebMCPTools(tools);
15
+
16
+ return null;
17
+ }