@fragments-sdk/cli 0.2.2

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 (259) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/bin.d.ts +1 -0
  4. package/dist/bin.js +4783 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/chunk-4FDQSGKX.js +786 -0
  7. package/dist/chunk-4FDQSGKX.js.map +1 -0
  8. package/dist/chunk-7H2MMGYG.js +369 -0
  9. package/dist/chunk-7H2MMGYG.js.map +1 -0
  10. package/dist/chunk-BSCG3IP7.js +619 -0
  11. package/dist/chunk-BSCG3IP7.js.map +1 -0
  12. package/dist/chunk-LY2CFFPY.js +898 -0
  13. package/dist/chunk-LY2CFFPY.js.map +1 -0
  14. package/dist/chunk-MUZ6CM66.js +6636 -0
  15. package/dist/chunk-MUZ6CM66.js.map +1 -0
  16. package/dist/chunk-OAENNG3G.js +1489 -0
  17. package/dist/chunk-OAENNG3G.js.map +1 -0
  18. package/dist/chunk-XHNKNI6J.js +235 -0
  19. package/dist/chunk-XHNKNI6J.js.map +1 -0
  20. package/dist/core-DWKLGY4N.js +68 -0
  21. package/dist/core-DWKLGY4N.js.map +1 -0
  22. package/dist/generate-4LQNJ7SX.js +249 -0
  23. package/dist/generate-4LQNJ7SX.js.map +1 -0
  24. package/dist/index.d.ts +775 -0
  25. package/dist/index.js +41 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/init-EMVI47QG.js +416 -0
  28. package/dist/init-EMVI47QG.js.map +1 -0
  29. package/dist/mcp-bin.d.ts +1 -0
  30. package/dist/mcp-bin.js +1117 -0
  31. package/dist/mcp-bin.js.map +1 -0
  32. package/dist/scan-4YPRF7FV.js +12 -0
  33. package/dist/scan-4YPRF7FV.js.map +1 -0
  34. package/dist/service-QSZMZJBJ.js +208 -0
  35. package/dist/service-QSZMZJBJ.js.map +1 -0
  36. package/dist/static-viewer-MIPGZ4Z7.js +12 -0
  37. package/dist/static-viewer-MIPGZ4Z7.js.map +1 -0
  38. package/dist/test-SQ5ZHXWU.js +1067 -0
  39. package/dist/test-SQ5ZHXWU.js.map +1 -0
  40. package/dist/tokens-HSGMYK64.js +173 -0
  41. package/dist/tokens-HSGMYK64.js.map +1 -0
  42. package/dist/viewer-YRF4SQE4.js +11101 -0
  43. package/dist/viewer-YRF4SQE4.js.map +1 -0
  44. package/package.json +107 -0
  45. package/src/ai.ts +266 -0
  46. package/src/analyze.ts +265 -0
  47. package/src/bin.ts +916 -0
  48. package/src/build.ts +248 -0
  49. package/src/commands/a11y.ts +302 -0
  50. package/src/commands/add.ts +313 -0
  51. package/src/commands/audit.ts +195 -0
  52. package/src/commands/baseline.ts +221 -0
  53. package/src/commands/build.ts +144 -0
  54. package/src/commands/compare.ts +337 -0
  55. package/src/commands/context.ts +107 -0
  56. package/src/commands/dev.ts +107 -0
  57. package/src/commands/enhance.ts +858 -0
  58. package/src/commands/generate.ts +391 -0
  59. package/src/commands/init.ts +531 -0
  60. package/src/commands/link/figma.ts +645 -0
  61. package/src/commands/link/index.ts +10 -0
  62. package/src/commands/link/storybook.ts +267 -0
  63. package/src/commands/list.ts +49 -0
  64. package/src/commands/metrics.ts +114 -0
  65. package/src/commands/reset.ts +242 -0
  66. package/src/commands/scan.ts +537 -0
  67. package/src/commands/storygen.ts +207 -0
  68. package/src/commands/tokens.ts +251 -0
  69. package/src/commands/validate.ts +93 -0
  70. package/src/commands/verify.ts +215 -0
  71. package/src/core/composition.test.ts +262 -0
  72. package/src/core/composition.ts +255 -0
  73. package/src/core/config.ts +84 -0
  74. package/src/core/constants.ts +111 -0
  75. package/src/core/context.ts +380 -0
  76. package/src/core/defineSegment.ts +137 -0
  77. package/src/core/discovery.ts +337 -0
  78. package/src/core/figma.ts +263 -0
  79. package/src/core/fragment-types.ts +214 -0
  80. package/src/core/generators/context.ts +389 -0
  81. package/src/core/generators/index.ts +23 -0
  82. package/src/core/generators/registry.ts +364 -0
  83. package/src/core/generators/typescript-extractor.ts +374 -0
  84. package/src/core/importAnalyzer.ts +217 -0
  85. package/src/core/index.ts +149 -0
  86. package/src/core/loader.ts +155 -0
  87. package/src/core/node.ts +63 -0
  88. package/src/core/parser.ts +551 -0
  89. package/src/core/previewLoader.ts +172 -0
  90. package/src/core/schema/fragment.schema.json +189 -0
  91. package/src/core/schema/registry.schema.json +137 -0
  92. package/src/core/schema.ts +182 -0
  93. package/src/core/storyAdapter.test.ts +571 -0
  94. package/src/core/storyAdapter.ts +761 -0
  95. package/src/core/token-types.ts +287 -0
  96. package/src/core/types.ts +754 -0
  97. package/src/diff.ts +323 -0
  98. package/src/index.ts +43 -0
  99. package/src/mcp/__tests__/projectFields.test.ts +130 -0
  100. package/src/mcp/bin.ts +36 -0
  101. package/src/mcp/index.ts +8 -0
  102. package/src/mcp/server.ts +1310 -0
  103. package/src/mcp/utils.ts +54 -0
  104. package/src/mcp-bin.ts +36 -0
  105. package/src/migrate/__tests__/argTypes/argTypes.test.ts +189 -0
  106. package/src/migrate/__tests__/args/args.test.ts +452 -0
  107. package/src/migrate/__tests__/meta/meta.test.ts +198 -0
  108. package/src/migrate/__tests__/stories/stories.test.ts +278 -0
  109. package/src/migrate/__tests__/utils/utils.test.ts +371 -0
  110. package/src/migrate/__tests__/values/values.test.ts +303 -0
  111. package/src/migrate/bin.ts +108 -0
  112. package/src/migrate/converter.ts +658 -0
  113. package/src/migrate/detect.ts +196 -0
  114. package/src/migrate/index.ts +45 -0
  115. package/src/migrate/migrate.ts +163 -0
  116. package/src/migrate/parser.ts +1136 -0
  117. package/src/migrate/report.ts +624 -0
  118. package/src/migrate/types.ts +169 -0
  119. package/src/screenshot.ts +249 -0
  120. package/src/service/__tests__/ast-utils.test.ts +426 -0
  121. package/src/service/__tests__/enhance-scanner.test.ts +200 -0
  122. package/src/service/__tests__/figma/figma.test.ts +652 -0
  123. package/src/service/__tests__/metrics-store.test.ts +409 -0
  124. package/src/service/__tests__/patch-generator.test.ts +186 -0
  125. package/src/service/__tests__/props-extractor.test.ts +365 -0
  126. package/src/service/__tests__/token-registry.test.ts +267 -0
  127. package/src/service/analytics.ts +659 -0
  128. package/src/service/ast-utils.ts +444 -0
  129. package/src/service/browser-pool.ts +339 -0
  130. package/src/service/capture.ts +267 -0
  131. package/src/service/diff.ts +279 -0
  132. package/src/service/enhance/aggregator.ts +489 -0
  133. package/src/service/enhance/cache.ts +275 -0
  134. package/src/service/enhance/codebase-scanner.ts +357 -0
  135. package/src/service/enhance/context-generator.ts +529 -0
  136. package/src/service/enhance/doc-extractor.ts +523 -0
  137. package/src/service/enhance/index.ts +131 -0
  138. package/src/service/enhance/props-extractor.ts +665 -0
  139. package/src/service/enhance/scanner.ts +445 -0
  140. package/src/service/enhance/storybook-parser.ts +552 -0
  141. package/src/service/enhance/types.ts +346 -0
  142. package/src/service/enhance/variant-renderer.ts +479 -0
  143. package/src/service/figma.ts +1008 -0
  144. package/src/service/index.ts +249 -0
  145. package/src/service/metrics-store.ts +333 -0
  146. package/src/service/patch-generator.ts +349 -0
  147. package/src/service/report.ts +854 -0
  148. package/src/service/storage.ts +401 -0
  149. package/src/service/token-fixes.ts +281 -0
  150. package/src/service/token-parser.ts +504 -0
  151. package/src/service/token-registry.ts +721 -0
  152. package/src/service/utils.ts +172 -0
  153. package/src/setup.ts +241 -0
  154. package/src/shared/command-wrapper.ts +81 -0
  155. package/src/shared/dev-server-client.ts +199 -0
  156. package/src/shared/index.ts +8 -0
  157. package/src/shared/segment-loader.ts +59 -0
  158. package/src/shared/types.ts +147 -0
  159. package/src/static-viewer.ts +715 -0
  160. package/src/test/discovery.ts +172 -0
  161. package/src/test/index.ts +281 -0
  162. package/src/test/reporters/console.ts +194 -0
  163. package/src/test/reporters/json.ts +190 -0
  164. package/src/test/reporters/junit.ts +186 -0
  165. package/src/test/runner.ts +598 -0
  166. package/src/test/types.ts +245 -0
  167. package/src/test/watch.ts +200 -0
  168. package/src/validators.ts +152 -0
  169. package/src/viewer/__tests__/jsx-parser.test.ts +502 -0
  170. package/src/viewer/__tests__/render-utils.test.ts +232 -0
  171. package/src/viewer/__tests__/style-utils.test.ts +404 -0
  172. package/src/viewer/bin.ts +86 -0
  173. package/src/viewer/cli/health.ts +256 -0
  174. package/src/viewer/cli/index.ts +33 -0
  175. package/src/viewer/cli/scan.ts +124 -0
  176. package/src/viewer/cli/utils.ts +174 -0
  177. package/src/viewer/components/AccessibilityPanel.tsx +1404 -0
  178. package/src/viewer/components/ActionCapture.tsx +172 -0
  179. package/src/viewer/components/ActionsPanel.tsx +371 -0
  180. package/src/viewer/components/App.tsx +638 -0
  181. package/src/viewer/components/BottomPanel.tsx +224 -0
  182. package/src/viewer/components/CodePanel.tsx +589 -0
  183. package/src/viewer/components/CommandPalette.tsx +336 -0
  184. package/src/viewer/components/ComponentGraph.tsx +394 -0
  185. package/src/viewer/components/ComponentHeader.tsx +85 -0
  186. package/src/viewer/components/ContractPanel.tsx +234 -0
  187. package/src/viewer/components/ErrorBoundary.tsx +85 -0
  188. package/src/viewer/components/FigmaEmbed.tsx +231 -0
  189. package/src/viewer/components/FragmentEditor.tsx +485 -0
  190. package/src/viewer/components/HealthDashboard.tsx +452 -0
  191. package/src/viewer/components/HmrStatusIndicator.tsx +71 -0
  192. package/src/viewer/components/Icons.tsx +417 -0
  193. package/src/viewer/components/InteractionsPanel.tsx +720 -0
  194. package/src/viewer/components/IsolatedPreviewFrame.tsx +321 -0
  195. package/src/viewer/components/IsolatedRender.tsx +111 -0
  196. package/src/viewer/components/KeyboardShortcutsHelp.tsx +89 -0
  197. package/src/viewer/components/LandingPage.tsx +441 -0
  198. package/src/viewer/components/Layout.tsx +22 -0
  199. package/src/viewer/components/LeftSidebar.tsx +391 -0
  200. package/src/viewer/components/MultiViewportPreview.tsx +429 -0
  201. package/src/viewer/components/PreviewArea.tsx +404 -0
  202. package/src/viewer/components/PreviewFrameHost.tsx +310 -0
  203. package/src/viewer/components/PreviewPane.tsx +150 -0
  204. package/src/viewer/components/PreviewToolbar.tsx +176 -0
  205. package/src/viewer/components/PropsEditor.tsx +512 -0
  206. package/src/viewer/components/PropsTable.tsx +98 -0
  207. package/src/viewer/components/RelationsSection.tsx +57 -0
  208. package/src/viewer/components/ResizablePanel.tsx +328 -0
  209. package/src/viewer/components/RightSidebar.tsx +118 -0
  210. package/src/viewer/components/ScreenshotButton.tsx +90 -0
  211. package/src/viewer/components/Sidebar.tsx +169 -0
  212. package/src/viewer/components/SkeletonLoader.tsx +156 -0
  213. package/src/viewer/components/StoryRenderer.tsx +128 -0
  214. package/src/viewer/components/ThemeProvider.tsx +96 -0
  215. package/src/viewer/components/Toast.tsx +67 -0
  216. package/src/viewer/components/TokenStylePanel.tsx +708 -0
  217. package/src/viewer/components/UsageSection.tsx +95 -0
  218. package/src/viewer/components/VariantMatrix.tsx +350 -0
  219. package/src/viewer/components/VariantRenderer.tsx +131 -0
  220. package/src/viewer/components/VariantTabs.tsx +84 -0
  221. package/src/viewer/components/ViewportSelector.tsx +165 -0
  222. package/src/viewer/components/_future/CreatePage.tsx +836 -0
  223. package/src/viewer/composition-renderer.ts +381 -0
  224. package/src/viewer/constants/index.ts +1 -0
  225. package/src/viewer/constants/ui.ts +185 -0
  226. package/src/viewer/entry.tsx +299 -0
  227. package/src/viewer/hooks/index.ts +2 -0
  228. package/src/viewer/hooks/useA11yCache.ts +383 -0
  229. package/src/viewer/hooks/useA11yService.ts +498 -0
  230. package/src/viewer/hooks/useActions.ts +138 -0
  231. package/src/viewer/hooks/useAppState.ts +124 -0
  232. package/src/viewer/hooks/useFigmaIntegration.ts +132 -0
  233. package/src/viewer/hooks/useHmrStatus.ts +109 -0
  234. package/src/viewer/hooks/useKeyboardShortcuts.ts +222 -0
  235. package/src/viewer/hooks/usePreviewBridge.ts +347 -0
  236. package/src/viewer/hooks/useScrollSpy.ts +78 -0
  237. package/src/viewer/hooks/useUrlState.ts +330 -0
  238. package/src/viewer/hooks/useViewSettings.ts +125 -0
  239. package/src/viewer/index.html +28 -0
  240. package/src/viewer/index.ts +14 -0
  241. package/src/viewer/intelligence/healthReport.ts +505 -0
  242. package/src/viewer/intelligence/styleDrift.ts +340 -0
  243. package/src/viewer/intelligence/usageScanner.ts +309 -0
  244. package/src/viewer/jsx-parser.ts +485 -0
  245. package/src/viewer/postcss.config.js +6 -0
  246. package/src/viewer/preview-frame-entry.tsx +25 -0
  247. package/src/viewer/preview-frame.html +109 -0
  248. package/src/viewer/render-template.html +68 -0
  249. package/src/viewer/render-utils.ts +170 -0
  250. package/src/viewer/server.ts +276 -0
  251. package/src/viewer/style-utils.ts +414 -0
  252. package/src/viewer/styles/globals.css +355 -0
  253. package/src/viewer/tailwind.config.js +37 -0
  254. package/src/viewer/types/a11y.ts +197 -0
  255. package/src/viewer/utils/a11y-fixes.ts +471 -0
  256. package/src/viewer/utils/actionExport.ts +372 -0
  257. package/src/viewer/utils/colorSchemes.ts +201 -0
  258. package/src/viewer/utils/detectRelationships.ts +256 -0
  259. package/src/viewer/vite-plugin.ts +2143 -0
@@ -0,0 +1,2143 @@
1
+ /**
2
+ * Segments Vite Plugin
3
+ *
4
+ * This plugin runs WITHIN the project's Vite context, giving it access to:
5
+ * - All project dependencies (React, UI libraries, etc.)
6
+ * - Project's loaders (SCSS, CSS modules, etc.)
7
+ * - Project's path aliases (@/components, etc.)
8
+ * - Project's tsconfig paths
9
+ *
10
+ * It provides:
11
+ * - Virtual module for segment imports
12
+ * - Viewer UI served at /fragments/
13
+ * - HMR support for segment file changes
14
+ */
15
+
16
+ import type { Plugin, ViteDevServer, ResolvedConfig } from "vite";
17
+ import { resolve, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { readFile } from "node:fs/promises";
20
+ import { transform } from "esbuild";
21
+ import type { SegmentsConfig, CompiledSegment } from "../core/index.js";
22
+ import { generateContext, BRAND } from "../core/index.js";
23
+ import {
24
+ findStorybookDir,
25
+ findPreviewConfigPath,
26
+ generatePreviewModule,
27
+ } from "../core/node.js";
28
+ import svgr from "vite-plugin-svgr";
29
+ import {
30
+ generateRenderScript,
31
+ findSegmentByName,
32
+ getAvailableComponents,
33
+ type RenderRequest,
34
+ } from "./render-utils.js";
35
+ import {
36
+ compareStyles,
37
+ type StyleDiffItem,
38
+ } from "./style-utils.js";
39
+
40
+ /**
41
+ * Request body for /fragments/compare endpoint
42
+ */
43
+ interface CompareRequest {
44
+ /** Component name */
45
+ component: string;
46
+ /** Variant name (optional, uses first variant if not specified) */
47
+ variant?: string;
48
+ /** Props to render with */
49
+ props?: Record<string, unknown>;
50
+ /** Figma URL (optional if segment has figma link) */
51
+ figmaUrl?: string;
52
+ /** Viewport dimensions */
53
+ viewport?: { width: number; height: number };
54
+ /** Figma access token (can be passed from CLI) */
55
+ figmaToken?: string;
56
+ /** Diff threshold percentage (default: 1.0) */
57
+ threshold?: number;
58
+ /** Include style comparison from Figma design properties */
59
+ includeStyleDiff?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Response from /fragments/compare endpoint
64
+ */
65
+ interface CompareResponse {
66
+ /** Whether diff is within threshold */
67
+ match: boolean;
68
+ /** Diff percentage (0-100) */
69
+ diffPercentage: number;
70
+ /** Threshold that was used */
71
+ threshold: number;
72
+ /** Rendered component screenshot (base64 PNG) */
73
+ rendered: string;
74
+ /** Figma design image (base64 PNG) */
75
+ figma: string;
76
+ /** Diff image highlighting differences (base64 PNG) */
77
+ diff: string;
78
+ /** The Figma URL that was used */
79
+ figmaUrl: string;
80
+ /** Regions that changed */
81
+ changedRegions: Array<{
82
+ x: number;
83
+ y: number;
84
+ width: number;
85
+ height: number;
86
+ }>;
87
+ /** Style comparison results (when includeStyleDiff is true) */
88
+ styleDiff?: {
89
+ /** Whether all styles match */
90
+ match: boolean;
91
+ /** Individual property comparisons */
92
+ properties: StyleDiffItem[];
93
+ /** CSS properties from Figma design */
94
+ figmaStyles: Record<string, string>;
95
+ /** Computed CSS properties from rendered component */
96
+ renderedStyles: Record<string, string>;
97
+ };
98
+ }
99
+
100
+ const __dirname = dirname(fileURLToPath(import.meta.url));
101
+
102
+ // Store pending render requests (for internal render page to pick up)
103
+ const pendingRenders = new Map<
104
+ string,
105
+ { script: string; viewport?: { width: number; height: number } }
106
+ >();
107
+
108
+ // Shared browser pool for render captures (lazy initialized)
109
+ let sharedRenderPool: any = null;
110
+ let browserPoolModule: any = null;
111
+
112
+ /**
113
+ * Get or create the shared browser pool for render captures.
114
+ * The pool is lazily initialized on first use and reused across requests.
115
+ */
116
+ async function getSharedRenderPool() {
117
+ if (!browserPoolModule) {
118
+ browserPoolModule = await import("../service/index.js");
119
+ }
120
+
121
+ if (!sharedRenderPool) {
122
+ sharedRenderPool = new browserPoolModule.BrowserPool({
123
+ viewport: { width: 800, height: 600 }, // Default viewport, will be overridden per page
124
+ poolSize: 2, // Keep 2 contexts warm for parallel requests
125
+ idleTimeoutMs: 60000, // Keep warm for 60 seconds
126
+ });
127
+ }
128
+
129
+ return { pool: sharedRenderPool, bufferToBase64Url: browserPoolModule.bufferToBase64Url };
130
+ }
131
+
132
+ export interface SegmentsPluginOptions {
133
+ /** Discovered segment files */
134
+ segmentFiles: Array<{
135
+ absolutePath: string;
136
+ relativePath: string;
137
+ }>;
138
+
139
+ /** Segments configuration */
140
+ config: SegmentsConfig;
141
+
142
+ /** Project root directory */
143
+ projectRoot: string;
144
+ }
145
+
146
+ /**
147
+ * Create the Segments Vite plugin.
148
+ * Returns an array of plugins to support SVGR and other transforms.
149
+ */
150
+ export function segmentsPlugin(options: SegmentsPluginOptions): Plugin[] {
151
+ const { segmentFiles, config, projectRoot } = options;
152
+
153
+ // Virtual module IDs
154
+ const VIRTUAL_SEGMENTS = `virtual:${BRAND.nameLower}`;
155
+ const VIRTUAL_SEGMENTS_RESOLVED = `\0virtual:${BRAND.nameLower}`;
156
+
157
+ const VIRTUAL_VIEWER_ENTRY = `virtual:${BRAND.nameLower}-viewer-entry`;
158
+ const VIRTUAL_VIEWER_ENTRY_RESOLVED = `\0virtual:${BRAND.nameLower}-viewer-entry`;
159
+
160
+ const VIRTUAL_PREVIEW = `virtual:${BRAND.nameLower}-preview`;
161
+ const VIRTUAL_PREVIEW_RESOLVED = `\0virtual:${BRAND.nameLower}-preview`;
162
+
163
+ let server: ViteDevServer | null = null;
164
+ let resolvedConfig: ResolvedConfig | null = null;
165
+
166
+ // Detect Storybook preview config path
167
+ const storybookDir = findStorybookDir(projectRoot);
168
+ const previewConfigPath = storybookDir
169
+ ? findPreviewConfigPath(storybookDir)
170
+ : null;
171
+
172
+ // Track segment files for HMR
173
+ const segmentFileSet = new Set(segmentFiles.map((f) => f.absolutePath));
174
+
175
+ const mainPlugin: Plugin = {
176
+ name: "segments",
177
+
178
+ // Add process.env shim and esbuild config for Storybook compatibility
179
+ config() {
180
+ return {
181
+ define: {
182
+ // Shim process.env for story files that use it (e.g., process.env.STORYBOOK_*)
183
+ "process.env": "{}",
184
+ },
185
+ esbuild: {
186
+ // Handle JSX in .js files (common in Storybook preview.js files)
187
+ loader: "tsx",
188
+ include: /\.(tsx?|jsx?)$/,
189
+ },
190
+ optimizeDeps: {
191
+ // Force esbuild to handle .js files with JSX
192
+ esbuildOptions: {
193
+ loader: {
194
+ ".js": "jsx",
195
+ },
196
+ },
197
+ },
198
+ };
199
+ },
200
+
201
+ // Store resolved config
202
+ configResolved(config) {
203
+ resolvedConfig = config;
204
+ },
205
+
206
+ // Store server reference for HMR
207
+ configureServer(_server) {
208
+ server = _server;
209
+
210
+ // Serve the viewer UI at /fragments/
211
+ _server.middlewares.use(async (req, res, next) => {
212
+ // Handle /fragments/render endpoint for AI preview
213
+ if (req.url === "/fragments/render" && req.method === "POST") {
214
+ try {
215
+ // Parse JSON body
216
+ const body = await parseJsonBody(req);
217
+ const { component, props = {}, viewport } = body as RenderRequest;
218
+
219
+ if (!component) {
220
+ res.writeHead(400, { "Content-Type": "application/json" });
221
+ res.end(
222
+ JSON.stringify({ error: "Missing required field: component" })
223
+ );
224
+ return;
225
+ }
226
+
227
+ // Load segments to find the component
228
+ const loadedSegments = await loadSegmentsForRender(
229
+ segmentFiles,
230
+ projectRoot
231
+ );
232
+ const segmentInfo = findSegmentByName(component, loadedSegments);
233
+
234
+ if (!segmentInfo) {
235
+ const available = getAvailableComponents(loadedSegments);
236
+ res.writeHead(400, { "Content-Type": "application/json" });
237
+ res.end(
238
+ JSON.stringify({
239
+ error: `Component '${component}' not found. Available: ${available.join(
240
+ ", "
241
+ )}`,
242
+ })
243
+ );
244
+ return;
245
+ }
246
+
247
+ // Find the absolute path for the segment
248
+ const segmentFile = segmentFiles.find(
249
+ (f) => f.relativePath === segmentInfo.path
250
+ );
251
+ if (!segmentFile) {
252
+ res.writeHead(500, { "Content-Type": "application/json" });
253
+ res.end(
254
+ JSON.stringify({ error: "Could not resolve segment file path" })
255
+ );
256
+ return;
257
+ }
258
+
259
+ // Generate render script
260
+ const renderScript = generateRenderScript(
261
+ segmentFile.absolutePath,
262
+ segmentInfo.name,
263
+ props
264
+ );
265
+
266
+ // Store the render request for the render page to pick up
267
+ const requestId =
268
+ Date.now().toString(36) + Math.random().toString(36).slice(2);
269
+ pendingRenders.set(requestId, { script: renderScript, viewport });
270
+
271
+ // Get server address
272
+ const address = _server.httpServer?.address();
273
+ const port =
274
+ typeof address === "object" && address ? address.port : 6006;
275
+
276
+ // Use Playwright to render and capture
277
+ const screenshot = await captureRender(
278
+ `http://localhost:${port}/fragments/__render__/${requestId}`,
279
+ viewport || { width: 800, height: 600 }
280
+ );
281
+
282
+ // Clean up
283
+ pendingRenders.delete(requestId);
284
+
285
+ res.setHeader("Content-Type", "application/json");
286
+ res.end(JSON.stringify({ screenshot }));
287
+ } catch (error) {
288
+ console.error("[Fragments] Error rendering:", error);
289
+ res.writeHead(500, { "Content-Type": "application/json" });
290
+ res.end(
291
+ JSON.stringify({
292
+ error: error instanceof Error ? error.message : "Render failed",
293
+ })
294
+ );
295
+ }
296
+ return;
297
+ }
298
+
299
+ // Serve render page for AI preview (internal use)
300
+ if (req.url?.startsWith("/fragments/__render__/")) {
301
+ const requestId = req.url
302
+ .split("/fragments/__render__/")[1]
303
+ ?.split("?")[0];
304
+ const renderData = pendingRenders.get(requestId || "");
305
+
306
+ if (!renderData) {
307
+ res.writeHead(404, { "Content-Type": "text/plain" });
308
+ res.end("Render request not found or expired");
309
+ return;
310
+ }
311
+
312
+ await serveRenderHTML(res, _server, renderData.script);
313
+ return;
314
+ }
315
+
316
+ // Handle /fragments/compare endpoint for Figma design verification
317
+ if (req.url === "/fragments/compare" && req.method === "POST") {
318
+ try {
319
+ const body = (await parseJsonBody(req)) as CompareRequest;
320
+ const {
321
+ component,
322
+ variant,
323
+ props = {},
324
+ figmaUrl,
325
+ viewport,
326
+ threshold = 1.0,
327
+ includeStyleDiff = false,
328
+ } = body;
329
+
330
+ if (!component) {
331
+ res.writeHead(400, { "Content-Type": "application/json" });
332
+ res.end(
333
+ JSON.stringify({ error: "Missing required field: component" })
334
+ );
335
+ return;
336
+ }
337
+
338
+ // Check for Figma access token (request body > env var > config)
339
+ const figmaToken =
340
+ body.figmaToken ||
341
+ process.env.FIGMA_ACCESS_TOKEN ||
342
+ config.figmaToken;
343
+ if (!figmaToken && !figmaUrl) {
344
+ res.writeHead(400, { "Content-Type": "application/json" });
345
+ res.end(
346
+ JSON.stringify({
347
+ error: `No Figma access token configured. Figma token: ${figmaToken}`,
348
+ suggestion:
349
+ "Set FIGMA_ACCESS_TOKEN env var, add figmaToken to fragments.config.ts, or provide in request",
350
+ })
351
+ );
352
+ return;
353
+ }
354
+
355
+ // Debug: Log segment files
356
+ console.log("[Fragments] Compare request for:", component);
357
+ console.log("[Fragments] segmentFiles count:", segmentFiles.length);
358
+ console.log("[Fragments] First 3 segment files:", segmentFiles.slice(0, 3).map(f => f.relativePath));
359
+ console.log("[Fragments] projectRoot:", projectRoot);
360
+
361
+ // Load segments to find the component and its figma URL
362
+ const loadedSegments = await loadSegmentsForRender(
363
+ segmentFiles,
364
+ projectRoot
365
+ );
366
+ console.log("[Fragments] loadedSegments count:", loadedSegments.length);
367
+ console.log("[Fragments] First 3 loaded:", loadedSegments.slice(0, 3).map(s => s.segment.meta.name));
368
+ const segmentInfo = findSegmentByName(component, loadedSegments);
369
+
370
+ if (!segmentInfo) {
371
+ const available = getAvailableComponents(loadedSegments);
372
+ res.writeHead(400, { "Content-Type": "application/json" });
373
+ res.end(
374
+ JSON.stringify({
375
+ error: `Component '${component}' not found. Available: ${available.join(
376
+ ", "
377
+ )}`,
378
+ })
379
+ );
380
+ return;
381
+ }
382
+
383
+ // Find full segment data to get figma URL
384
+ const fullSegmentData = await loadFullSegmentForCompare(
385
+ _server,
386
+ segmentFiles,
387
+ component,
388
+ variant,
389
+ projectRoot
390
+ );
391
+
392
+ // Determine which Figma URL to use (request > variant > meta)
393
+ const effectiveFigmaUrl = figmaUrl || fullSegmentData?.figmaUrl;
394
+
395
+ if (!effectiveFigmaUrl) {
396
+ res.writeHead(400, { "Content-Type": "application/json" });
397
+ res.end(
398
+ JSON.stringify({
399
+ error: `No Figma URL for component '${component}'`,
400
+ suggestion:
401
+ "Add 'figma' field to segment definition or provide figmaUrl in request",
402
+ })
403
+ );
404
+ return;
405
+ }
406
+
407
+ if (!figmaToken) {
408
+ res.writeHead(400, { "Content-Type": "application/json" });
409
+ res.end(
410
+ JSON.stringify({
411
+ error: "Figma access token required for comparison",
412
+ suggestion:
413
+ "Set FIGMA_ACCESS_TOKEN env var or add figmaToken to fragments.config.ts",
414
+ })
415
+ );
416
+ return;
417
+ }
418
+
419
+ // Find segment file for rendering
420
+ const segmentFile = segmentFiles.find(
421
+ (f) => f.relativePath === segmentInfo.path
422
+ );
423
+ if (!segmentFile) {
424
+ res.writeHead(500, { "Content-Type": "application/json" });
425
+ res.end(
426
+ JSON.stringify({ error: "Could not resolve segment file path" })
427
+ );
428
+ return;
429
+ }
430
+
431
+ // Get server port
432
+ const address = _server.httpServer?.address();
433
+ const port =
434
+ typeof address === "object" && address ? address.port : 6006;
435
+ const renderViewport = viewport || { width: 800, height: 600 };
436
+
437
+ // Import Figma service
438
+ const { FigmaClient, bufferToBase64Url } = await import(
439
+ "../service/index.js"
440
+ );
441
+ const figmaClient = new FigmaClient({
442
+ accessToken: figmaToken,
443
+ });
444
+
445
+ // Parse Figma URL to get file key and node ID for style diff
446
+ const { fileKey, nodeId } = figmaClient.parseUrl(effectiveFigmaUrl);
447
+
448
+ // Generate render script and request ID for component capture
449
+ const renderScript = generateRenderScript(
450
+ segmentFile.absolutePath,
451
+ segmentInfo.name,
452
+ props
453
+ );
454
+ const requestId =
455
+ Date.now().toString(36) + Math.random().toString(36).slice(2);
456
+ pendingRenders.set(requestId, {
457
+ script: renderScript,
458
+ viewport: renderViewport,
459
+ });
460
+
461
+ try {
462
+ // Execute render, Figma fetch, and optionally style fetch in parallel
463
+ const [captureResult, figmaImageResult, figmaDesignProps] = await Promise.all([
464
+ // Render and capture the component (with optional computed styles)
465
+ captureRenderWithStyles(
466
+ `http://localhost:${port}/fragments/__render__/${requestId}`,
467
+ renderViewport,
468
+ includeStyleDiff
469
+ ),
470
+ // Fetch Figma image
471
+ figmaClient.getImageFromUrl(effectiveFigmaUrl),
472
+ // Fetch Figma design properties (only if includeStyleDiff is true)
473
+ includeStyleDiff
474
+ ? figmaClient.getNodeProperties(fileKey, nodeId)
475
+ : Promise.resolve(null),
476
+ ]);
477
+
478
+ const renderedImage = captureResult.screenshot;
479
+ const renderedStyles = captureResult.computedStyles;
480
+ const figmaImage = bufferToBase64Url(figmaImageResult.data);
481
+
482
+ // Compare the images
483
+ const compareResult = await compareImages(
484
+ renderedImage,
485
+ figmaImage,
486
+ threshold
487
+ );
488
+
489
+ // Build response
490
+ const response: CompareResponse = {
491
+ match: compareResult.matches,
492
+ diffPercentage: compareResult.diffPercentage,
493
+ threshold,
494
+ rendered: renderedImage,
495
+ figma: figmaImage,
496
+ diff: compareResult.diffImage || renderedImage,
497
+ figmaUrl: effectiveFigmaUrl,
498
+ changedRegions: compareResult.changedRegions,
499
+ };
500
+
501
+ // Add style diff if requested
502
+ if (includeStyleDiff && figmaDesignProps && renderedStyles) {
503
+ const figmaStyles = figmaClient.convertToCSS(figmaDesignProps);
504
+ // Convert CSSDesignProperties to Record<string, string | undefined>
505
+ const figmaStylesRecord: Record<string, string | undefined> = { ...figmaStyles };
506
+ const styleDiffResult = compareStyles(figmaStylesRecord, renderedStyles);
507
+ response.styleDiff = styleDiffResult;
508
+
509
+ // Update overall match to include style match
510
+ if (!styleDiffResult.match) {
511
+ response.match = false;
512
+ }
513
+ }
514
+
515
+ res.setHeader("Content-Type", "application/json");
516
+ res.end(JSON.stringify(response));
517
+ } finally {
518
+ pendingRenders.delete(requestId);
519
+ }
520
+ } catch (error) {
521
+ console.error("[Fragments] Error comparing:", error);
522
+ res.writeHead(500, { "Content-Type": "application/json" });
523
+ res.end(
524
+ JSON.stringify({
525
+ error:
526
+ error instanceof Error ? error.message : "Compare failed",
527
+ })
528
+ );
529
+ }
530
+ return;
531
+ }
532
+
533
+ // Handle /fragments/figma-styles endpoint for lightweight style fetching
534
+ // This avoids the heavy Playwright rendering just to get styles
535
+ if (req.url === "/fragments/figma-styles" && req.method === "POST") {
536
+ try {
537
+ const body = (await parseJsonBody(req)) as {
538
+ figmaUrl: string;
539
+ };
540
+
541
+ const { figmaUrl } = body;
542
+
543
+ if (!figmaUrl) {
544
+ res.writeHead(400, { "Content-Type": "application/json" });
545
+ res.end(JSON.stringify({ error: "Missing figmaUrl" }));
546
+ return;
547
+ }
548
+
549
+ // Check for Figma access token
550
+ const figmaToken =
551
+ process.env.FIGMA_ACCESS_TOKEN || config.figmaToken;
552
+ if (!figmaToken) {
553
+ res.writeHead(400, { "Content-Type": "application/json" });
554
+ res.end(
555
+ JSON.stringify({
556
+ error: "No Figma access token configured",
557
+ suggestion:
558
+ "Set FIGMA_ACCESS_TOKEN env var or add figmaToken to fragments.config.ts",
559
+ })
560
+ );
561
+ return;
562
+ }
563
+
564
+ // Import Figma service
565
+ const { FigmaClient } = await import("../service/index.js");
566
+ const figmaClient = new FigmaClient({ accessToken: figmaToken });
567
+
568
+ // Parse Figma URL
569
+ const { fileKey, nodeId } = figmaClient.parseUrl(figmaUrl);
570
+
571
+ // Fetch design properties
572
+ const figmaDesignProps = await figmaClient.getNodeProperties(
573
+ fileKey,
574
+ nodeId
575
+ );
576
+ const figmaStyles = figmaClient.convertToCSS(figmaDesignProps);
577
+
578
+ res.setHeader("Content-Type", "application/json");
579
+ res.end(JSON.stringify({ styles: figmaStyles }));
580
+ } catch (error) {
581
+ console.error("[Fragments] Error fetching Figma styles:", error);
582
+ res.writeHead(500, { "Content-Type": "application/json" });
583
+ res.end(
584
+ JSON.stringify({
585
+ error:
586
+ error instanceof Error
587
+ ? error.message
588
+ : "Failed to fetch Figma styles",
589
+ })
590
+ );
591
+ }
592
+ return;
593
+ }
594
+
595
+ // Handle /fragments/tokens endpoint for token registry
596
+ if (req.url?.startsWith("/fragments/tokens")) {
597
+ try {
598
+ const url = new URL(req.url, "http://localhost");
599
+ const format = url.searchParams.get("format") || "json";
600
+ const category = url.searchParams.get("category");
601
+ const theme = url.searchParams.get("theme");
602
+
603
+ // Check if tokens are configured
604
+ if (!config.tokens || !config.tokens.include || config.tokens.include.length === 0) {
605
+ res.writeHead(400, { "Content-Type": "application/json" });
606
+ res.end(JSON.stringify({
607
+ error: "No token configuration found",
608
+ suggestion: "Add 'tokens' config to fragments.config.ts with 'include' patterns for CSS/SCSS files",
609
+ example: {
610
+ tokens: {
611
+ include: ["src/styles/theme.scss", "src/styles/variables.css"],
612
+ themeSelectors: { ":root": "default", "[data-theme='dark']": "dark" },
613
+ },
614
+ },
615
+ }));
616
+ return;
617
+ }
618
+
619
+ // Import token registry
620
+ const { getSharedTokenRegistry } = await import("../service/index.js");
621
+ const registry = getSharedTokenRegistry();
622
+
623
+ // Initialize if not already
624
+ if (!registry.isInitialized()) {
625
+ await registry.initialize(config.tokens, projectRoot);
626
+ }
627
+
628
+ let tokens = registry.getAllTokens();
629
+
630
+ // Filter by category if specified
631
+ if (category) {
632
+ tokens = tokens.filter(t => t.category === category);
633
+ }
634
+
635
+ // Filter by theme if specified
636
+ if (theme) {
637
+ tokens = tokens.filter(t => t.theme === theme || t.theme === "default");
638
+ }
639
+
640
+ const meta = registry.getMeta();
641
+
642
+ if (format === "summary") {
643
+ // Return summary only
644
+ const summary = {
645
+ totalTokens: meta?.totalTokens || 0,
646
+ byCategory: {} as Record<string, number>,
647
+ byTheme: {} as Record<string, number>,
648
+ parseTimeMs: meta?.parseTimeMs || 0,
649
+ sourceFiles: meta?.sourceFiles || [],
650
+ };
651
+
652
+ for (const token of registry.getAllTokens()) {
653
+ summary.byCategory[token.category] = (summary.byCategory[token.category] || 0) + 1;
654
+ summary.byTheme[token.theme] = (summary.byTheme[token.theme] || 0) + 1;
655
+ }
656
+
657
+ res.setHeader("Content-Type", "application/json");
658
+ res.end(JSON.stringify(summary, null, 2));
659
+ } else {
660
+ // Return full token list
661
+ res.setHeader("Content-Type", "application/json");
662
+ res.end(JSON.stringify({
663
+ tokens,
664
+ meta,
665
+ }, null, 2));
666
+ }
667
+ } catch (error) {
668
+ console.error("[Fragments] Error fetching tokens:", error);
669
+ res.writeHead(500, { "Content-Type": "application/json" });
670
+ res.end(JSON.stringify({
671
+ error: error instanceof Error ? error.message : "Failed to fetch tokens",
672
+ }));
673
+ }
674
+ return;
675
+ }
676
+
677
+ // Handle /fragments/token-match endpoint for reverse token lookup
678
+ if (req.url === "/fragments/token-match" && req.method === "POST") {
679
+ try {
680
+ const body = (await parseJsonBody(req)) as {
681
+ value: string;
682
+ propertyType?: "color" | "spacing" | "typography" | "other";
683
+ theme?: string;
684
+ };
685
+
686
+ const { value, propertyType, theme } = body;
687
+
688
+ if (!value) {
689
+ res.writeHead(400, { "Content-Type": "application/json" });
690
+ res.end(JSON.stringify({ error: "Missing required field: value" }));
691
+ return;
692
+ }
693
+
694
+ // Check if tokens are configured
695
+ if (!config.tokens || !config.tokens.include || config.tokens.include.length === 0) {
696
+ res.writeHead(400, { "Content-Type": "application/json" });
697
+ res.end(JSON.stringify({
698
+ error: "No token configuration found",
699
+ suggestion: "Add 'tokens' config to fragments.config.ts",
700
+ }));
701
+ return;
702
+ }
703
+
704
+ // Import token registry
705
+ const { getSharedTokenRegistry } = await import("../service/index.js");
706
+ const registry = getSharedTokenRegistry();
707
+
708
+ // Initialize if not already
709
+ if (!registry.isInitialized()) {
710
+ await registry.initialize(config.tokens, projectRoot);
711
+ }
712
+
713
+ // Match the value
714
+ const result = registry.matchValue({
715
+ value,
716
+ propertyType,
717
+ theme,
718
+ });
719
+
720
+ res.setHeader("Content-Type", "application/json");
721
+ res.end(JSON.stringify(result));
722
+ } catch (error) {
723
+ console.error("[Fragments] Error matching token:", error);
724
+ res.writeHead(500, { "Content-Type": "application/json" });
725
+ res.end(JSON.stringify({
726
+ error: error instanceof Error ? error.message : "Failed to match token",
727
+ }));
728
+ }
729
+ return;
730
+ }
731
+
732
+ // Handle /fragments/compliance endpoint for token compliance checking
733
+ if (req.url === "/fragments/compliance" && req.method === "POST") {
734
+ try {
735
+ const body = (await parseJsonBody(req)) as {
736
+ component: string;
737
+ variant?: string;
738
+ theme?: string;
739
+ };
740
+
741
+ const { component, variant, theme = "default" } = body;
742
+
743
+ if (!component) {
744
+ res.writeHead(400, { "Content-Type": "application/json" });
745
+ res.end(JSON.stringify({ error: "Missing required field: component" }));
746
+ return;
747
+ }
748
+
749
+ // Check if tokens are configured
750
+ if (!config.tokens || !config.tokens.include || config.tokens.include.length === 0) {
751
+ // Return 100% compliance if no tokens configured (can't check)
752
+ res.setHeader("Content-Type", "application/json");
753
+ res.end(JSON.stringify({
754
+ component,
755
+ variant,
756
+ compliance: 100,
757
+ totalProperties: 0,
758
+ hardcoded: 0,
759
+ usingTokens: 0,
760
+ violations: [],
761
+ note: "No token configuration found - token compliance checking disabled",
762
+ }));
763
+ return;
764
+ }
765
+
766
+ // Load segment data
767
+ const loadedSegments = await loadSegmentsForRender(segmentFiles, projectRoot);
768
+ const segmentInfo = findSegmentByName(component, loadedSegments);
769
+
770
+ if (!segmentInfo) {
771
+ const available = getAvailableComponents(loadedSegments);
772
+ res.writeHead(400, { "Content-Type": "application/json" });
773
+ res.end(JSON.stringify({
774
+ error: `Component '${component}' not found. Available: ${available.join(", ")}`,
775
+ }));
776
+ return;
777
+ }
778
+
779
+ // Find segment file for rendering
780
+ const segmentFile = segmentFiles.find(
781
+ (f) => f.relativePath === segmentInfo.path
782
+ );
783
+ if (!segmentFile) {
784
+ res.writeHead(500, { "Content-Type": "application/json" });
785
+ res.end(JSON.stringify({ error: "Could not resolve segment file path" }));
786
+ return;
787
+ }
788
+
789
+ // Import token registry
790
+ const { getSharedTokenRegistry } = await import("../service/index.js");
791
+ const registry = getSharedTokenRegistry();
792
+
793
+ // Initialize if not already
794
+ if (!registry.isInitialized()) {
795
+ await registry.initialize(config.tokens, projectRoot);
796
+ }
797
+
798
+ // Get server port
799
+ const address = _server.httpServer?.address();
800
+ const port = typeof address === "object" && address ? address.port : 6006;
801
+ const renderViewport = { width: 800, height: 600 };
802
+
803
+ // Generate render script and capture with styles
804
+ const renderScript = generateRenderScript(
805
+ segmentFile.absolutePath,
806
+ segmentInfo.name,
807
+ {}
808
+ );
809
+ const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2);
810
+ pendingRenders.set(requestId, { script: renderScript, viewport: renderViewport });
811
+
812
+ try {
813
+ // Render the component and extract computed styles
814
+ const captureResult = await captureRenderWithStyles(
815
+ `http://localhost:${port}/fragments/__render__/${requestId}`,
816
+ renderViewport,
817
+ true // extractStyles = true
818
+ );
819
+
820
+ const computedStyles = captureResult.computedStyles || {};
821
+
822
+ // Convert computed styles to style diff format for calculateUsageSummary
823
+ const styleDiffs: Array<{
824
+ property: string;
825
+ figma: string;
826
+ rendered: string;
827
+ match: boolean;
828
+ }> = [];
829
+
830
+ // Check each computed style property
831
+ for (const [property, value] of Object.entries(computedStyles)) {
832
+ if (!value) continue;
833
+
834
+ // Try to find a matching token
835
+ const matchResult = registry.matchValue({
836
+ value,
837
+ propertyType: property.toLowerCase().includes("color") ? "color" :
838
+ property.toLowerCase().includes("font") ? "typography" :
839
+ property.toLowerCase().includes("spacing") || property.toLowerCase().includes("padding") || property.toLowerCase().includes("margin") ? "spacing" : undefined,
840
+ theme,
841
+ });
842
+
843
+ // If we found an exact match, the value is using a token
844
+ const isUsingToken = matchResult.exactMatches.length > 0;
845
+
846
+ styleDiffs.push({
847
+ property,
848
+ figma: value, // Use the value as both figma and rendered for self-comparison
849
+ rendered: value,
850
+ match: isUsingToken,
851
+ });
852
+ }
853
+
854
+ // Calculate compliance using token registry
855
+ const usageSummary = registry.calculateUsageSummary(styleDiffs, theme);
856
+
857
+ // Build violations list from hardcoded properties
858
+ interface ViolationItem {
859
+ property: string;
860
+ issue: string;
861
+ severity: "error" | "warning";
862
+ suggestion?: string;
863
+ expected?: string;
864
+ actual?: string;
865
+ }
866
+
867
+ const violations: ViolationItem[] = usageSummary.hardcodedProperties.map(hp => {
868
+ const suggestion = hp.suggestedFix
869
+ ? `Use ${hp.suggestedFix.tokenName} (${hp.suggestedFix.tokenValue})`
870
+ : undefined;
871
+
872
+ return {
873
+ property: hp.property,
874
+ issue: `Hardcoded value "${hp.rendered}" should use a design token`,
875
+ severity: "warning" as const,
876
+ suggestion,
877
+ expected: hp.figmaToken,
878
+ actual: hp.rendered,
879
+ };
880
+ });
881
+
882
+ res.setHeader("Content-Type", "application/json");
883
+ res.end(JSON.stringify({
884
+ component,
885
+ variant,
886
+ compliance: usageSummary.compliancePercent,
887
+ totalProperties: usageSummary.totalProperties,
888
+ hardcoded: usageSummary.hardcoded,
889
+ usingTokens: usageSummary.usingTokens,
890
+ violations,
891
+ }));
892
+ } finally {
893
+ pendingRenders.delete(requestId);
894
+ }
895
+ } catch (error) {
896
+ console.error("[Fragments] Error checking compliance:", error);
897
+ res.writeHead(500, { "Content-Type": "application/json" });
898
+ res.end(JSON.stringify({
899
+ error: error instanceof Error ? error.message : "Compliance check failed",
900
+ }));
901
+ }
902
+ return;
903
+ }
904
+
905
+ // Handle /fragments/context endpoint for AI context generation
906
+ if (req.url?.startsWith("/fragments/context")) {
907
+ try {
908
+ const url = new URL(req.url, "http://localhost");
909
+ const format = (url.searchParams.get("format") || "markdown") as
910
+ | "markdown"
911
+ | "json";
912
+ const compact = url.searchParams.get("compact") === "true";
913
+
914
+ // Load all segments from BRAND.outFile
915
+ const compiledSegments = await loadSegmentsForContext(
916
+ _server,
917
+ segmentFiles,
918
+ config,
919
+ projectRoot
920
+ );
921
+
922
+ const { content, tokenEstimate } = generateContext(
923
+ compiledSegments,
924
+ {
925
+ format,
926
+ compact,
927
+ include: {
928
+ code: url.searchParams.get("code") === "true",
929
+ relations: url.searchParams.get("relations") === "true",
930
+ },
931
+ }
932
+ );
933
+
934
+ res.setHeader("X-Token-Estimate", String(tokenEstimate));
935
+ res.setHeader(
936
+ "Content-Type",
937
+ format === "json"
938
+ ? "application/json"
939
+ : "text/markdown; charset=utf-8"
940
+ );
941
+ res.end(content);
942
+ } catch (error) {
943
+ console.error("[Fragments] Error generating context:", error);
944
+ res.writeHead(500, { "Content-Type": "text/plain" });
945
+ res.end(
946
+ "Error generating context: " +
947
+ (error instanceof Error ? error.message : error)
948
+ );
949
+ }
950
+ return;
951
+ }
952
+
953
+ // Handle /fragments/save endpoint for saving fragment metadata
954
+ if (req.url === "/fragments/save" && req.method === "POST") {
955
+ try {
956
+ const body = await parseJsonBody(req);
957
+ const { componentName, fragment } = body as {
958
+ componentName: string;
959
+ fragment: Record<string, unknown>;
960
+ };
961
+
962
+ if (!componentName || !fragment) {
963
+ res.writeHead(400, { "Content-Type": "application/json" });
964
+ res.end(
965
+ JSON.stringify({
966
+ error: "Missing required fields: componentName, fragment",
967
+ })
968
+ );
969
+ return;
970
+ }
971
+
972
+ // Import writeFile for saving
973
+ const { writeFile, mkdir } = await import("node:fs/promises");
974
+ const { join } = await import("node:path");
975
+ const { BRAND } = await import("../core/index.js");
976
+
977
+ // Ensure .fragments/components directory exists
978
+ const fragmentsDir = join(projectRoot, BRAND.dataDir, BRAND.componentsDir);
979
+ await mkdir(fragmentsDir, { recursive: true });
980
+
981
+ // Write fragment file
982
+ const fragmentPath = join(
983
+ fragmentsDir,
984
+ `${componentName}${BRAND.fileExtension}`
985
+ );
986
+ await writeFile(
987
+ fragmentPath,
988
+ JSON.stringify(fragment, null, 2),
989
+ "utf-8"
990
+ );
991
+
992
+ res.setHeader("Content-Type", "application/json");
993
+ res.end(JSON.stringify({ success: true, path: fragmentPath }));
994
+ } catch (error) {
995
+ console.error("[Fragments] Error saving fragment:", error);
996
+ res.writeHead(500, { "Content-Type": "application/json" });
997
+ res.end(
998
+ JSON.stringify({
999
+ error: error instanceof Error ? error.message : "Save failed",
1000
+ })
1001
+ );
1002
+ }
1003
+ return;
1004
+ }
1005
+
1006
+ // Handle /fragments/fix endpoint for generating token fix patches
1007
+ if (req.url === "/fragments/fix" && req.method === "POST") {
1008
+ try {
1009
+ const body = (await parseJsonBody(req)) as {
1010
+ component: string;
1011
+ variant?: string;
1012
+ fixType?: "token" | "all";
1013
+ };
1014
+
1015
+ const { component, variant, fixType = "all" } = body;
1016
+
1017
+ if (!component) {
1018
+ res.writeHead(400, { "Content-Type": "application/json" });
1019
+ res.end(JSON.stringify({ error: "Missing required field: component" }));
1020
+ return;
1021
+ }
1022
+
1023
+ // Check if tokens are configured
1024
+ if (!config.tokens || !config.tokens.include || config.tokens.include.length === 0) {
1025
+ res.writeHead(400, { "Content-Type": "application/json" });
1026
+ res.end(JSON.stringify({
1027
+ error: "No token configuration found",
1028
+ suggestion: "Add 'tokens' config to fragments.config.ts to enable fix generation",
1029
+ }));
1030
+ return;
1031
+ }
1032
+
1033
+ // Load segment data
1034
+ const loadedSegments = await loadSegmentsForRender(segmentFiles, projectRoot);
1035
+ const segmentInfo = findSegmentByName(component, loadedSegments);
1036
+
1037
+ if (!segmentInfo) {
1038
+ const available = getAvailableComponents(loadedSegments);
1039
+ res.writeHead(400, { "Content-Type": "application/json" });
1040
+ res.end(JSON.stringify({
1041
+ error: `Component '${component}' not found. Available: ${available.join(", ")}`,
1042
+ }));
1043
+ return;
1044
+ }
1045
+
1046
+ // Import services
1047
+ const {
1048
+ getSharedTokenRegistry,
1049
+ generateTokenPatches,
1050
+ } = await import("../service/index.js");
1051
+ const registry = getSharedTokenRegistry();
1052
+
1053
+ // Initialize token registry if not already
1054
+ if (!registry.isInitialized()) {
1055
+ await registry.initialize(config.tokens, projectRoot);
1056
+ }
1057
+
1058
+ // For now, we generate patches based on style diff data
1059
+ // In a full implementation, we would:
1060
+ // 1. Render the component and get computed styles
1061
+ // 2. Compare with Figma styles to find hardcoded values
1062
+ // 3. Generate patches for each hardcoded value
1063
+
1064
+ // Get source file path from segment
1065
+ const segmentFile = segmentFiles.find(
1066
+ (f) => f.relativePath === segmentInfo.path
1067
+ );
1068
+ const sourceFile = segmentFile?.relativePath || `${component}.tsx`;
1069
+
1070
+ // For demonstration, we'll create a placeholder response
1071
+ // In production, this would use style comparison + AST patching
1072
+ const result = generateTokenPatches(
1073
+ component,
1074
+ [], // Would be populated by actual style diffs
1075
+ registry,
1076
+ { sourceFile }
1077
+ );
1078
+
1079
+ res.setHeader("Content-Type", "application/json");
1080
+ res.end(JSON.stringify({
1081
+ patches: result.patches,
1082
+ summary: result.summary,
1083
+ fixableCount: result.fixableCount,
1084
+ unfixableCount: result.unfixableCount,
1085
+ }));
1086
+ } catch (error) {
1087
+ console.error("[Fragments] Error generating fixes:", error);
1088
+ res.writeHead(500, { "Content-Type": "application/json" });
1089
+ res.end(JSON.stringify({
1090
+ error: error instanceof Error ? error.message : "Fix generation failed",
1091
+ }));
1092
+ }
1093
+ return;
1094
+ }
1095
+
1096
+ // Handle /fragments/preview/ - isolated iframe for component previews
1097
+ if (req.url?.startsWith("/fragments/preview")) {
1098
+ // Redirect to trailing slash
1099
+ if (req.url === "/fragments/preview") {
1100
+ res.writeHead(302, { Location: "/fragments/preview/" });
1101
+ res.end();
1102
+ return;
1103
+ }
1104
+
1105
+ // Serve the preview frame HTML
1106
+ await servePreviewFrameHTML(res, _server);
1107
+ return;
1108
+ }
1109
+
1110
+ if (req.url === "/segments" || req.url === "/fragments/") {
1111
+ // Redirect to /fragments/
1112
+ if (!req.url.endsWith("/")) {
1113
+ res.writeHead(302, { Location: "/fragments/" });
1114
+ res.end();
1115
+ return;
1116
+ }
1117
+
1118
+ // Serve the viewer HTML
1119
+ serveViewerHTML(res, _server);
1120
+ return;
1121
+ }
1122
+
1123
+ next();
1124
+ });
1125
+
1126
+ // Log startup message
1127
+ _server.httpServer?.once("listening", () => {
1128
+ const address = _server.httpServer?.address();
1129
+ const port =
1130
+ typeof address === "object" && address ? address.port : 6006;
1131
+ console.log(
1132
+ `\n 📦 Fragments Viewer: http://localhost:${port}/fragments/\n`
1133
+ );
1134
+ });
1135
+ },
1136
+
1137
+ // Resolve virtual modules
1138
+ resolveId(id) {
1139
+ if (id === VIRTUAL_SEGMENTS) {
1140
+ return VIRTUAL_SEGMENTS_RESOLVED;
1141
+ }
1142
+ if (id === VIRTUAL_VIEWER_ENTRY) {
1143
+ return VIRTUAL_VIEWER_ENTRY_RESOLVED;
1144
+ }
1145
+ if (id === VIRTUAL_PREVIEW) {
1146
+ return VIRTUAL_PREVIEW_RESOLVED;
1147
+ }
1148
+ return null;
1149
+ },
1150
+
1151
+ // Load virtual modules
1152
+ load(id) {
1153
+ if (id === VIRTUAL_SEGMENTS_RESOLVED) {
1154
+ return generateSegmentsModule(segmentFiles, config, previewConfigPath);
1155
+ }
1156
+ if (id === VIRTUAL_VIEWER_ENTRY_RESOLVED) {
1157
+ return generateViewerEntry();
1158
+ }
1159
+ if (id === VIRTUAL_PREVIEW_RESOLVED) {
1160
+ return generatePreviewModule(previewConfigPath);
1161
+ }
1162
+ return null;
1163
+ },
1164
+
1165
+ // Handle HMR for segment files
1166
+ handleHotUpdate({ file, server }) {
1167
+ if (segmentFileSet.has(file)) {
1168
+ // Invalidate the virtual segments module
1169
+ const mod = server.moduleGraph.getModuleById(VIRTUAL_SEGMENTS_RESOLVED);
1170
+ if (mod) {
1171
+ server.moduleGraph.invalidateModule(mod);
1172
+ }
1173
+
1174
+ // Send HMR update
1175
+ server.ws.send({
1176
+ type: "custom",
1177
+ event: "segments:update",
1178
+ data: { file },
1179
+ });
1180
+
1181
+ // Return empty array to prevent full reload
1182
+ // The component HMR will handle the actual update
1183
+ return [];
1184
+ }
1185
+ },
1186
+ };
1187
+
1188
+ // Plugin to transform JSX in .js files (common in Storybook preview.js)
1189
+ // Uses the `load` hook instead of `transform` because we need to intercept
1190
+ // the file BEFORE Vite's import-analysis tries to parse it
1191
+ const jsxTransformPlugin: Plugin = {
1192
+ name: "segments-jsx-transform",
1193
+ enforce: "pre",
1194
+ async load(id) {
1195
+ // Only handle .js files that might contain JSX (like preview.js)
1196
+ if (!id.endsWith(".js")) return null;
1197
+
1198
+ // Only handle .storybook directory files (most common case for JSX in .js)
1199
+ if (!id.includes(".storybook")) return null;
1200
+
1201
+ // Read the file content
1202
+ const fs = await import("node:fs/promises");
1203
+ let code: string;
1204
+ try {
1205
+ code = await fs.readFile(id, "utf-8");
1206
+ } catch {
1207
+ return null;
1208
+ }
1209
+
1210
+ // Check if the file contains JSX syntax
1211
+ const hasOpeningTag = code.includes("<");
1212
+ const hasSelfClosingTag = code.includes("/>");
1213
+ const hasClosingTag = code.includes("</");
1214
+
1215
+ // Skip if no JSX detected
1216
+ if (!hasOpeningTag || (!hasSelfClosingTag && !hasClosingTag)) return null;
1217
+
1218
+ try {
1219
+ const result = await transform(code, {
1220
+ loader: "jsx",
1221
+ jsx: "automatic",
1222
+ sourcefile: id,
1223
+ sourcemap: true,
1224
+ });
1225
+ return {
1226
+ code: result.code,
1227
+ map: result.map,
1228
+ };
1229
+ } catch (error) {
1230
+ // Log error for debugging but don't block
1231
+ console.warn(`[Fragments] JSX transform failed for ${id}:`, error instanceof Error ? error.message : error);
1232
+ return null;
1233
+ }
1234
+ },
1235
+ };
1236
+
1237
+ // Return array of plugins including SVGR for SVG imports
1238
+ return [
1239
+ // JSX transform for .js files (must run first)
1240
+ jsxTransformPlugin,
1241
+ // SVGR plugin to handle `import { ReactComponent } from "*.svg"` pattern
1242
+ svgr({
1243
+ svgrOptions: {
1244
+ exportType: "named", // Export as { ReactComponent }
1245
+ },
1246
+ include: "**/*.svg",
1247
+ }),
1248
+ // Main segments plugin
1249
+ mainPlugin,
1250
+ ];
1251
+ }
1252
+
1253
+ /**
1254
+ * Check if a file path is a Storybook story file
1255
+ */
1256
+ function isStoryFile(filePath: string): boolean {
1257
+ return /\.stories\.(tsx?|jsx?)$/.test(filePath);
1258
+ }
1259
+
1260
+ /**
1261
+ * Get the base component path from a segment or story file path.
1262
+ * e.g., "src/components/Button/Button.segment.tsx" -> "src/components/Button/Button"
1263
+ * e.g., "src/components/Button/Button.stories.tsx" -> "src/components/Button/Button"
1264
+ */
1265
+ function getBaseComponentPath(filePath: string): string {
1266
+ return filePath.replace(/\.(segment|stories)\.(tsx?|jsx?)$/, "");
1267
+ }
1268
+
1269
+ /**
1270
+ * Generate the virtual segments module.
1271
+ * Uses dynamic imports for lazy loading - segments are loaded on demand.
1272
+ * Supports both native .segment.tsx files and Storybook .stories.tsx files.
1273
+ * Integrates Storybook preview config for global decorators, parameters, etc.
1274
+ *
1275
+ * MERGE STRATEGY: When both .segment.tsx and .stories.tsx exist for the same component:
1276
+ * - Use .stories.tsx for RENDERING (variants, props, etc.) - it's the source of truth
1277
+ * - Merge METADATA from .segment.tsx (Figma URLs, AI descriptions, usage guidelines)
1278
+ * - This gives us the best of both worlds: working renders + rich metadata
1279
+ */
1280
+ function generateSegmentsModule(
1281
+ segmentFiles: Array<{ absolutePath: string; relativePath: string }>,
1282
+ config: SegmentsConfig,
1283
+ previewConfigPath: string | null
1284
+ ): string {
1285
+ // Group files by base component path to identify pairs
1286
+ const filesByBasePath = new Map<string, {
1287
+ storyFile?: { absolutePath: string; relativePath: string };
1288
+ segmentFile?: { absolutePath: string; relativePath: string };
1289
+ }>();
1290
+
1291
+ for (const file of segmentFiles) {
1292
+ const basePath = getBaseComponentPath(file.relativePath);
1293
+ const isStory = isStoryFile(file.relativePath);
1294
+
1295
+ const existing = filesByBasePath.get(basePath) || {};
1296
+
1297
+ if (isStory) {
1298
+ existing.storyFile = file;
1299
+ } else {
1300
+ existing.segmentFile = file;
1301
+ }
1302
+
1303
+ filesByBasePath.set(basePath, existing);
1304
+ }
1305
+
1306
+ // Generate loaders with metadata merge support
1307
+ // Priority: stories for rendering, segment for metadata (Figma URLs, etc.)
1308
+ const loaders = Array.from(filesByBasePath.values())
1309
+ .map((files) => {
1310
+ // Determine which file to use for rendering
1311
+ const primaryFile = files.storyFile || files.segmentFile;
1312
+ if (!primaryFile) return null;
1313
+
1314
+ const isStory = !!files.storyFile;
1315
+
1316
+ // If we have both, include the segment file path for metadata merge
1317
+ const metadataPath = (files.storyFile && files.segmentFile)
1318
+ ? files.segmentFile.absolutePath
1319
+ : null;
1320
+
1321
+ return ` {
1322
+ path: "${primaryFile.relativePath}",
1323
+ isStory: ${isStory},
1324
+ loader: () => import("${primaryFile.absolutePath}"),
1325
+ metadataLoader: ${metadataPath ? `() => import("${metadataPath}")` : 'null'}
1326
+ }`;
1327
+ })
1328
+ .filter(Boolean)
1329
+ .join(",\n");
1330
+
1331
+ // Generate preview config import if available
1332
+ const previewImport = previewConfigPath
1333
+ ? `import * as previewConfig from "virtual:segments-preview";`
1334
+ : "";
1335
+ const previewSetup = previewConfigPath
1336
+ ? `
1337
+ // Set global preview config before loading segments
1338
+ setPreviewConfig({
1339
+ decorators: previewConfig.decorators,
1340
+ parameters: previewConfig.parameters,
1341
+ globalTypes: previewConfig.globalTypes,
1342
+ args: previewConfig.args,
1343
+ argTypes: previewConfig.argTypes,
1344
+ loaders: previewConfig.loaders,
1345
+ });
1346
+ `
1347
+ : "";
1348
+
1349
+ return `
1350
+ import { storyModuleToSegment, setPreviewConfig } from "@fragments/core";
1351
+ ${previewImport}
1352
+ ${previewSetup}
1353
+ // Lazy segment loaders (supports both .segment.tsx and .stories.tsx)
1354
+ const segmentLoaders = [
1355
+ ${loaders}
1356
+ ];
1357
+
1358
+ // Cache for loaded segments
1359
+ const loadedSegments = new Map();
1360
+
1361
+ /**
1362
+ * Merge metadata from a segment file into a story-based segment.
1363
+ * This preserves Figma URLs and other AI-agent focused data.
1364
+ */
1365
+ function mergeMetadata(segment, metadataModule) {
1366
+ if (!metadataModule?.default) return segment;
1367
+
1368
+ const metadata = metadataModule.default;
1369
+
1370
+ // Merge meta-level Figma URL
1371
+ if (metadata.meta?.figma && !segment.meta.figma) {
1372
+ segment.meta.figma = metadata.meta.figma;
1373
+ }
1374
+
1375
+ // Merge description if not present
1376
+ if (metadata.meta?.description && !segment.meta.description) {
1377
+ segment.meta.description = metadata.meta.description;
1378
+ }
1379
+
1380
+ // Merge variant-level Figma URLs
1381
+ if (metadata.variants && segment.variants) {
1382
+ for (const metaVariant of metadata.variants) {
1383
+ const segmentVariant = segment.variants.find(v => v.name === metaVariant.name);
1384
+ if (segmentVariant && metaVariant.figma && !segmentVariant.figma) {
1385
+ segmentVariant.figma = metaVariant.figma;
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ return segment;
1391
+ }
1392
+
1393
+ // Load all segments (for initial render)
1394
+ // Gracefully handles individual failures - one bad story won't break all segments
1395
+ export async function loadAllSegments() {
1396
+ const results = await Promise.all(
1397
+ segmentLoaders.map(async (loader) => {
1398
+ try {
1399
+ if (loadedSegments.has(loader.path)) {
1400
+ const cached = loadedSegments.get(loader.path);
1401
+ return cached ? { path: loader.path, segment: cached } : null;
1402
+ }
1403
+
1404
+ const module = await loader.loader();
1405
+
1406
+ // Convert story modules to segments at runtime
1407
+ let segment;
1408
+ if (loader.isStory) {
1409
+ segment = storyModuleToSegment(module, loader.path);
1410
+ // storyModuleToSegment returns null for stories without a component
1411
+ if (!segment) {
1412
+ loadedSegments.set(loader.path, null);
1413
+ return null;
1414
+ }
1415
+ } else {
1416
+ segment = module.default;
1417
+ }
1418
+
1419
+ // Merge metadata from corresponding segment file if available
1420
+ if (loader.metadataLoader) {
1421
+ try {
1422
+ const metadataModule = await loader.metadataLoader();
1423
+ segment = mergeMetadata(segment, metadataModule);
1424
+ } catch (metaError) {
1425
+ // Metadata loading is optional - don't fail if it errors
1426
+ console.warn("[Fragments] Could not load metadata for " + loader.path + ":", metaError.message);
1427
+ }
1428
+ }
1429
+
1430
+ loadedSegments.set(loader.path, segment);
1431
+ return { path: loader.path, segment };
1432
+ } catch (error) {
1433
+ console.warn("[Fragments] Failed to load " + loader.path + ":", error.message);
1434
+ return null;
1435
+ }
1436
+ })
1437
+ );
1438
+ // Filter out failed loads
1439
+ return results.filter(r => r !== null);
1440
+ }
1441
+
1442
+ // Load a single segment by path
1443
+ export async function loadSegment(path) {
1444
+ const loader = segmentLoaders.find(l => l.path === path);
1445
+ if (!loader) return null;
1446
+
1447
+ if (loadedSegments.has(path)) {
1448
+ return loadedSegments.get(path);
1449
+ }
1450
+
1451
+ const module = await loader.loader();
1452
+
1453
+ // Convert story modules to segments at runtime
1454
+ let segment;
1455
+ if (loader.isStory) {
1456
+ segment = storyModuleToSegment(module, path);
1457
+ } else {
1458
+ segment = module.default;
1459
+ }
1460
+
1461
+ // Merge metadata from corresponding segment file if available
1462
+ if (loader.metadataLoader && segment) {
1463
+ try {
1464
+ const metadataModule = await loader.metadataLoader();
1465
+ segment = mergeMetadata(segment, metadataModule);
1466
+ } catch (metaError) {
1467
+ console.warn("[Fragments] Could not load metadata for " + path + ":", metaError.message);
1468
+ }
1469
+ }
1470
+
1471
+ loadedSegments.set(path, segment);
1472
+ return segment;
1473
+ }
1474
+
1475
+ // For backwards compatibility, load all segments synchronously on import
1476
+ // This is still lazy per-file but awaited at module load
1477
+ let segments = [];
1478
+ const segmentsPromise = loadAllSegments().then(s => { segments = s; return s; });
1479
+
1480
+ export { segments, segmentsPromise };
1481
+ export const config = ${JSON.stringify(config)};
1482
+
1483
+ // HMR support
1484
+ if (import.meta.hot) {
1485
+ import.meta.hot.accept();
1486
+
1487
+ import.meta.hot.on("segments:update", (data) => {
1488
+ console.log("[Fragments] File updated:", data.file);
1489
+ // Clear cache for the updated file (handles both .segment and .stories)
1490
+ for (const [path, _] of loadedSegments) {
1491
+ const basePath = path.replace(/\\.(segment|stories)\\.tsx?$/, '');
1492
+ if (data.file.includes(basePath)) {
1493
+ loadedSegments.delete(path);
1494
+ }
1495
+ }
1496
+ // Trigger re-render in viewer
1497
+ window.dispatchEvent(new CustomEvent("segments:update"));
1498
+ });
1499
+ }
1500
+ `;
1501
+ }
1502
+
1503
+ /**
1504
+ * Generate the viewer entry point.
1505
+ */
1506
+ function generateViewerEntry(): string {
1507
+ return `
1508
+ import { segments, config } from "virtual:segments";
1509
+
1510
+ // Re-export for viewer
1511
+ export { segments, config };
1512
+
1513
+ // Initialize viewer
1514
+ console.log("[Fragments] Loaded", segments.length, "segment(s)");
1515
+ `;
1516
+ }
1517
+
1518
+ /**
1519
+ * Load segments for context generation.
1520
+ * Uses BRAND.outFile to avoid SSR module loading issues with React CJS modules.
1521
+ */
1522
+ async function loadSegmentsForContext(
1523
+ _server: ViteDevServer,
1524
+ _segmentFiles: Array<{ absolutePath: string; relativePath: string }>,
1525
+ _config: SegmentsConfig,
1526
+ configDir?: string
1527
+ ): Promise<CompiledSegment[]> {
1528
+ const { join } = await import("node:path");
1529
+
1530
+ // Read from outFile (avoids SSR issues with React CJS)
1531
+ const segmentsJsonPath = join(configDir || process.cwd(), BRAND.outFile);
1532
+
1533
+ try {
1534
+ const content = await readFile(segmentsJsonPath, "utf-8");
1535
+ const data = JSON.parse(content) as {
1536
+ segments: Record<string, CompiledSegment>;
1537
+ };
1538
+
1539
+ return Object.values(data.segments || {});
1540
+ } catch (error) {
1541
+ console.warn(
1542
+ `[${BRAND.name}] Failed to load ${BRAND.outFile} for context:`,
1543
+ error
1544
+ );
1545
+ console.warn(`[${BRAND.name}] Run '${BRAND.cliCommand} build' to generate ${BRAND.outFile}`);
1546
+ return [];
1547
+ }
1548
+ }
1549
+
1550
+ /**
1551
+ * Serve the viewer HTML page.
1552
+ */
1553
+ async function serveViewerHTML(res: any, server: ViteDevServer): Promise<void> {
1554
+ const viewerRoot = resolve(__dirname, "..");
1555
+ const entryPath = resolve(viewerRoot, "src/entry.tsx");
1556
+
1557
+ try {
1558
+ // Read the viewer HTML template
1559
+ let html = await readFile(resolve(viewerRoot, "index.html"), "utf-8");
1560
+
1561
+ // Rewrite the entry.tsx path to use absolute path to viewer package
1562
+ html = html.replace("/src/entry.tsx", entryPath);
1563
+
1564
+ // Transform HTML through Vite's pipeline
1565
+ html = await server.transformIndexHtml("/fragments/", html);
1566
+
1567
+ res.writeHead(200, { "Content-Type": "text/html" });
1568
+ res.end(html);
1569
+ } catch (error) {
1570
+ console.error("[Fragments] Error serving viewer:", error);
1571
+ res.writeHead(500, { "Content-Type": "text/plain" });
1572
+ res.end("Error loading Segments viewer");
1573
+ }
1574
+ }
1575
+
1576
+ /**
1577
+ * Serve the isolated preview frame HTML page.
1578
+ * This is used for iframe-based component preview with CSS isolation.
1579
+ */
1580
+ async function servePreviewFrameHTML(res: any, server: ViteDevServer): Promise<void> {
1581
+ const viewerRoot = resolve(__dirname, "..");
1582
+ const entryPath = resolve(viewerRoot, "src/preview-frame-entry.tsx");
1583
+
1584
+ try {
1585
+ // Read the preview frame HTML template
1586
+ let html = await readFile(resolve(viewerRoot, "src/preview-frame.html"), "utf-8");
1587
+
1588
+ // Rewrite the entry path to use absolute path to viewer package
1589
+ html = html.replace("/src/preview-frame-entry.tsx", entryPath);
1590
+
1591
+ // Transform HTML through Vite's pipeline
1592
+ html = await server.transformIndexHtml("/fragments/preview/", html);
1593
+
1594
+ res.writeHead(200, { "Content-Type": "text/html" });
1595
+ res.end(html);
1596
+ } catch (error) {
1597
+ console.error("[Fragments] Error serving preview frame:", error);
1598
+ res.writeHead(500, { "Content-Type": "text/plain" });
1599
+ res.end("Error loading preview frame");
1600
+ }
1601
+ }
1602
+
1603
+ /**
1604
+ * Parse JSON body from an HTTP request.
1605
+ */
1606
+ async function parseJsonBody(req: any): Promise<unknown> {
1607
+ return new Promise((resolve, reject) => {
1608
+ let body = "";
1609
+ req.on("data", (chunk: Buffer) => {
1610
+ body += chunk.toString();
1611
+ });
1612
+ req.on("end", () => {
1613
+ try {
1614
+ resolve(JSON.parse(body));
1615
+ } catch (error) {
1616
+ reject(new Error("Invalid JSON body"));
1617
+ }
1618
+ });
1619
+ req.on("error", reject);
1620
+ });
1621
+ }
1622
+
1623
+ /**
1624
+ * Load segments for render from BRAND.outFile or by building on-the-fly.
1625
+ * This avoids SSR issues with React components.
1626
+ */
1627
+ async function loadSegmentsForRender(
1628
+ segmentFiles: Array<{ absolutePath: string; relativePath: string }>,
1629
+ configDir: string
1630
+ ): Promise<Array<{ path: string; segment: { meta: { name: string } } }>> {
1631
+ const { join } = await import("node:path");
1632
+
1633
+ // Try to read from the project directory
1634
+ const segmentsJsonPath = join(configDir, BRAND.outFile);
1635
+
1636
+ try {
1637
+ const content = await readFile(segmentsJsonPath, "utf-8");
1638
+ const data = JSON.parse(content) as {
1639
+ segments: Record<string, { filePath: string; meta: { name: string } }>;
1640
+ };
1641
+
1642
+ // Convert to the expected format if we have entries
1643
+ const segmentEntries = Object.values(data.segments || {});
1644
+ if (segmentEntries.length > 0) {
1645
+ return segmentEntries.map((segment) => ({
1646
+ path: segment.filePath,
1647
+ segment: { meta: { name: segment.meta.name } },
1648
+ }));
1649
+ }
1650
+ // Fall through to file-based extraction if outFile is empty
1651
+ } catch {
1652
+ // outFile doesn't exist or is invalid - fall through to file-based extraction
1653
+ }
1654
+
1655
+ // Extract component names from file paths (fallback)
1656
+ return segmentFiles.map((f) => {
1657
+ let name: string;
1658
+ if (isStoryFile(f.relativePath)) {
1659
+ // Extract name from path like "src/components/Button/Button.stories.tsx"
1660
+ const match = f.relativePath.match(/\/([^/]+)\.stories\./);
1661
+ name = match ? match[1] : f.relativePath;
1662
+ } else {
1663
+ // Extract name from path like "src/components/Button/Button.segment.tsx"
1664
+ const match = f.relativePath.match(/\/([^/]+)\.segment\./);
1665
+ name = match ? match[1] : f.relativePath;
1666
+ }
1667
+ return {
1668
+ path: f.relativePath,
1669
+ segment: { meta: { name } },
1670
+ };
1671
+ });
1672
+ }
1673
+
1674
+ /**
1675
+ * Serve the render HTML page for AI preview.
1676
+ */
1677
+ async function serveRenderHTML(
1678
+ res: any,
1679
+ server: ViteDevServer,
1680
+ renderScript: string
1681
+ ): Promise<void> {
1682
+ const viewerRoot = resolve(__dirname, "..");
1683
+
1684
+ try {
1685
+ // Read the render template
1686
+ let html = await readFile(
1687
+ resolve(viewerRoot, "src/render-template.html"),
1688
+ "utf-8"
1689
+ );
1690
+
1691
+ // Inject the render script
1692
+ html = html.replace(
1693
+ "<!-- RENDER_SCRIPT_PLACEHOLDER -->",
1694
+ `<script type="module">${renderScript}</script>`
1695
+ );
1696
+
1697
+ // Transform HTML through Vite's pipeline to process imports
1698
+ // Use a unique URL to prevent Vite from caching the transformed HTML
1699
+ const uniqueUrl = `/fragments/__render__/${Date.now()}`;
1700
+ html = await server.transformIndexHtml(uniqueUrl, html);
1701
+
1702
+ res.writeHead(200, { "Content-Type": "text/html" });
1703
+ res.end(html);
1704
+ } catch (error) {
1705
+ console.error("[Fragments] Error serving render page:", error);
1706
+ res.writeHead(500, { "Content-Type": "text/plain" });
1707
+ res.end("Error loading render page");
1708
+ }
1709
+ }
1710
+
1711
+ /**
1712
+ * Capture a render using the shared browser pool from @fragments/service.
1713
+ * Uses a shared pool for efficiency - browser stays warm across requests.
1714
+ */
1715
+ async function captureRender(
1716
+ url: string,
1717
+ viewport: { width: number; height: number }
1718
+ ): Promise<string> {
1719
+ const { pool, bufferToBase64Url } = await getSharedRenderPool();
1720
+
1721
+ const ctx = await pool.acquire();
1722
+ const page = await ctx.newPage();
1723
+
1724
+ try {
1725
+ // Set viewport for this specific render
1726
+ await page.setViewportSize(viewport);
1727
+
1728
+ // Navigate to the render page
1729
+ await page.goto(url, { waitUntil: "networkidle" });
1730
+
1731
+ // Wait for render to complete (indicated by ready class or timeout)
1732
+ await page.waitForFunction(
1733
+ () => (window as any).__RENDER_READY__ === true,
1734
+ { timeout: 10000 }
1735
+ );
1736
+
1737
+ // Check for render error
1738
+ const error = await page.evaluate(() => (window as any).__RENDER_ERROR__);
1739
+ if (error) {
1740
+ throw new Error(`Render error: ${error}`);
1741
+ }
1742
+
1743
+ // Screenshot the render root element
1744
+ const element = await page.$("#render-root");
1745
+ if (!element) {
1746
+ throw new Error("Render root element not found");
1747
+ }
1748
+
1749
+ const screenshot = await element.screenshot({ type: "png" });
1750
+
1751
+ return bufferToBase64Url(screenshot);
1752
+ } finally {
1753
+ await page.close();
1754
+ pool.release(ctx);
1755
+ }
1756
+ }
1757
+
1758
+ /**
1759
+ * Capture a render with optional computed styles extraction.
1760
+ * Uses the shared browser pool for efficiency.
1761
+ */
1762
+ async function captureRenderWithStyles(
1763
+ url: string,
1764
+ viewport: { width: number; height: number },
1765
+ extractStyles: boolean
1766
+ ): Promise<{ screenshot: string; computedStyles: Record<string, string> | null }> {
1767
+ const { pool, bufferToBase64Url } = await getSharedRenderPool();
1768
+
1769
+ const ctx = await pool.acquire();
1770
+ const page = await ctx.newPage();
1771
+
1772
+ try {
1773
+ // Set viewport for this specific render
1774
+ await page.setViewportSize(viewport);
1775
+
1776
+ await page.goto(url, { waitUntil: "networkidle" });
1777
+
1778
+ await page.waitForFunction(
1779
+ () => (window as any).__RENDER_READY__ === true,
1780
+ { timeout: 10000 }
1781
+ );
1782
+
1783
+ const error = await page.evaluate(() => (window as any).__RENDER_ERROR__);
1784
+ if (error) {
1785
+ throw new Error(`Render error: ${error}`);
1786
+ }
1787
+
1788
+ const element = await page.$("#render-root");
1789
+ if (!element) {
1790
+ throw new Error("Render root element not found");
1791
+ }
1792
+
1793
+ // Extract computed styles if requested
1794
+ let computedStyles: Record<string, string> | null = null;
1795
+ if (extractStyles) {
1796
+ computedStyles = await page.evaluate(() => {
1797
+ const root = document.getElementById("render-root");
1798
+ if (!root) return null;
1799
+
1800
+ // Helper function to check if a color is visible (not transparent)
1801
+ const isVisibleColor = (color: string | undefined): boolean => {
1802
+ if (!color) return false;
1803
+ if (color === "transparent") return false;
1804
+ if (color === "rgba(0, 0, 0, 0)") return false;
1805
+ if (color.includes("rgba") && color.includes(", 0)")) return false;
1806
+ return true;
1807
+ };
1808
+
1809
+ // Helper to extract styles from an element
1810
+ const extractStylesFromElement = (el: HTMLElement): Record<string, string> => {
1811
+ const styles = window.getComputedStyle(el);
1812
+ const relevantProps = [
1813
+ "backgroundColor",
1814
+ "borderColor",
1815
+ "borderWidth",
1816
+ "borderRadius",
1817
+ "fontFamily",
1818
+ "fontSize",
1819
+ "fontWeight",
1820
+ "lineHeight",
1821
+ "letterSpacing",
1822
+ "textAlign",
1823
+ "boxShadow",
1824
+ "padding",
1825
+ "paddingTop",
1826
+ "paddingRight",
1827
+ "paddingBottom",
1828
+ "paddingLeft",
1829
+ "gap",
1830
+ "opacity",
1831
+ "width",
1832
+ "height",
1833
+ ];
1834
+
1835
+ const result: Record<string, string> = {};
1836
+ for (const prop of relevantProps) {
1837
+ const value = styles.getPropertyValue(
1838
+ prop.replace(/([A-Z])/g, "-$1").toLowerCase()
1839
+ );
1840
+ if (value) {
1841
+ result[prop] = value;
1842
+ }
1843
+ }
1844
+ return result;
1845
+ };
1846
+
1847
+ // Strategy: Find the element with the most visible styles
1848
+ // Start by looking at all elements and score them based on visual presence
1849
+ const candidates = root.querySelectorAll("*");
1850
+ let bestElement: HTMLElement | null = null;
1851
+ let bestScore = -1;
1852
+
1853
+ for (const el of candidates) {
1854
+ const htmlEl = el as HTMLElement;
1855
+ const styles = window.getComputedStyle(htmlEl);
1856
+ let score = 0;
1857
+
1858
+ // Score based on visual properties
1859
+ const bg = styles.backgroundColor;
1860
+ if (isVisibleColor(bg)) {
1861
+ score += 10; // Visible background is a strong signal
1862
+ }
1863
+
1864
+ const border = styles.borderWidth;
1865
+ if (border && border !== "0px") {
1866
+ score += 3;
1867
+ }
1868
+
1869
+ const boxShadow = styles.boxShadow;
1870
+ if (boxShadow && boxShadow !== "none") {
1871
+ score += 3;
1872
+ }
1873
+
1874
+ // Bonus for being an interactive element
1875
+ const tagName = htmlEl.tagName.toLowerCase();
1876
+ if (["button", "a", "input", "select", "textarea"].includes(tagName)) {
1877
+ score += 5;
1878
+ }
1879
+
1880
+ // Bonus for having role="button"
1881
+ if (htmlEl.getAttribute("role") === "button") {
1882
+ score += 5;
1883
+ }
1884
+
1885
+ // Penalty for being too small (likely not the main component)
1886
+ const rect = htmlEl.getBoundingClientRect();
1887
+ if (rect.width < 10 || rect.height < 10) {
1888
+ score -= 10;
1889
+ }
1890
+
1891
+ // Penalty for very large elements (likely containers)
1892
+ if (rect.width > 500 || rect.height > 500) {
1893
+ score -= 3;
1894
+ }
1895
+
1896
+ if (score > bestScore) {
1897
+ bestScore = score;
1898
+ bestElement = htmlEl;
1899
+ }
1900
+ }
1901
+
1902
+ // If we still have no good element, fall back to first child
1903
+ if (!bestElement) {
1904
+ bestElement = root.firstElementChild as HTMLElement | null;
1905
+ }
1906
+
1907
+ if (!bestElement) return null;
1908
+
1909
+ const result = extractStylesFromElement(bestElement);
1910
+
1911
+ // Normalize padding into shorthand if individual values exist
1912
+ if (result.paddingTop && result.paddingRight && result.paddingBottom && result.paddingLeft) {
1913
+ const t = result.paddingTop;
1914
+ const r = result.paddingRight;
1915
+ const b = result.paddingBottom;
1916
+ const l = result.paddingLeft;
1917
+ if (t === r && r === b && b === l) {
1918
+ result.padding = t;
1919
+ } else if (t === b && r === l) {
1920
+ result.padding = `${t} ${r}`;
1921
+ } else {
1922
+ result.padding = `${t} ${r} ${b} ${l}`;
1923
+ }
1924
+ }
1925
+
1926
+ return result;
1927
+ });
1928
+ }
1929
+
1930
+ const screenshot = await element.screenshot({ type: "png" });
1931
+
1932
+ return {
1933
+ screenshot: bufferToBase64Url(screenshot),
1934
+ computedStyles,
1935
+ };
1936
+ } finally {
1937
+ await page.close();
1938
+ pool.release(ctx);
1939
+ }
1940
+ }
1941
+
1942
+ /**
1943
+ * Load full segment data to get figma URL from segment or variant.
1944
+ * Uses BRAND.outFile to avoid SSR module loading issues with React CJS modules.
1945
+ */
1946
+ async function loadFullSegmentForCompare(
1947
+ _server: ViteDevServer,
1948
+ _segmentFiles: Array<{ absolutePath: string; relativePath: string }>,
1949
+ componentName: string,
1950
+ variantName?: string,
1951
+ configDir?: string
1952
+ ): Promise<{ figmaUrl?: string } | null> {
1953
+ const { join } = await import("node:path");
1954
+
1955
+ // Try to read from outFile (avoids SSR issues with React CJS)
1956
+ const segmentsJsonPath = join(configDir || process.cwd(), BRAND.outFile);
1957
+
1958
+ try {
1959
+ const content = await readFile(segmentsJsonPath, "utf-8");
1960
+ const data = JSON.parse(content) as {
1961
+ segments: Record<
1962
+ string,
1963
+ {
1964
+ meta: { name: string; figma?: string };
1965
+ variants?: Array<{ name: string; figma?: string }>;
1966
+ }
1967
+ >;
1968
+ };
1969
+
1970
+ const segment = data.segments[componentName];
1971
+ if (!segment) {
1972
+ return null;
1973
+ }
1974
+
1975
+ // Priority: variant.figma > meta.figma
1976
+ if (variantName && segment.variants) {
1977
+ const variant = segment.variants.find((v) => v.name === variantName);
1978
+ if (variant?.figma) {
1979
+ return { figmaUrl: variant.figma };
1980
+ }
1981
+ }
1982
+
1983
+ // Fall back to meta.figma
1984
+ if (segment.meta.figma) {
1985
+ return { figmaUrl: segment.meta.figma };
1986
+ }
1987
+
1988
+ return null;
1989
+ } catch {
1990
+ // outFile not found or invalid
1991
+ console.warn(
1992
+ `[${BRAND.name}] ${BRAND.outFile} not found, run '${BRAND.cliCommand} build' first`
1993
+ );
1994
+ return null;
1995
+ }
1996
+ }
1997
+
1998
+ /**
1999
+ * Compare two base64 images and return diff result.
2000
+ */
2001
+ async function compareImages(
2002
+ image1Base64: string,
2003
+ image2Base64: string,
2004
+ threshold: number
2005
+ ): Promise<{
2006
+ matches: boolean;
2007
+ diffPercentage: number;
2008
+ diffImage?: string;
2009
+ changedRegions: Array<{
2010
+ x: number;
2011
+ y: number;
2012
+ width: number;
2013
+ height: number;
2014
+ }>;
2015
+ }> {
2016
+ const { DiffEngine, base64UrlToBuffer, bufferToBase64Url } = await import(
2017
+ "../service/index.js"
2018
+ );
2019
+ const { PNG } = await import("pngjs");
2020
+
2021
+ // Convert base64 to buffers
2022
+ const buffer1 = base64UrlToBuffer(image1Base64);
2023
+ const buffer2 = base64UrlToBuffer(image2Base64);
2024
+
2025
+ // Parse PNGs to get dimensions
2026
+ const png1 = PNG.sync.read(buffer1);
2027
+ const png2 = PNG.sync.read(buffer2);
2028
+
2029
+ // If dimensions don't match, resize the smaller one to match the larger
2030
+ let finalBuffer1 = buffer1;
2031
+ let finalBuffer2 = buffer2;
2032
+
2033
+ if (png1.width !== png2.width || png1.height !== png2.height) {
2034
+ // Resize to the larger dimensions by padding the smaller image
2035
+ const targetWidth = Math.max(png1.width, png2.width);
2036
+ const targetHeight = Math.max(png1.height, png2.height);
2037
+
2038
+ if (png1.width !== targetWidth || png1.height !== targetHeight) {
2039
+ finalBuffer1 = await resizePng(
2040
+ buffer1,
2041
+ png1.width,
2042
+ png1.height,
2043
+ targetWidth,
2044
+ targetHeight
2045
+ );
2046
+ }
2047
+ if (png2.width !== targetWidth || png2.height !== targetHeight) {
2048
+ finalBuffer2 = await resizePng(
2049
+ buffer2,
2050
+ png2.width,
2051
+ png2.height,
2052
+ targetWidth,
2053
+ targetHeight
2054
+ );
2055
+ }
2056
+ }
2057
+
2058
+ // Create Screenshot-like objects for DiffEngine
2059
+ const screenshot1 = {
2060
+ data: finalBuffer1,
2061
+ hash: "",
2062
+ viewport: { width: png1.width, height: png1.height },
2063
+ capturedAt: new Date(),
2064
+ metadata: {
2065
+ component: "",
2066
+ variant: "",
2067
+ theme: "light" as const,
2068
+ renderTimeMs: 0,
2069
+ captureTimeMs: 0,
2070
+ },
2071
+ };
2072
+
2073
+ const screenshot2 = {
2074
+ data: finalBuffer2,
2075
+ hash: "",
2076
+ viewport: { width: png2.width, height: png2.height },
2077
+ capturedAt: new Date(),
2078
+ metadata: {
2079
+ component: "",
2080
+ variant: "",
2081
+ theme: "light" as const,
2082
+ renderTimeMs: 0,
2083
+ captureTimeMs: 0,
2084
+ },
2085
+ };
2086
+
2087
+ const diffEngine = new DiffEngine(threshold);
2088
+ const result = diffEngine.compare(screenshot1, screenshot2, { threshold });
2089
+
2090
+ return {
2091
+ matches: result.matches,
2092
+ diffPercentage: result.diffPercentage,
2093
+ diffImage: result.diffImage
2094
+ ? bufferToBase64Url(result.diffImage)
2095
+ : undefined,
2096
+ changedRegions: result.changedRegions,
2097
+ };
2098
+ }
2099
+
2100
+ /**
2101
+ * Resize a PNG by padding with transparent pixels to match target dimensions.
2102
+ */
2103
+ async function resizePng(
2104
+ buffer: Buffer,
2105
+ srcWidth: number,
2106
+ srcHeight: number,
2107
+ targetWidth: number,
2108
+ targetHeight: number
2109
+ ): Promise<Buffer> {
2110
+ const { PNG } = await import("pngjs");
2111
+
2112
+ const srcPng = PNG.sync.read(buffer);
2113
+ const dstPng = new PNG({
2114
+ width: targetWidth,
2115
+ height: targetHeight,
2116
+ fill: true,
2117
+ });
2118
+
2119
+ // Fill with transparent white
2120
+ for (let y = 0; y < targetHeight; y++) {
2121
+ for (let x = 0; x < targetWidth; x++) {
2122
+ const idx = (y * targetWidth + x) * 4;
2123
+ dstPng.data[idx] = 255; // R
2124
+ dstPng.data[idx + 1] = 255; // G
2125
+ dstPng.data[idx + 2] = 255; // B
2126
+ dstPng.data[idx + 3] = 255; // A (opaque white background)
2127
+ }
2128
+ }
2129
+
2130
+ // Copy source image data
2131
+ for (let y = 0; y < srcHeight; y++) {
2132
+ for (let x = 0; x < srcWidth; x++) {
2133
+ const srcIdx = (y * srcWidth + x) * 4;
2134
+ const dstIdx = (y * targetWidth + x) * 4;
2135
+ dstPng.data[dstIdx] = srcPng.data[srcIdx];
2136
+ dstPng.data[dstIdx + 1] = srcPng.data[srcIdx + 1];
2137
+ dstPng.data[dstIdx + 2] = srcPng.data[srcIdx + 2];
2138
+ dstPng.data[dstIdx + 3] = srcPng.data[srcIdx + 3];
2139
+ }
2140
+ }
2141
+
2142
+ return PNG.sync.write(dstPng);
2143
+ }