@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,509 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import {
3
+ Tabs,
4
+ Select,
5
+ Textarea,
6
+ Button,
7
+ CodeBlock,
8
+ Badge,
9
+ Card,
10
+ Stack,
11
+ Box,
12
+ Text,
13
+ } from "@fragments-sdk/ui";
14
+ import { useWebMCPContext } from "@fragments-sdk/webmcp/react";
15
+ import type {
16
+ WebMCPTool,
17
+ JSONSchemaInput,
18
+ ToolCallEvent,
19
+ } from "@fragments-sdk/webmcp";
20
+ import { SPEC_VERSION } from "@fragments-sdk/webmcp";
21
+ import { useToolAnalytics } from "../webmcp/use-tool-analytics.js";
22
+
23
+ function generateInputSkeleton(tool: WebMCPTool): string {
24
+ const schema = tool.inputSchema as JSONSchemaInput | undefined;
25
+ if (!schema?.properties) return "{}";
26
+ const obj: Record<string, unknown> = {};
27
+ for (const [key, prop] of Object.entries(schema.properties)) {
28
+ if (prop.default !== undefined) obj[key] = prop.default;
29
+ else if (prop.enum?.length) obj[key] = prop.enum[0];
30
+ else if (prop.type === "string") obj[key] = "";
31
+ else if (prop.type === "number" || prop.type === "integer") obj[key] = 0;
32
+ else if (prop.type === "boolean") obj[key] = false;
33
+ else if (prop.type === "array") obj[key] = [];
34
+ else obj[key] = null;
35
+ }
36
+ return JSON.stringify(obj, null, 2);
37
+ }
38
+
39
+ function formatTimestamp(ts: number): string {
40
+ return new Date(ts).toLocaleTimeString();
41
+ }
42
+
43
+ function formatDuration(ms: number): string {
44
+ if (ms < 1000) return `${ms}ms`;
45
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
46
+ return `${(ms / 60_000).toFixed(1)}m`;
47
+ }
48
+
49
+ const MAX_ACTIVITY_ENTRIES = 50;
50
+
51
+ export function WebMCPDevTools() {
52
+ const { shimRegistry, isShim } = useWebMCPContext();
53
+ const { summary, refreshSummary } = useToolAnalytics();
54
+ const [tools, setTools] = useState<WebMCPTool[]>([]);
55
+ const [selectedTool, setSelectedTool] = useState<string>("");
56
+ const [inputJson, setInputJson] = useState("{}");
57
+ const [executing, setExecuting] = useState(false);
58
+ const [result, setResult] = useState<{ type: "success" | "error"; data: string } | null>(null);
59
+ const [activity, setActivity] = useState<ToolCallEvent[]>([]);
60
+ const [expandedEntry, setExpandedEntry] = useState<number | null>(null);
61
+ const activityRef = useRef(activity);
62
+ activityRef.current = activity;
63
+
64
+ // Sync tools from registry
65
+ useEffect(() => {
66
+ if (!shimRegistry) return;
67
+
68
+ const update = () => {
69
+ setTools(Array.from(shimRegistry.tools.values()));
70
+ };
71
+ update();
72
+
73
+ shimRegistry.addEventListener("toolchange", update);
74
+ return () => shimRegistry.removeEventListener("toolchange", update);
75
+ }, [shimRegistry]);
76
+
77
+ // Subscribe to toolcall events
78
+ useEffect(() => {
79
+ if (!shimRegistry) return;
80
+
81
+ const onToolCall = (event: ToolCallEvent) => {
82
+ setActivity((prev) => [event, ...prev].slice(0, MAX_ACTIVITY_ENTRIES));
83
+ };
84
+
85
+ shimRegistry.addEventListener("toolcall", onToolCall);
86
+ return () => shimRegistry.removeEventListener("toolcall", onToolCall);
87
+ }, [shimRegistry]);
88
+
89
+ // Update input skeleton when tool selection changes
90
+ useEffect(() => {
91
+ if (!selectedTool) return;
92
+ const tool = tools.find((t) => t.name === selectedTool);
93
+ if (tool) {
94
+ setInputJson(generateInputSkeleton(tool));
95
+ setResult(null);
96
+ }
97
+ }, [selectedTool, tools]);
98
+
99
+ // Auto-select first tool
100
+ useEffect(() => {
101
+ if (tools.length > 0 && !selectedTool) {
102
+ setSelectedTool(tools[0].name);
103
+ }
104
+ }, [tools, selectedTool]);
105
+
106
+ const handleExecute = useCallback(async () => {
107
+ const tool = tools.find((t) => t.name === selectedTool);
108
+ if (!tool) return;
109
+
110
+ setExecuting(true);
111
+ setResult(null);
112
+
113
+ try {
114
+ const input = JSON.parse(inputJson);
115
+ const output = await tool.execute(input, {} as any);
116
+ setResult({
117
+ type: "success",
118
+ data: JSON.stringify(output, null, 2),
119
+ });
120
+ } catch (err) {
121
+ setResult({
122
+ type: "error",
123
+ data: err instanceof Error ? err.message : String(err),
124
+ });
125
+ } finally {
126
+ setExecuting(false);
127
+ }
128
+ }, [tools, selectedTool, inputJson]);
129
+
130
+ const currentTool = tools.find((t) => t.name === selectedTool);
131
+ const schema = currentTool?.inputSchema as JSONSchemaInput | undefined;
132
+
133
+ return (
134
+ <Stack direction="column" gap="md" style={{ height: "100%" }}>
135
+ <Tabs defaultValue="tools">
136
+ <Tabs.List variant="underline">
137
+ <Tabs.Tab value="tools">Tools</Tabs.Tab>
138
+ <Tabs.Tab value="execute">Execute</Tabs.Tab>
139
+ <Tabs.Tab value="activity">
140
+ Activity{activity.length > 0 ? ` (${activity.length})` : ""}
141
+ </Tabs.Tab>
142
+ <Tabs.Tab value="analytics">Analytics</Tabs.Tab>
143
+ </Tabs.List>
144
+
145
+ {/* Tab 1: Tool Catalog */}
146
+ <Tabs.Panel value="tools">
147
+ <Stack direction="column" gap="md" style={{ paddingTop: "12px" }}>
148
+ {tools.length === 0 ? (
149
+ <Text color="secondary">No tools registered yet.</Text>
150
+ ) : (
151
+ <>
152
+ <Select
153
+ value={selectedTool}
154
+ onValueChange={setSelectedTool}
155
+ placeholder="Select a tool"
156
+ >
157
+ <Select.Trigger />
158
+ <Select.Content>
159
+ {tools.map((tool) => (
160
+ <Select.Item key={tool.name} value={tool.name}>
161
+ {tool.name}
162
+ </Select.Item>
163
+ ))}
164
+ </Select.Content>
165
+ </Select>
166
+
167
+ {currentTool && (
168
+ <Card variant="outlined">
169
+ <Card.Body>
170
+ <Stack direction="column" gap="sm">
171
+ <Text weight="semibold">{currentTool.name}</Text>
172
+ <Text color="secondary" size="sm">
173
+ {currentTool.description}
174
+ </Text>
175
+ {currentTool.annotations?.readOnlyHint && (
176
+ <div>
177
+ <Badge variant="info" size="sm">
178
+ readOnly
179
+ </Badge>
180
+ </div>
181
+ )}
182
+
183
+ {schema?.properties && (
184
+ <Box style={{ marginTop: "8px" }}>
185
+ <Text size="xs" weight="semibold" color="secondary" style={{ marginBottom: "4px" }}>
186
+ Parameters
187
+ </Text>
188
+ <Stack direction="column" gap="xs">
189
+ {Object.entries(schema.properties).map(
190
+ ([name, prop]) => (
191
+ <Box
192
+ key={name}
193
+ style={{
194
+ padding: "6px 8px",
195
+ borderRadius: "var(--radius-sm)",
196
+ backgroundColor: "var(--bg-secondary)",
197
+ }}
198
+ >
199
+ <Stack direction="row" gap="xs" align="center">
200
+ <Text
201
+ size="xs"
202
+ weight="medium"
203
+ style={{ fontFamily: "monospace" }}
204
+ >
205
+ {name}
206
+ </Text>
207
+ <Text size="2xs" color="tertiary">
208
+ {prop.type}
209
+ </Text>
210
+ {schema.required?.includes(name) && (
211
+ <Badge variant="warning" size="sm">
212
+ required
213
+ </Badge>
214
+ )}
215
+ </Stack>
216
+ {prop.description && (
217
+ <Text size="2xs" color="secondary" style={{ marginTop: "2px" }}>
218
+ {prop.description}
219
+ </Text>
220
+ )}
221
+ {prop.enum && (
222
+ <Text size="2xs" color="tertiary" style={{ marginTop: "2px", fontFamily: "monospace" }}>
223
+ enum: {prop.enum.join(" | ")}
224
+ </Text>
225
+ )}
226
+ {prop.default !== undefined && (
227
+ <Text size="2xs" color="tertiary" style={{ marginTop: "2px" }}>
228
+ default: {JSON.stringify(prop.default)}
229
+ </Text>
230
+ )}
231
+ </Box>
232
+ )
233
+ )}
234
+ </Stack>
235
+ </Box>
236
+ )}
237
+ </Stack>
238
+ </Card.Body>
239
+ </Card>
240
+ )}
241
+ </>
242
+ )}
243
+ </Stack>
244
+ </Tabs.Panel>
245
+
246
+ {/* Tab 2: Manual Execution */}
247
+ <Tabs.Panel value="execute">
248
+ <Stack direction="column" gap="md" style={{ paddingTop: "12px" }}>
249
+ <Select
250
+ value={selectedTool}
251
+ onValueChange={setSelectedTool}
252
+ placeholder="Select a tool"
253
+ >
254
+ <Select.Trigger />
255
+ <Select.Content>
256
+ {tools.map((tool) => (
257
+ <Select.Item key={tool.name} value={tool.name}>
258
+ {tool.name}
259
+ </Select.Item>
260
+ ))}
261
+ </Select.Content>
262
+ </Select>
263
+
264
+ <Textarea
265
+ label="Input (JSON)"
266
+ rows={8}
267
+ value={inputJson}
268
+ onChange={setInputJson}
269
+ resize="vertical"
270
+ style={{ fontFamily: "monospace", fontSize: "12px" }}
271
+ />
272
+
273
+ <Button
274
+ variant="primary"
275
+ onClick={handleExecute}
276
+ disabled={executing || !selectedTool}
277
+ data-testid="webmcp-execute-btn"
278
+ >
279
+ {executing ? "Executing..." : "Execute"}
280
+ </Button>
281
+
282
+ {result && (
283
+ <Box style={{ marginTop: "4px" }}>
284
+ {result.type === "success" ? (
285
+ <CodeBlock
286
+ code={result.data}
287
+ language="json"
288
+ maxHeight={300}
289
+ wordWrap
290
+ showCopy
291
+ compact
292
+ />
293
+ ) : (
294
+ <Box
295
+ style={{
296
+ padding: "12px",
297
+ borderRadius: "var(--radius-md)",
298
+ backgroundColor: "var(--bg-danger, #fef2f2)",
299
+ }}
300
+ >
301
+ <Text size="sm" style={{ color: "var(--color-danger, #dc2626)" }}>
302
+ {result.data}
303
+ </Text>
304
+ </Box>
305
+ )}
306
+ </Box>
307
+ )}
308
+ </Stack>
309
+ </Tabs.Panel>
310
+
311
+ {/* Tab 3: Activity Log */}
312
+ <Tabs.Panel value="activity">
313
+ <Stack direction="column" gap="xs" style={{ paddingTop: "12px" }}>
314
+ {activity.length === 0 ? (
315
+ <Text color="secondary" size="sm">
316
+ No tool calls yet. Execute a tool or wait for an agent to call
317
+ one.
318
+ </Text>
319
+ ) : (
320
+ activity.map((entry, i) => (
321
+ <Card
322
+ key={`${entry.timestamp}-${i}`}
323
+ variant="outlined"
324
+ style={{ padding: "8px 12px", cursor: "pointer" }}
325
+ onClick={() => setExpandedEntry(expandedEntry === i ? null : i)}
326
+ >
327
+ <Stack direction="row" gap="xs" align="center">
328
+ <Badge
329
+ size="sm"
330
+ variant={entry.error ? "error" : "success"}
331
+ >
332
+ {entry.toolName}
333
+ </Badge>
334
+ <Text size="2xs" color="tertiary">
335
+ {formatTimestamp(entry.timestamp)}
336
+ </Text>
337
+ <Text size="2xs" color="secondary">
338
+ {entry.durationMs}ms
339
+ </Text>
340
+ </Stack>
341
+ {expandedEntry === i && (
342
+ <Stack direction="column" gap="xs" style={{ marginTop: "8px" }}>
343
+ <Text size="2xs" weight="semibold" color="secondary">
344
+ Input
345
+ </Text>
346
+ <CodeBlock
347
+ code={JSON.stringify(entry.input, null, 2)}
348
+ language="json"
349
+ compact
350
+ maxHeight={150}
351
+ />
352
+ <Text size="2xs" weight="semibold" color="secondary">
353
+ {entry.error ? "Error" : "Output"}
354
+ </Text>
355
+ <CodeBlock
356
+ code={
357
+ entry.error
358
+ ? entry.error
359
+ : JSON.stringify(entry.output, null, 2)
360
+ }
361
+ language={entry.error ? "plaintext" : "json"}
362
+ compact
363
+ maxHeight={150}
364
+ />
365
+ </Stack>
366
+ )}
367
+ </Card>
368
+ ))
369
+ )}
370
+ </Stack>
371
+ </Tabs.Panel>
372
+
373
+ {/* Tab 4: Analytics */}
374
+ <Tabs.Panel value="analytics">
375
+ <Stack direction="column" gap="md" style={{ paddingTop: "12px" }}>
376
+ <Stack direction="row" gap="sm" align="center">
377
+ <Text weight="semibold" size="sm">Session Analytics</Text>
378
+ <Button variant="ghost" size="sm" onClick={refreshSummary}>
379
+ Refresh
380
+ </Button>
381
+ </Stack>
382
+
383
+ {!summary || summary.totalCalls === 0 ? (
384
+ <Text color="secondary" size="sm">
385
+ No tool calls recorded yet. Analytics will appear after tools are used.
386
+ </Text>
387
+ ) : (
388
+ <Stack direction="column" gap="md">
389
+ {/* Summary Cards */}
390
+ <Stack direction="row" gap="sm" style={{ flexWrap: 'wrap' }}>
391
+ <Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
392
+ <Card.Body>
393
+ <Text size="2xs" color="tertiary">Total Calls</Text>
394
+ <Text size="lg" weight="bold">{summary.totalCalls}</Text>
395
+ </Card.Body>
396
+ </Card>
397
+ <Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
398
+ <Card.Body>
399
+ <Text size="2xs" color="tertiary">Unique Tools</Text>
400
+ <Text size="lg" weight="bold">{summary.uniqueTools}</Text>
401
+ </Card.Body>
402
+ </Card>
403
+ <Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
404
+ <Card.Body>
405
+ <Text size="2xs" color="tertiary">Error Rate</Text>
406
+ <Text size="lg" weight="bold">{(summary.errorRate * 100).toFixed(0)}%</Text>
407
+ </Card.Body>
408
+ </Card>
409
+ <Card variant="outlined" style={{ flex: '1 1 120px', padding: '12px' }}>
410
+ <Card.Body>
411
+ <Text size="2xs" color="tertiary">Duration</Text>
412
+ <Text size="lg" weight="bold">{formatDuration(summary.sessionDurationMs)}</Text>
413
+ </Card.Body>
414
+ </Card>
415
+ </Stack>
416
+
417
+ {/* Tool Breakdown */}
418
+ <Box>
419
+ <Text size="xs" weight="semibold" color="secondary" style={{ marginBottom: "8px" }}>
420
+ Tool Usage
421
+ </Text>
422
+ <Stack direction="column" gap="xs">
423
+ {Object.entries(summary.toolStats)
424
+ .sort(([, a], [, b]) => b.callCount - a.callCount)
425
+ .map(([name, stat]) => (
426
+ <Box
427
+ key={name}
428
+ style={{
429
+ padding: "8px 10px",
430
+ borderRadius: "var(--radius-sm)",
431
+ backgroundColor: "var(--bg-secondary)",
432
+ }}
433
+ >
434
+ <Stack direction="row" gap="sm" align="center" style={{ justifyContent: "space-between" }}>
435
+ <Text size="xs" weight="medium" style={{ fontFamily: "monospace" }}>
436
+ {name}
437
+ </Text>
438
+ <Stack direction="row" gap="xs">
439
+ <Badge size="sm" variant="info">{stat.callCount}x</Badge>
440
+ <Text size="2xs" color="tertiary">avg {stat.avgDuration}ms</Text>
441
+ {stat.errorCount > 0 && (
442
+ <Badge size="sm" variant="error">{stat.errorCount} err</Badge>
443
+ )}
444
+ </Stack>
445
+ </Stack>
446
+ <Text size="2xs" color="tertiary" style={{ marginTop: "2px" }}>
447
+ p50: {stat.p50}ms | p95: {stat.p95}ms
448
+ </Text>
449
+ </Box>
450
+ ))}
451
+ </Stack>
452
+ </Box>
453
+
454
+ {/* Common Flows */}
455
+ {summary.commonFlows.length > 0 && (
456
+ <Box>
457
+ <Text size="xs" weight="semibold" color="secondary" style={{ marginBottom: "8px" }}>
458
+ Common Flows
459
+ </Text>
460
+ <Stack direction="column" gap="xs">
461
+ {summary.commonFlows.map((flow, i) => (
462
+ <Box
463
+ key={i}
464
+ style={{
465
+ padding: "6px 8px",
466
+ borderRadius: "var(--radius-sm)",
467
+ backgroundColor: "var(--bg-secondary)",
468
+ }}
469
+ >
470
+ <Stack direction="row" gap="xs" align="center">
471
+ <Text size="2xs" style={{ fontFamily: "monospace" }}>
472
+ {flow.sequence.join(' → ')}
473
+ </Text>
474
+ <Badge size="sm" variant="info">{flow.count}x</Badge>
475
+ </Stack>
476
+ </Box>
477
+ ))}
478
+ </Stack>
479
+ </Box>
480
+ )}
481
+ </Stack>
482
+ )}
483
+ </Stack>
484
+ </Tabs.Panel>
485
+ </Tabs>
486
+
487
+ {/* Protocol Status Footer */}
488
+ <Box
489
+ style={{
490
+ marginTop: "auto",
491
+ paddingTop: "12px",
492
+ borderTop: "1px solid var(--border-subtle)",
493
+ }}
494
+ >
495
+ <Stack direction="row" gap="sm" align="center">
496
+ <Badge variant="success" size="sm" dot>
497
+ {isShim ? "Connected" : "Native"}
498
+ </Badge>
499
+ <Text size="2xs" color="tertiary">
500
+ W3C Draft
501
+ </Text>
502
+ <Text size="2xs" color="tertiary">
503
+ {tools.length} tool{tools.length !== 1 ? "s" : ""}
504
+ </Text>
505
+ </Stack>
506
+ </Box>
507
+ </Stack>
508
+ );
509
+ }
@@ -0,0 +1,47 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { WebMCPProvider, useWebMCPContext } from "@fragments-sdk/webmcp/react";
3
+ import { useFragmentTools } from "@fragments-sdk/webmcp/fragments";
4
+ import { useCompiledFragments } from "../hooks/useCompiledFragments.js";
5
+ import { useToolAnalytics } from "../webmcp/use-tool-analytics.js";
6
+ import { RuntimeToolsRegistrar } from "./RuntimeToolsRegistrar.js";
7
+
8
+ function FragmentToolsRegistrar() {
9
+ const { data } = useCompiledFragments();
10
+ const { shimRegistry } = useWebMCPContext();
11
+ const { count } = useFragmentTools(data);
12
+
13
+ // Expose registry on window for E2E testing (dev-only)
14
+ useEffect(() => {
15
+ if (!shimRegistry || typeof window === "undefined") return;
16
+ (window as any).__WEBMCP_REGISTRY__ = shimRegistry;
17
+ (window as any).__WEBMCP_TOOL_COUNT__ = shimRegistry.tools.size;
18
+
19
+ const onToolChange = () => {
20
+ (window as any).__WEBMCP_TOOL_COUNT__ = shimRegistry.tools.size;
21
+ };
22
+ shimRegistry.addEventListener("toolchange", onToolChange);
23
+ return () => shimRegistry.removeEventListener("toolchange", onToolChange);
24
+ }, [shimRegistry]);
25
+
26
+ return null;
27
+ }
28
+
29
+ function AnalyticsWiring() {
30
+ useToolAnalytics();
31
+ return null;
32
+ }
33
+
34
+ interface WebMCPIntegrationProps {
35
+ children: ReactNode;
36
+ }
37
+
38
+ export function WebMCPIntegration({ children }: WebMCPIntegrationProps) {
39
+ return (
40
+ <WebMCPProvider devShim>
41
+ <FragmentToolsRegistrar />
42
+ <RuntimeToolsRegistrar />
43
+ <AnalyticsWiring />
44
+ {children}
45
+ </WebMCPProvider>
46
+ );
47
+ }
@@ -0,0 +1,60 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useWebMCPContext } from "@fragments-sdk/webmcp/react";
3
+ import { Badge, Drawer } from "@fragments-sdk/ui";
4
+ import { WebMCPDevTools } from "./WebMCPDevTools.js";
5
+
6
+ export function WebMCPStatusIndicator() {
7
+ const { shimRegistry, isSupported, isShim } = useWebMCPContext();
8
+ const [toolCount, setToolCount] = useState(0);
9
+ const [drawerOpen, setDrawerOpen] = useState(false);
10
+
11
+ useEffect(() => {
12
+ if (!shimRegistry) return;
13
+
14
+ const update = () => setToolCount(shimRegistry.tools.size);
15
+ update();
16
+
17
+ shimRegistry.addEventListener("toolchange", update);
18
+ return () => shimRegistry.removeEventListener("toolchange", update);
19
+ }, [shimRegistry]);
20
+
21
+ if (!isSupported || toolCount === 0) return null;
22
+
23
+ return (
24
+ <>
25
+ <button
26
+ onClick={() => setDrawerOpen(true)}
27
+ title={`WebMCP: ${toolCount} tool${toolCount !== 1 ? "s" : ""} registered`}
28
+ data-testid="webmcp-status"
29
+ style={{
30
+ display: "inline-flex",
31
+ alignItems: "center",
32
+ background: "none",
33
+ border: "none",
34
+ padding: 0,
35
+ cursor: "pointer",
36
+ }}
37
+ >
38
+ <Badge variant="success" size="sm" dot>
39
+ WebMCP {toolCount}
40
+ </Badge>
41
+ </button>
42
+
43
+ <Drawer open={drawerOpen} onOpenChange={setDrawerOpen} modal={false}>
44
+ <Drawer.Content side="right" size="lg">
45
+ <Drawer.Close />
46
+ <Drawer.Header>
47
+ <Drawer.Title>WebMCP DevTools</Drawer.Title>
48
+ <Drawer.Description>
49
+ {toolCount} tool{toolCount !== 1 ? "s" : ""} registered via{" "}
50
+ {isShim ? "dev shim" : "native API"}
51
+ </Drawer.Description>
52
+ </Drawer.Header>
53
+ <Drawer.Body>
54
+ <WebMCPDevTools />
55
+ </Drawer.Body>
56
+ </Drawer.Content>
57
+ </Drawer>
58
+ </>
59
+ );
60
+ }
@@ -4,6 +4,7 @@ import { App } from "./components/App.js";
4
4
  import { ThemeProvider } from "./components/ThemeProvider.js";
5
5
  import { ToastProvider } from "./components/Toast.js";
6
6
  import { AppSkeleton } from "./components/SkeletonLoader.js";
7
+ import { WebMCPIntegration } from "./components/WebMCPIntegration.js";
7
8
  // Fragments UI globals - base resets (box-sizing, body, scrollbars) + --fui-* CSS variables
8
9
  import "@fragments-sdk/ui";
9
10
  // Viewer-specific token aliases and utility classes
@@ -149,6 +150,8 @@ type FragmentItem = {
149
150
  let fragments: FragmentItem[] = window.__FRAGMENTS__ ?? [];
150
151
  let loadError: string | null = window.__FRAGMENTS_ERROR__ ?? null;
151
152
  let appRoot: Root | null = null;
153
+ const SKELETON_DELAY_MS = 120;
154
+ const MIN_SKELETON_DISPLAY_MS = 220;
152
155
 
153
156
  // Filter helper
154
157
  function filterValidFragments(items: FragmentItem[]): FragmentItem[] {
@@ -247,9 +250,11 @@ if (rootElement) {
247
250
  appRoot?.render(
248
251
  <ThemeProvider>
249
252
  <ToastProvider position="bottom-right" duration={3000}>
250
- <AppErrorBoundary>
251
- <App fragments={currentFragments} />
252
- </AppErrorBoundary>
253
+ <WebMCPIntegration>
254
+ <AppErrorBoundary>
255
+ <App fragments={currentFragments} />
256
+ </AppErrorBoundary>
257
+ </WebMCPIntegration>
253
258
  </ToastProvider>
254
259
  </ThemeProvider>
255
260
  );
@@ -257,11 +262,33 @@ if (rootElement) {
257
262
 
258
263
  // Show skeleton immediately if no fragments yet
259
264
  if (fragments.length === 0 && !loadError) {
260
- renderApp(true);
265
+ const loadStartedAt = Date.now();
266
+ let skeletonShown = false;
267
+
268
+ const skeletonTimer = setTimeout(() => {
269
+ skeletonShown = true;
270
+ renderApp(true);
271
+ }, SKELETON_DELAY_MS);
261
272
 
262
273
  // Load fragments asynchronously and re-render
263
274
  loadFragmentsFromVirtualModule().then(() => {
264
- renderApp(false);
275
+ clearTimeout(skeletonTimer);
276
+
277
+ if (!skeletonShown) {
278
+ renderApp(false);
279
+ return;
280
+ }
281
+
282
+ const elapsed = Date.now() - loadStartedAt;
283
+ const remaining = Math.max(MIN_SKELETON_DISPLAY_MS - elapsed, 0);
284
+ if (remaining === 0) {
285
+ renderApp(false);
286
+ return;
287
+ }
288
+
289
+ setTimeout(() => {
290
+ renderApp(false);
291
+ }, remaining);
265
292
  });
266
293
  } else {
267
294
  // We have fragments from window, render immediately