@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.
- package/LICENSE +84 -0
- package/index.html +28 -0
- package/package.json +71 -0
- package/src/__tests__/a11y-fixes.test.ts +358 -0
- package/src/__tests__/jsx-parser.test.ts +502 -0
- package/src/__tests__/render-utils.test.ts +232 -0
- package/src/__tests__/style-utils.test.ts +404 -0
- package/src/app/index.ts +1 -0
- package/src/assets/fragments-logo.ts +4 -0
- package/src/assets/fragments_logo.png +0 -0
- package/src/components/AccessibilityPanel.tsx +1457 -0
- package/src/components/ActionCapture.tsx +172 -0
- package/src/components/ActionsPanel.tsx +332 -0
- package/src/components/AllVariantsPreview.tsx +78 -0
- package/src/components/App.tsx +604 -0
- package/src/components/BottomPanel.tsx +288 -0
- package/src/components/CodePanel.naming.test.tsx +59 -0
- package/src/components/CodePanel.tsx +118 -0
- package/src/components/CommandPalette.tsx +392 -0
- package/src/components/ComponentDocView.tsx +164 -0
- package/src/components/ComponentGraph.tsx +380 -0
- package/src/components/ComponentHeader.tsx +88 -0
- package/src/components/ContractPanel.tsx +241 -0
- package/src/components/DeviceMockup.tsx +156 -0
- package/src/components/EmptyVariantMessage.tsx +54 -0
- package/src/components/ErrorBoundary.tsx +97 -0
- package/src/components/FigmaEmbed.tsx +238 -0
- package/src/components/FragmentEditor.tsx +525 -0
- package/src/components/FragmentRenderer.tsx +61 -0
- package/src/components/HeaderSearch.tsx +24 -0
- package/src/components/HealthDashboard.tsx +441 -0
- package/src/components/HmrStatusIndicator.tsx +61 -0
- package/src/components/Icons.tsx +479 -0
- package/src/components/InteractionsPanel.tsx +757 -0
- package/src/components/IsolatedPreviewFrame.tsx +390 -0
- package/src/components/IsolatedRender.tsx +113 -0
- package/src/components/KeyboardShortcutsHelp.tsx +53 -0
- package/src/components/LandingPage.tsx +420 -0
- package/src/components/Layout.tsx +27 -0
- package/src/components/LeftSidebar.tsx +472 -0
- package/src/components/LoadErrorMessage.tsx +102 -0
- package/src/components/MultiViewportPreview.tsx +527 -0
- package/src/components/NoVariantsMessage.tsx +59 -0
- package/src/components/PanelShell.tsx +161 -0
- package/src/components/PerformancePanel.tsx +304 -0
- package/src/components/PreviewArea.tsx +254 -0
- package/src/components/PreviewAside.tsx +168 -0
- package/src/components/PreviewFrameHost.tsx +304 -0
- package/src/components/PreviewToolbar.tsx +80 -0
- package/src/components/PropsEditor.tsx +506 -0
- package/src/components/PropsTable.tsx +111 -0
- package/src/components/RelationsSection.tsx +88 -0
- package/src/components/ResizablePanel.tsx +271 -0
- package/src/components/RightSidebar.tsx +102 -0
- package/src/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/components/ScreenshotButton.tsx +90 -0
- package/src/components/ShadowPreview.tsx +204 -0
- package/src/components/Sidebar.tsx +169 -0
- package/src/components/SkeletonLoader.tsx +161 -0
- package/src/components/ThemeProvider.tsx +42 -0
- package/src/components/Toast.tsx +3 -0
- package/src/components/TokenStylePanel.tsx +699 -0
- package/src/components/TopToolbar.tsx +159 -0
- package/src/components/Untitled +1 -0
- package/src/components/UsageSection.tsx +95 -0
- package/src/components/VariantMatrix.tsx +391 -0
- package/src/components/VariantRenderer.tsx +131 -0
- package/src/components/VariantTabs.tsx +40 -0
- package/src/components/ViewerHeader.tsx +69 -0
- package/src/components/ViewerStateSync.tsx +52 -0
- package/src/components/ViewportSelector.tsx +172 -0
- package/src/components/WebMCPDevTools.tsx +503 -0
- package/src/components/WebMCPIntegration.tsx +47 -0
- package/src/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/components/_future/CreatePage.tsx +835 -0
- package/src/components/viewer-utils.ts +16 -0
- package/src/composition-renderer.ts +381 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/ui.ts +166 -0
- package/src/entry.tsx +335 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useA11yCache.ts +383 -0
- package/src/hooks/useA11yService.ts +364 -0
- package/src/hooks/useActions.ts +138 -0
- package/src/hooks/useAppState.ts +147 -0
- package/src/hooks/useCompiledFragments.ts +42 -0
- package/src/hooks/useFigmaIntegration.ts +132 -0
- package/src/hooks/useHmrStatus.ts +109 -0
- package/src/hooks/useKeyboardShortcuts.ts +270 -0
- package/src/hooks/usePreviewBridge.ts +347 -0
- package/src/hooks/useScrollSpy.ts +78 -0
- package/src/hooks/useShadowStyles.ts +221 -0
- package/src/hooks/useUrlState.ts +318 -0
- package/src/hooks/useViewSettings.ts +111 -0
- package/src/intelligence/healthReport.ts +505 -0
- package/src/intelligence/styleDrift.ts +340 -0
- package/src/intelligence/usageScanner.ts +309 -0
- package/src/jsx-parser.ts +486 -0
- package/src/preview-frame-entry.tsx +25 -0
- package/src/preview-frame.html +148 -0
- package/src/render-template.html +68 -0
- package/src/render-utils.ts +311 -0
- package/src/shared/ComponentDocContent.module.scss +10 -0
- package/src/shared/ComponentDocContent.module.scss.d.ts +2 -0
- package/src/shared/ComponentDocContent.tsx +274 -0
- package/src/shared/DocsHeaderBar.tsx +129 -0
- package/src/shared/DocsPageAsideHost.tsx +89 -0
- package/src/shared/DocsPageShell.tsx +124 -0
- package/src/shared/DocsSearchCommand.tsx +99 -0
- package/src/shared/DocsSidebarNav.tsx +66 -0
- package/src/shared/PropsTable.module.scss +68 -0
- package/src/shared/PropsTable.module.scss.d.ts +2 -0
- package/src/shared/PropsTable.tsx +76 -0
- package/src/shared/VariantPreviewCard.module.scss +114 -0
- package/src/shared/VariantPreviewCard.module.scss.d.ts +2 -0
- package/src/shared/VariantPreviewCard.tsx +137 -0
- package/src/shared/docs-data/index.ts +32 -0
- package/src/shared/docs-data/mcp-configs.ts +72 -0
- package/src/shared/docs-data/palettes.ts +75 -0
- package/src/shared/docs-data/setup-examples.ts +55 -0
- package/src/shared/docs-layout.scss +28 -0
- package/src/shared/docs-layout.scss.d.ts +2 -0
- package/src/shared/index.ts +34 -0
- package/src/shared/types.ts +53 -0
- package/src/style-utils.ts +414 -0
- package/src/styles/globals.css +278 -0
- package/src/types/a11y.ts +197 -0
- package/src/utils/a11y-fixes.ts +509 -0
- package/src/utils/actionExport.ts +372 -0
- package/src/utils/colorSchemes.ts +201 -0
- package/src/utils/contrast.ts +246 -0
- package/src/utils/detectRelationships.ts +256 -0
- package/src/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/webmcp/analytics.ts +165 -0
- package/src/webmcp/index.ts +3 -0
- package/src/webmcp/posthog-bridge.ts +39 -0
- package/src/webmcp/runtime-tools.ts +152 -0
- package/src/webmcp/scan-utils.ts +135 -0
- package/src/webmcp/use-tool-analytics.ts +69 -0
- package/src/webmcp/viewer-state.ts +45 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color Contrast Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript WCAG 2.1 color contrast math — zero dependencies.
|
|
5
|
+
* Handles hex (#rgb, #rrggbb), rgb(), rgba(), hsl(), hsla().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RGB {
|
|
9
|
+
r: number;
|
|
10
|
+
g: number;
|
|
11
|
+
b: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a CSS color string into an RGB object.
|
|
16
|
+
* Supports: #rgb, #rrggbb, rgb(), rgba(), hsl(), hsla().
|
|
17
|
+
*/
|
|
18
|
+
export function parseColor(css: string): RGB {
|
|
19
|
+
const trimmed = css.trim().toLowerCase();
|
|
20
|
+
|
|
21
|
+
// Hex: #rgb or #rrggbb
|
|
22
|
+
const hexMatch = trimmed.match(/^#([0-9a-f]{3,8})$/);
|
|
23
|
+
if (hexMatch) {
|
|
24
|
+
const hex = hexMatch[1];
|
|
25
|
+
if (hex.length === 3) {
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
28
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
29
|
+
b: parseInt(hex[2] + hex[2], 16),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
33
|
+
return {
|
|
34
|
+
r: parseInt(hex.substring(0, 2), 16),
|
|
35
|
+
g: parseInt(hex.substring(2, 4), 16),
|
|
36
|
+
b: parseInt(hex.substring(4, 6), 16),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// rgb() / rgba()
|
|
42
|
+
const rgbMatch = trimmed.match(
|
|
43
|
+
/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
|
|
44
|
+
);
|
|
45
|
+
if (rgbMatch) {
|
|
46
|
+
return {
|
|
47
|
+
r: parseInt(rgbMatch[1], 10),
|
|
48
|
+
g: parseInt(rgbMatch[2], 10),
|
|
49
|
+
b: parseInt(rgbMatch[3], 10),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// hsl() / hsla()
|
|
54
|
+
const hslMatch = trimmed.match(
|
|
55
|
+
/^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/
|
|
56
|
+
);
|
|
57
|
+
if (hslMatch) {
|
|
58
|
+
return hslToRgb(
|
|
59
|
+
parseFloat(hslMatch[1]),
|
|
60
|
+
parseFloat(hslMatch[2]),
|
|
61
|
+
parseFloat(hslMatch[3])
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(`Cannot parse color: "${css}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert HSL values to RGB.
|
|
70
|
+
*/
|
|
71
|
+
function hslToRgb(h: number, s: number, l: number): RGB {
|
|
72
|
+
const sNorm = s / 100;
|
|
73
|
+
const lNorm = l / 100;
|
|
74
|
+
|
|
75
|
+
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
|
76
|
+
const hPrime = (((h % 360) + 360) % 360) / 60;
|
|
77
|
+
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
|
78
|
+
const m = lNorm - c / 2;
|
|
79
|
+
|
|
80
|
+
let r1 = 0, g1 = 0, b1 = 0;
|
|
81
|
+
if (hPrime < 1) { r1 = c; g1 = x; }
|
|
82
|
+
else if (hPrime < 2) { r1 = x; g1 = c; }
|
|
83
|
+
else if (hPrime < 3) { g1 = c; b1 = x; }
|
|
84
|
+
else if (hPrime < 4) { g1 = x; b1 = c; }
|
|
85
|
+
else if (hPrime < 5) { r1 = x; b1 = c; }
|
|
86
|
+
else { r1 = c; b1 = x; }
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
r: Math.round((r1 + m) * 255),
|
|
90
|
+
g: Math.round((g1 + m) * 255),
|
|
91
|
+
b: Math.round((b1 + m) * 255),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Linearize an sRGB channel value (0-255) for luminance calculation.
|
|
97
|
+
*/
|
|
98
|
+
function linearize(channel: number): number {
|
|
99
|
+
const srgb = channel / 255;
|
|
100
|
+
return srgb <= 0.04045
|
|
101
|
+
? srgb / 12.92
|
|
102
|
+
: Math.pow((srgb + 0.055) / 1.055, 2.4);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Calculate relative luminance per WCAG 2.1.
|
|
107
|
+
* @see https://www.w3.org/WAI/WCAG21/Techniques/general/G17#procedure
|
|
108
|
+
*/
|
|
109
|
+
export function relativeLuminance(r: number, g: number, b: number): number {
|
|
110
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Calculate the contrast ratio between two colors.
|
|
115
|
+
* Returns a value >= 1 (lighter / darker).
|
|
116
|
+
*/
|
|
117
|
+
export function contrastRatio(fg: RGB, bg: RGB): number {
|
|
118
|
+
const l1 = relativeLuminance(fg.r, fg.g, fg.b);
|
|
119
|
+
const l2 = relativeLuminance(bg.r, bg.g, bg.b);
|
|
120
|
+
const lighter = Math.max(l1, l2);
|
|
121
|
+
const darker = Math.min(l1, l2);
|
|
122
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check whether a contrast ratio meets WCAG 2.1 AA.
|
|
127
|
+
* Normal text: 4.5:1. Large text (18pt / 14pt bold): 3:1.
|
|
128
|
+
*/
|
|
129
|
+
export function meetsAA(ratio: number, isLargeText = false): boolean {
|
|
130
|
+
return ratio >= (isLargeText ? 3.0 : 4.5);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check whether a contrast ratio meets WCAG 2.1 AAA.
|
|
135
|
+
* Normal text: 7:1. Large text: 4.5:1.
|
|
136
|
+
*/
|
|
137
|
+
export function meetsAAA(ratio: number, isLargeText = false): boolean {
|
|
138
|
+
return ratio >= (isLargeText ? 4.5 : 7.0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Convert an RGB object back to a hex string.
|
|
143
|
+
*/
|
|
144
|
+
export function rgbToHex(r: number, g: number, b: number): string {
|
|
145
|
+
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
|
146
|
+
const hex = (v: number) => clamp(v).toString(16).padStart(2, '0');
|
|
147
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Suggest a fixed foreground color that meets the target contrast ratio
|
|
152
|
+
* against the given background.
|
|
153
|
+
*
|
|
154
|
+
* Uses binary search: hold bg fixed, adjust fg luminance until the target
|
|
155
|
+
* ratio is met. Returns the adjusted foreground hex color.
|
|
156
|
+
*/
|
|
157
|
+
export function suggestFix(
|
|
158
|
+
fg: RGB,
|
|
159
|
+
bg: RGB,
|
|
160
|
+
targetRatio: number
|
|
161
|
+
): string {
|
|
162
|
+
const bgLum = relativeLuminance(bg.r, bg.g, bg.b);
|
|
163
|
+
const fgLum = relativeLuminance(fg.r, fg.g, fg.b);
|
|
164
|
+
|
|
165
|
+
// Determine whether we need to go darker or lighter
|
|
166
|
+
const fgIsLighter = fgLum >= bgLum;
|
|
167
|
+
|
|
168
|
+
// Target luminance for the foreground to meet the required ratio
|
|
169
|
+
let targetLum: number;
|
|
170
|
+
if (fgIsLighter) {
|
|
171
|
+
// fg is lighter: ratio = (fgLum + 0.05) / (bgLum + 0.05)
|
|
172
|
+
// Try making fg lighter first
|
|
173
|
+
targetLum = targetRatio * (bgLum + 0.05) - 0.05;
|
|
174
|
+
if (targetLum > 1) {
|
|
175
|
+
// Can't go lighter enough — go darker instead
|
|
176
|
+
targetLum = (bgLum + 0.05) / targetRatio - 0.05;
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
// fg is darker: ratio = (bgLum + 0.05) / (fgLum + 0.05)
|
|
180
|
+
// Try making fg darker first
|
|
181
|
+
targetLum = (bgLum + 0.05) / targetRatio - 0.05;
|
|
182
|
+
if (targetLum < 0) {
|
|
183
|
+
// Can't go darker enough — go lighter instead
|
|
184
|
+
targetLum = targetRatio * (bgLum + 0.05) - 0.05;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
targetLum = Math.max(0, Math.min(1, targetLum));
|
|
189
|
+
|
|
190
|
+
// Scale the fg color channels proportionally to reach target luminance
|
|
191
|
+
return adjustToLuminance(fg, targetLum);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Adjust an RGB color to reach the target relative luminance.
|
|
196
|
+
* Scales channels proportionally, clamping at boundaries.
|
|
197
|
+
*/
|
|
198
|
+
function adjustToLuminance(color: RGB, targetLum: number): string {
|
|
199
|
+
const currentLum = relativeLuminance(color.r, color.g, color.b);
|
|
200
|
+
|
|
201
|
+
if (currentLum === 0) {
|
|
202
|
+
// Pure black — can only go to grayscale
|
|
203
|
+
// Solve: 0.2126*L + 0.7152*L + 0.0722*L = targetLum where L = linearize(v)
|
|
204
|
+
// => L = targetLum, v = delinearize(targetLum)
|
|
205
|
+
const v = delinearize(targetLum);
|
|
206
|
+
const channel = Math.round(v * 255);
|
|
207
|
+
return rgbToHex(channel, channel, channel);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Binary search for the right scale factor
|
|
211
|
+
let lo = 0;
|
|
212
|
+
let hi = 10;
|
|
213
|
+
let bestHex = rgbToHex(color.r, color.g, color.b);
|
|
214
|
+
let bestDiff = Infinity;
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < 40; i++) {
|
|
217
|
+
const mid = (lo + hi) / 2;
|
|
218
|
+
const r = Math.max(0, Math.min(255, Math.round(color.r * mid)));
|
|
219
|
+
const g = Math.max(0, Math.min(255, Math.round(color.g * mid)));
|
|
220
|
+
const b = Math.max(0, Math.min(255, Math.round(color.b * mid)));
|
|
221
|
+
const lum = relativeLuminance(r, g, b);
|
|
222
|
+
const diff = Math.abs(lum - targetLum);
|
|
223
|
+
|
|
224
|
+
if (diff < bestDiff) {
|
|
225
|
+
bestDiff = diff;
|
|
226
|
+
bestHex = rgbToHex(r, g, b);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (lum < targetLum) {
|
|
230
|
+
lo = mid;
|
|
231
|
+
} else {
|
|
232
|
+
hi = mid;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return bestHex;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reverse sRGB linearization: linear -> sRGB channel (0-1 range).
|
|
241
|
+
*/
|
|
242
|
+
function delinearize(linear: number): number {
|
|
243
|
+
return linear <= 0.0031308
|
|
244
|
+
? linear * 12.92
|
|
245
|
+
: 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
|
|
246
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Auto-detect component relationships from rendered elements
|
|
4
|
+
*
|
|
5
|
+
* Analyzes React elements to discover:
|
|
6
|
+
* - Composition relationships (components used within other components)
|
|
7
|
+
* - Sibling relationships (components in the same category)
|
|
8
|
+
* - Common usage patterns
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isValidElement, type ReactNode, type ReactElement, Children } from "react";
|
|
12
|
+
import type { FragmentDefinition, ComponentRelation, RelationshipType } from '@fragments-sdk/core';
|
|
13
|
+
|
|
14
|
+
interface DetectedRelationship {
|
|
15
|
+
component: string;
|
|
16
|
+
relationship: RelationshipType;
|
|
17
|
+
note: string;
|
|
18
|
+
confidence: number; // 0-1, how confident we are in this relationship
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Cache for relationship detection to avoid repeated expensive render() calls
|
|
22
|
+
const relationshipCache = new Map<string, { timestamp: number; relationships: DetectedRelationship[] }>();
|
|
23
|
+
const CACHE_TTL = 60000; // 1 minute cache
|
|
24
|
+
|
|
25
|
+
// Cache for known component names (expensive to recompute for 98+ components)
|
|
26
|
+
let knownComponentsCache: { count: number; set: Set<string> } | null = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract component name from a React element
|
|
30
|
+
*/
|
|
31
|
+
function getComponentName(element: ReactElement): string | null {
|
|
32
|
+
const type = element.type;
|
|
33
|
+
|
|
34
|
+
if (typeof type === "string") {
|
|
35
|
+
// HTML element, not a component
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof type === "function") {
|
|
40
|
+
// Function component or class component
|
|
41
|
+
return type.displayName || type.name || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof type === "object" && type !== null) {
|
|
45
|
+
// Could be forwardRef, memo, etc.
|
|
46
|
+
const innerType = (type as { $$typeof?: symbol; type?: unknown; render?: unknown })?.type ||
|
|
47
|
+
(type as { render?: unknown })?.render;
|
|
48
|
+
if (typeof innerType === "function") {
|
|
49
|
+
return (innerType as { displayName?: string; name?: string }).displayName ||
|
|
50
|
+
(innerType as { name?: string }).name || null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recursively collect all component names from an element tree
|
|
59
|
+
*/
|
|
60
|
+
function collectComponentNames(
|
|
61
|
+
element: ReactNode,
|
|
62
|
+
collected: Set<string> = new Set()
|
|
63
|
+
): Set<string> {
|
|
64
|
+
if (!isValidElement(element)) {
|
|
65
|
+
return collected;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const name = getComponentName(element as ReactElement);
|
|
69
|
+
if (name) {
|
|
70
|
+
collected.add(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Traverse children
|
|
74
|
+
const children = (element.props as { children?: ReactNode })?.children;
|
|
75
|
+
if (children) {
|
|
76
|
+
Children.forEach(children, (child) => {
|
|
77
|
+
collectComponentNames(child, collected);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return collected;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get cached set of known component names
|
|
86
|
+
*/
|
|
87
|
+
function getKnownComponents(
|
|
88
|
+
allFragments: Array<{ path: string; fragment: FragmentDefinition }>
|
|
89
|
+
): Set<string> {
|
|
90
|
+
// Invalidate cache if fragment count changed
|
|
91
|
+
if (!knownComponentsCache || knownComponentsCache.count !== allFragments.length) {
|
|
92
|
+
knownComponentsCache = {
|
|
93
|
+
count: allFragments.length,
|
|
94
|
+
set: new Set(allFragments.map(s => s.fragment.meta.name)),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return knownComponentsCache.set;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detect composition relationships from a fragment's variants
|
|
102
|
+
*/
|
|
103
|
+
export function detectCompositionRelationships(
|
|
104
|
+
fragment: FragmentDefinition,
|
|
105
|
+
allFragments: Array<{ path: string; fragment: FragmentDefinition }>
|
|
106
|
+
): DetectedRelationship[] {
|
|
107
|
+
const relationships: DetectedRelationship[] = [];
|
|
108
|
+
const componentName = fragment.meta.name;
|
|
109
|
+
const knownComponents = getKnownComponents(allFragments);
|
|
110
|
+
const usedComponents = new Set<string>();
|
|
111
|
+
|
|
112
|
+
// Analyze each variant's render output
|
|
113
|
+
for (const variant of fragment.variants || []) {
|
|
114
|
+
try {
|
|
115
|
+
const rendered = variant.render();
|
|
116
|
+
const componentNames = collectComponentNames(rendered);
|
|
117
|
+
|
|
118
|
+
for (const name of componentNames) {
|
|
119
|
+
if (name !== componentName && knownComponents.has(name)) {
|
|
120
|
+
usedComponents.add(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Render might fail, skip this variant
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Create composition relationships for used components
|
|
129
|
+
for (const usedComponent of usedComponents) {
|
|
130
|
+
relationships.push({
|
|
131
|
+
component: usedComponent,
|
|
132
|
+
relationship: "composition",
|
|
133
|
+
note: `Used within ${componentName} variants`,
|
|
134
|
+
confidence: 0.8,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return relationships;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Detect sibling relationships based on semantic connections.
|
|
143
|
+
*
|
|
144
|
+
* NOTE: Category-based sibling detection was removed because it creates
|
|
145
|
+
* meaningless relationships (e.g., Card → Separator just because both are "layout").
|
|
146
|
+
*
|
|
147
|
+
* True sibling relationships should be defined manually in the fragment definition
|
|
148
|
+
* based on actual use cases (e.g., Table uses Badge for status columns).
|
|
149
|
+
*
|
|
150
|
+
* This function now only detects siblings when:
|
|
151
|
+
* - There's a bidirectional manual relationship (A references B AND B references A)
|
|
152
|
+
* - Components share overlapping variant patterns (future enhancement)
|
|
153
|
+
*/
|
|
154
|
+
export function detectSiblingRelationships(
|
|
155
|
+
_fragment: FragmentDefinition,
|
|
156
|
+
_allFragments: Array<{ path: string; fragment: FragmentDefinition }>
|
|
157
|
+
): DetectedRelationship[] {
|
|
158
|
+
// Disabled: Category-based sibling detection creates noise
|
|
159
|
+
// Meaningful sibling relationships should be defined manually
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Detect alternative relationships.
|
|
165
|
+
*
|
|
166
|
+
* NOTE: Pattern-based alternative detection was removed because it creates
|
|
167
|
+
* weak relationships based on naming conventions rather than actual use cases.
|
|
168
|
+
*
|
|
169
|
+
* True alternative relationships should be defined manually in the fragment
|
|
170
|
+
* definition based on actual decision criteria (e.g., "Use IconButton instead
|
|
171
|
+
* of Button when only an icon is needed").
|
|
172
|
+
*
|
|
173
|
+
* A better approach would analyze the `whenNot` guidelines for semantic matches,
|
|
174
|
+
* but that requires NLP/semantic analysis which is out of scope.
|
|
175
|
+
*/
|
|
176
|
+
export function detectAlternativeRelationships(
|
|
177
|
+
_fragment: FragmentDefinition,
|
|
178
|
+
_allFragments: Array<{ path: string; fragment: FragmentDefinition }>
|
|
179
|
+
): DetectedRelationship[] {
|
|
180
|
+
// Disabled: Pattern-based alternative detection is too weak
|
|
181
|
+
// Meaningful alternatives should be defined manually with clear rationale
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Combine all detected relationships and deduplicate
|
|
187
|
+
* Uses caching to avoid expensive render() calls on subsequent views
|
|
188
|
+
*/
|
|
189
|
+
export function detectAllRelationships(
|
|
190
|
+
fragment: FragmentDefinition,
|
|
191
|
+
allFragments: Array<{ path: string; fragment: FragmentDefinition }>
|
|
192
|
+
): DetectedRelationship[] {
|
|
193
|
+
const cacheKey = fragment.meta.name;
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
|
|
196
|
+
// Check cache first
|
|
197
|
+
const cached = relationshipCache.get(cacheKey);
|
|
198
|
+
if (cached && (now - cached.timestamp) < CACHE_TTL) {
|
|
199
|
+
return cached.relationships;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const all = [
|
|
203
|
+
...detectCompositionRelationships(fragment, allFragments),
|
|
204
|
+
...detectSiblingRelationships(fragment, allFragments),
|
|
205
|
+
...detectAlternativeRelationships(fragment, allFragments),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
// Deduplicate by component name, keeping highest confidence
|
|
209
|
+
const byComponent = new Map<string, DetectedRelationship>();
|
|
210
|
+
|
|
211
|
+
for (const rel of all) {
|
|
212
|
+
const existing = byComponent.get(rel.component);
|
|
213
|
+
if (!existing || rel.confidence > existing.confidence) {
|
|
214
|
+
byComponent.set(rel.component, rel);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Sort by confidence descending
|
|
219
|
+
const result = Array.from(byComponent.values()).sort((a, b) => b.confidence - a.confidence);
|
|
220
|
+
|
|
221
|
+
// Cache the result
|
|
222
|
+
relationshipCache.set(cacheKey, { timestamp: now, relationships: result });
|
|
223
|
+
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Merge manual and auto-detected relationships
|
|
229
|
+
*/
|
|
230
|
+
export function mergeRelationships(
|
|
231
|
+
manual: ComponentRelation[] | undefined,
|
|
232
|
+
detected: DetectedRelationship[]
|
|
233
|
+
): Array<ComponentRelation & { isDetected?: boolean; confidence?: number }> {
|
|
234
|
+
const result: Array<ComponentRelation & { isDetected?: boolean; confidence?: number }> = [];
|
|
235
|
+
const manualComponents = new Set((manual || []).map(r => r.component));
|
|
236
|
+
|
|
237
|
+
// Add manual relationships first
|
|
238
|
+
for (const rel of manual || []) {
|
|
239
|
+
result.push({ ...rel, isDetected: false });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Add detected relationships that aren't already defined manually
|
|
243
|
+
for (const rel of detected) {
|
|
244
|
+
if (!manualComponents.has(rel.component)) {
|
|
245
|
+
result.push({
|
|
246
|
+
component: rel.component,
|
|
247
|
+
relationship: rel.relationship,
|
|
248
|
+
note: rel.note,
|
|
249
|
+
isDetected: true,
|
|
250
|
+
confidence: rel.confidence,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ToolAnalyticsCollector } from '../analytics.js';
|
|
3
|
+
import type { ToolCallEvent } from '@fragments-sdk/webmcp';
|
|
4
|
+
|
|
5
|
+
function createEvent(overrides: Partial<ToolCallEvent> = {}): ToolCallEvent {
|
|
6
|
+
return {
|
|
7
|
+
toolName: 'fragments_discover',
|
|
8
|
+
input: {},
|
|
9
|
+
output: {},
|
|
10
|
+
error: null,
|
|
11
|
+
durationMs: 50,
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('ToolAnalyticsCollector', () => {
|
|
18
|
+
it('records events and computes summary', () => {
|
|
19
|
+
const collector = new ToolAnalyticsCollector();
|
|
20
|
+
collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 10 }));
|
|
21
|
+
collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 20 }));
|
|
22
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', durationMs: 30 }));
|
|
23
|
+
|
|
24
|
+
const summary = collector.toSummary();
|
|
25
|
+
expect(summary.totalCalls).toBe(3);
|
|
26
|
+
expect(summary.uniqueTools).toBe(2);
|
|
27
|
+
expect(summary.toolStats.fragments_discover.callCount).toBe(2);
|
|
28
|
+
expect(summary.toolStats.fragments_discover.avgDuration).toBe(15);
|
|
29
|
+
expect(summary.toolStats.fragments_inspect.callCount).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('tracks error rate', () => {
|
|
33
|
+
const collector = new ToolAnalyticsCollector();
|
|
34
|
+
collector.record(createEvent({ error: null }));
|
|
35
|
+
collector.record(createEvent({ error: 'fail' }));
|
|
36
|
+
|
|
37
|
+
const summary = collector.toSummary();
|
|
38
|
+
expect(summary.errorRate).toBe(0.5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('detects common flows', () => {
|
|
42
|
+
const collector = new ToolAnalyticsCollector();
|
|
43
|
+
const base = Date.now();
|
|
44
|
+
|
|
45
|
+
// Create a repeated flow: discover -> inspect -> discover -> inspect
|
|
46
|
+
collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base }));
|
|
47
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 100 }));
|
|
48
|
+
collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base + 200 }));
|
|
49
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 300 }));
|
|
50
|
+
|
|
51
|
+
const summary = collector.toSummary();
|
|
52
|
+
expect(summary.commonFlows.length).toBeGreaterThan(0);
|
|
53
|
+
expect(summary.commonFlows[0].sequence).toEqual(['fragments_discover', 'fragments_inspect']);
|
|
54
|
+
expect(summary.commonFlows[0].count).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('splits sessions on 30s gap', () => {
|
|
58
|
+
const collector = new ToolAnalyticsCollector();
|
|
59
|
+
const base = Date.now();
|
|
60
|
+
|
|
61
|
+
collector.record(createEvent({ toolName: 'a', timestamp: base }));
|
|
62
|
+
collector.record(createEvent({ toolName: 'b', timestamp: base + 100 }));
|
|
63
|
+
// 31s gap
|
|
64
|
+
collector.record(createEvent({ toolName: 'a', timestamp: base + 31_100 }));
|
|
65
|
+
collector.record(createEvent({ toolName: 'b', timestamp: base + 31_200 }));
|
|
66
|
+
|
|
67
|
+
const summary = collector.toSummary();
|
|
68
|
+
// "a -> b" appears in both sessions
|
|
69
|
+
const abFlow = summary.commonFlows.find(f =>
|
|
70
|
+
f.sequence[0] === 'a' && f.sequence[1] === 'b'
|
|
71
|
+
);
|
|
72
|
+
expect(abFlow).toBeDefined();
|
|
73
|
+
expect(abFlow!.count).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('computes percentiles', () => {
|
|
77
|
+
const collector = new ToolAnalyticsCollector();
|
|
78
|
+
for (let i = 1; i <= 100; i++) {
|
|
79
|
+
collector.record(createEvent({ durationMs: i }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const summary = collector.toSummary();
|
|
83
|
+
const stat = summary.toolStats.fragments_discover;
|
|
84
|
+
expect(stat.p50).toBeGreaterThanOrEqual(49);
|
|
85
|
+
expect(stat.p50).toBeLessThanOrEqual(51);
|
|
86
|
+
expect(stat.p95).toBeGreaterThanOrEqual(94);
|
|
87
|
+
expect(stat.p95).toBeLessThanOrEqual(96);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('produces PostHog properties', () => {
|
|
91
|
+
const collector = new ToolAnalyticsCollector();
|
|
92
|
+
collector.record(createEvent());
|
|
93
|
+
|
|
94
|
+
const props = collector.toPostHogProperties();
|
|
95
|
+
expect(props.webmcp_total_calls).toBe(1);
|
|
96
|
+
expect(props.webmcp_unique_tools).toBe(1);
|
|
97
|
+
expect(typeof props.webmcp_tool_breakdown).toBe('string');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('resets state', () => {
|
|
101
|
+
const collector = new ToolAnalyticsCollector();
|
|
102
|
+
collector.record(createEvent());
|
|
103
|
+
collector.reset();
|
|
104
|
+
|
|
105
|
+
const summary = collector.toSummary();
|
|
106
|
+
expect(summary.totalCalls).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
});
|