@usefragments/core 1.0.0

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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/dist/chunk-243QYRUF.js +2145 -0
  3. package/dist/chunk-243QYRUF.js.map +1 -0
  4. package/dist/chunk-3LLRNCPX.js +111 -0
  5. package/dist/chunk-3LLRNCPX.js.map +1 -0
  6. package/dist/chunk-EIYNNS77.js +48 -0
  7. package/dist/chunk-EIYNNS77.js.map +1 -0
  8. package/dist/chunk-JNBFJ34I.js +326 -0
  9. package/dist/chunk-JNBFJ34I.js.map +1 -0
  10. package/dist/chunk-R3X7UBHU.js +228 -0
  11. package/dist/chunk-R3X7UBHU.js.map +1 -0
  12. package/dist/chunk-SH4KPIYH.js +393 -0
  13. package/dist/chunk-SH4KPIYH.js.map +1 -0
  14. package/dist/chunk-V4VQB57N.js +56 -0
  15. package/dist/chunk-V4VQB57N.js.map +1 -0
  16. package/dist/chunk-X34IA4LR.js +319 -0
  17. package/dist/chunk-X34IA4LR.js.map +1 -0
  18. package/dist/chunk-YF65VYRY.js +493 -0
  19. package/dist/chunk-YF65VYRY.js.map +1 -0
  20. package/dist/codes/index.d.ts +40 -0
  21. package/dist/codes/index.js +23 -0
  22. package/dist/codes/index.js.map +1 -0
  23. package/dist/compiled-types/index.d.ts +5 -0
  24. package/dist/compiled-types/index.js +1 -0
  25. package/dist/compiled-types/index.js.map +1 -0
  26. package/dist/generate/index.d.ts +47 -0
  27. package/dist/generate/index.js +11 -0
  28. package/dist/generate/index.js.map +1 -0
  29. package/dist/governance-BOa3KyiJ.d.ts +3279 -0
  30. package/dist/graph/index.d.ts +66 -0
  31. package/dist/graph/index.js +17 -0
  32. package/dist/graph/index.js.map +1 -0
  33. package/dist/index.d.ts +11388 -0
  34. package/dist/index.js +8711 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/package-identity.d.ts +42 -0
  37. package/dist/package-identity.js +23 -0
  38. package/dist/package-identity.js.map +1 -0
  39. package/dist/preview/index.d.ts +484 -0
  40. package/dist/preview/index.js +1129 -0
  41. package/dist/preview/index.js.map +1 -0
  42. package/dist/react-types.d.ts +22 -0
  43. package/dist/react-types.js +1 -0
  44. package/dist/react-types.js.map +1 -0
  45. package/dist/registry.d.ts +2367 -0
  46. package/dist/registry.js +62 -0
  47. package/dist/registry.js.map +1 -0
  48. package/dist/schemas/index.d.ts +2584 -0
  49. package/dist/schemas/index.js +55 -0
  50. package/dist/schemas/index.js.map +1 -0
  51. package/dist/severity.d.ts +18 -0
  52. package/dist/severity.js +27 -0
  53. package/dist/severity.js.map +1 -0
  54. package/dist/test-utils.d.ts +14 -0
  55. package/dist/test-utils.js +63 -0
  56. package/dist/test-utils.js.map +1 -0
  57. package/dist/topology/index.d.ts +48 -0
  58. package/dist/topology/index.js +7 -0
  59. package/dist/topology/index.js.map +1 -0
  60. package/dist/types-xJ2xyp_G.d.ts +119 -0
  61. package/package.json +120 -0
  62. package/src/__tests__/contract-parser.test.ts +614 -0
  63. package/src/__tests__/dtcg-generators.test.ts +439 -0
  64. package/src/__tests__/dtcg-parser.test.ts +884 -0
  65. package/src/__tests__/preview-runtime.test.tsx +111 -0
  66. package/src/__tests__/schema.test.ts +31 -0
  67. package/src/__tests__/severity.test.ts +34 -0
  68. package/src/__tests__/style-comparison.test.ts +662 -0
  69. package/src/__tests__/tokens-compat.test.ts +51 -0
  70. package/src/agent-format.test.ts +105 -0
  71. package/src/agent-format.ts +141 -0
  72. package/src/bundle.ts +149 -0
  73. package/src/codes/__tests__/codes.test.ts +73 -0
  74. package/src/codes/codes.ts +463 -0
  75. package/src/codes/index.ts +7 -0
  76. package/src/codes/source-finding-bridge.ts +113 -0
  77. package/src/compiled-types/index.ts +293 -0
  78. package/src/component-contract.ts +231 -0
  79. package/src/component-discovery.ts +80 -0
  80. package/src/composition.test.ts +262 -0
  81. package/src/composition.ts +318 -0
  82. package/src/config-paths.test.ts +78 -0
  83. package/src/config-paths.ts +47 -0
  84. package/src/config.ts +18 -0
  85. package/src/conform-prove.test.ts +274 -0
  86. package/src/conform-prove.ts +326 -0
  87. package/src/conform.ts +160 -0
  88. package/src/constants.ts +114 -0
  89. package/src/context.ts +2 -0
  90. package/src/contract/fixture.ts +110 -0
  91. package/src/contract/hash.test.ts +86 -0
  92. package/src/contract/hash.ts +195 -0
  93. package/src/contract/index.ts +43 -0
  94. package/src/contract/preimage.test.ts +254 -0
  95. package/src/contract/preimage.ts +365 -0
  96. package/src/contract/stamp.test.ts +100 -0
  97. package/src/contract/stamp.ts +105 -0
  98. package/src/contract-parser.ts +141 -0
  99. package/src/defineFragment.test.ts +257 -0
  100. package/src/defineFragment.ts +293 -0
  101. package/src/dtcg-generators.ts +324 -0
  102. package/src/dtcg-parser.ts +1 -0
  103. package/src/dtcg.ts +237 -0
  104. package/src/facts/__snapshots__/facts.test.ts.snap +124 -0
  105. package/src/facts/builders.ts +835 -0
  106. package/src/facts/compile.ts +422 -0
  107. package/src/facts/fact-index.ts +394 -0
  108. package/src/facts/facts.test.ts +494 -0
  109. package/src/facts/ids.ts +125 -0
  110. package/src/facts/index.ts +119 -0
  111. package/src/facts/types.ts +614 -0
  112. package/src/figma.ts +263 -0
  113. package/src/fragment-types.ts +214 -0
  114. package/src/generate/generate.test.ts +594 -0
  115. package/src/generate/index.ts +438 -0
  116. package/src/governance-integrity.test.ts +186 -0
  117. package/src/governance-integrity.ts +282 -0
  118. package/src/governance.test.ts +300 -0
  119. package/src/governance.ts +605 -0
  120. package/src/governance.type-test.ts +75 -0
  121. package/src/graph/engine.ts +446 -0
  122. package/src/graph/graph.test.ts +746 -0
  123. package/src/graph/index.ts +32 -0
  124. package/src/graph/serialization.ts +55 -0
  125. package/src/graph/types.ts +178 -0
  126. package/src/index.ts +891 -0
  127. package/src/local-canonical.ts +102 -0
  128. package/src/mcp-snapshot.ts +222 -0
  129. package/src/package-identity-match.ts +33 -0
  130. package/src/package-identity.generated.ts +233 -0
  131. package/src/package-identity.test.ts +99 -0
  132. package/src/package-identity.ts +157 -0
  133. package/src/performance-presets.ts +142 -0
  134. package/src/preview/comparison-artifact.test.ts +228 -0
  135. package/src/preview/comparison-artifact.ts +181 -0
  136. package/src/preview/comparison-context.ts +93 -0
  137. package/src/preview/comparison-evidence.ts +112 -0
  138. package/src/preview/comparison-format.ts +21 -0
  139. package/src/preview/comparison-presentation.ts +187 -0
  140. package/src/preview/comparison-references.test.ts +430 -0
  141. package/src/preview/comparison-references.ts +72 -0
  142. package/src/preview/comparison-score.ts +59 -0
  143. package/src/preview/comparison-styles.ts +197 -0
  144. package/src/preview/comparison.test.ts +559 -0
  145. package/src/preview/comparison.ts +488 -0
  146. package/src/preview/index.ts +10 -0
  147. package/src/preview/types.ts +309 -0
  148. package/src/preview/validation.test.ts +290 -0
  149. package/src/preview/validation.ts +356 -0
  150. package/src/preview-runtime.tsx +144 -0
  151. package/src/raw-html-canonical.test.ts +41 -0
  152. package/src/raw-html-canonical.ts +179 -0
  153. package/src/react-types.ts +21 -0
  154. package/src/registry-install-plan.ts +344 -0
  155. package/src/registry.test.ts +245 -0
  156. package/src/registry.ts +369 -0
  157. package/src/rules/a11y-required-accessible-name.ts +93 -0
  158. package/src/rules/color-math.test.ts +72 -0
  159. package/src/rules/color-math.ts +192 -0
  160. package/src/rules/components-forbidden-prop-value.ts +108 -0
  161. package/src/rules/components-prefer-library.ts +943 -0
  162. package/src/rules/components-unknown-prop.ts +94 -0
  163. package/src/rules/composition-pattern-schema.ts +125 -0
  164. package/src/rules/composition-pattern.test.ts +320 -0
  165. package/src/rules/composition-pattern.ts +271 -0
  166. package/src/rules/emit-gate.test.ts +42 -0
  167. package/src/rules/emit-gate.ts +60 -0
  168. package/src/rules/finding.ts +54 -0
  169. package/src/rules/index.ts +235 -0
  170. package/src/rules/jsx-preferred-component.ts +68 -0
  171. package/src/rules/jsx-preferred-import-path.ts +67 -0
  172. package/src/rules/presets.ts +57 -0
  173. package/src/rules/props-invalid-value.ts +115 -0
  174. package/src/rules/rules.test.ts +3636 -0
  175. package/src/rules/spacing-resolution.ts +184 -0
  176. package/src/rules/styles-no-raw-color.ts +329 -0
  177. package/src/rules/styles-no-raw-dimensions.ts +357 -0
  178. package/src/rules/styles-no-raw-spacing.ts +236 -0
  179. package/src/rules/styles-no-raw-typography.ts +237 -0
  180. package/src/rules/tailwind-arbitrary-color.test.ts +128 -0
  181. package/src/rules/tailwind-arbitrary-color.ts +93 -0
  182. package/src/rules/tailwind-arbitrary-spacing.test.ts +157 -0
  183. package/src/rules/tailwind-arbitrary-spacing.ts +120 -0
  184. package/src/rules/tailwind-forbidden-palette.test.ts +116 -0
  185. package/src/rules/tailwind-forbidden-palette.ts +107 -0
  186. package/src/rules/tailwind-off-scale-spacing-token.test.ts +100 -0
  187. package/src/rules/tailwind-off-scale-spacing-token.ts +108 -0
  188. package/src/rules/tailwind-raw-color-via-token.test.ts +108 -0
  189. package/src/rules/tailwind-raw-color-via-token.ts +82 -0
  190. package/src/rules/tailwind-rule-utils.ts +106 -0
  191. package/src/rules/tailwind-unknown-class.test.ts +157 -0
  192. package/src/rules/tailwind-unknown-class.ts +121 -0
  193. package/src/rules/taxonomy.test.ts +238 -0
  194. package/src/rules/theme-no-theme-coupled-literal.ts +132 -0
  195. package/src/rules/tiers.ts +111 -0
  196. package/src/rules/tokens-css-vars-must-be-defined.test.ts +244 -0
  197. package/src/rules/tokens-css-vars-must-be-defined.ts +156 -0
  198. package/src/rules/tokens-require-dual-fallback.ts +78 -0
  199. package/src/rules/types.ts +27 -0
  200. package/src/rules/utils.test.ts +110 -0
  201. package/src/rules/utils.ts +599 -0
  202. package/src/schema.ts +427 -0
  203. package/src/schemas/index.ts +275 -0
  204. package/src/schemas/schemas.test.ts +124 -0
  205. package/src/severity.ts +54 -0
  206. package/src/storyAdapter.test.ts +571 -0
  207. package/src/storyAdapter.ts +761 -0
  208. package/src/storyFilters.test.ts +350 -0
  209. package/src/storyFilters.ts +253 -0
  210. package/src/storybook-csf.ts +11 -0
  211. package/src/style-comparison.ts +423 -0
  212. package/src/test-utils.ts +68 -0
  213. package/src/token-parser.ts +2 -0
  214. package/src/token-types.ts +287 -0
  215. package/src/tokens/categories.ts +131 -0
  216. package/src/tokens/color.ts +193 -0
  217. package/src/tokens/design-token-parser.test.ts +159 -0
  218. package/src/tokens/design-token-parser.ts +402 -0
  219. package/src/tokens/dtcg.ts +428 -0
  220. package/src/tokens/index.ts +130 -0
  221. package/src/tokens/lookup.ts +19 -0
  222. package/src/tokens/scss.ts +171 -0
  223. package/src/tokens/tailwind-v4.ts +187 -0
  224. package/src/tokens/types.ts +43 -0
  225. package/src/topology/index.ts +9 -0
  226. package/src/topology/resolve-area.test.ts +174 -0
  227. package/src/topology/resolve-area.ts +215 -0
  228. package/src/types.ts +1057 -0
@@ -0,0 +1,430 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildFigmaDesignSource,
4
+ buildPreviewComparisonReport,
5
+ getPreviewDesignReference,
6
+ getPreviewComparisonContextSummary,
7
+ parseFigmaNodeUrl,
8
+ type PreviewComponentDefinition,
9
+ } from "./index";
10
+
11
+ describe("preview design references", () => {
12
+ it("parses Figma design and file URLs", () => {
13
+ expect(parseFigmaNodeUrl("https://www.figma.com/design/file123/System?node-id=1%3A2")).toEqual({
14
+ fileKey: "file123",
15
+ nodeId: "1:2",
16
+ });
17
+ expect(parseFigmaNodeUrl("https://figma.com/file/file456/System?node-id=3-4")).toEqual({
18
+ fileKey: "file456",
19
+ nodeId: "3:4",
20
+ });
21
+ expect(parseFigmaNodeUrl("www.figma.com/design/file789?node-id=5-6&t=abc123")).toEqual({
22
+ fileKey: "file789",
23
+ nodeId: "5:6",
24
+ });
25
+
26
+ expect(parseFigmaNodeUrl("https://example.com")).toBeNull();
27
+ expect(parseFigmaNodeUrl("https://evilfigma.com/design/file123/System?node-id=1-2")).toBeNull();
28
+ expect(parseFigmaNodeUrl("https://www.figma.com/design/file123/System")).toBeNull();
29
+ });
30
+
31
+ it("resolves variant design references before component defaults", () => {
32
+ const component: PreviewComponentDefinition = {
33
+ componentKey: "Button",
34
+ publicRef: "Button",
35
+ displayName: "Button",
36
+ exportName: "Button",
37
+ importPath: "@acme/ui",
38
+ tier: "primitive",
39
+ previewability: "ready",
40
+ definitionSource: "manual",
41
+ diagnostics: [],
42
+ props: {},
43
+ design: {
44
+ provider: "figma",
45
+ label: "Button",
46
+ nodeUrl: "https://www.figma.com/design/file123/System?node-id=1%3A2",
47
+ },
48
+ variants: [
49
+ {
50
+ id: "secondary",
51
+ name: "Secondary",
52
+ source: "manual",
53
+ props: {},
54
+ design: {
55
+ provider: "figma",
56
+ label: "Button / Secondary",
57
+ nodeUrl: "https://www.figma.com/design/file123/System?node-id=4%3A5",
58
+ imageUrl: "/figma/button-secondary.png",
59
+ size: { width: 120, height: 40 },
60
+ comparison: {
61
+ state: {
62
+ variantId: "secondary",
63
+ theme: "light",
64
+ viewport: { width: 120, height: 40 },
65
+ },
66
+ previewImageUrl: "/captures/button-secondary.png",
67
+ previewSize: { width: 120, height: 40 },
68
+ designStyles: {
69
+ backgroundColor: "#ffffff",
70
+ borderRadius: "8px",
71
+ },
72
+ previewStyles: {
73
+ backgroundColor: "rgb(255, 255, 255)",
74
+ borderRadius: "8px",
75
+ },
76
+ diffImageUrl: "/diffs/button-secondary.png",
77
+ diffPercentage: 0.25,
78
+ changedRegions: [{ x: 0, y: 0, width: 4, height: 4 }],
79
+ capturedAt: "2026-05-24T00:00:00.000Z",
80
+ },
81
+ },
82
+ },
83
+ ],
84
+ };
85
+
86
+ const reference = getPreviewDesignReference(component, "secondary");
87
+
88
+ expect(reference).toEqual({
89
+ provider: "figma",
90
+ label: "Button / Secondary",
91
+ nodeUrl: "https://www.figma.com/design/file123/System?node-id=4%3A5",
92
+ fileKey: "file123",
93
+ nodeId: "4:5",
94
+ imageUrl: "/figma/button-secondary.png",
95
+ size: { width: 120, height: 40 },
96
+ comparison: {
97
+ state: {
98
+ variantId: "secondary",
99
+ theme: "light",
100
+ viewport: { width: 120, height: 40 },
101
+ },
102
+ previewImageUrl: "/captures/button-secondary.png",
103
+ previewSize: { width: 120, height: 40 },
104
+ designStyles: {
105
+ backgroundColor: "#ffffff",
106
+ borderRadius: "8px",
107
+ },
108
+ previewStyles: {
109
+ backgroundColor: "rgb(255, 255, 255)",
110
+ borderRadius: "8px",
111
+ },
112
+ diffImageUrl: "/diffs/button-secondary.png",
113
+ diffPercentage: 0.25,
114
+ changedRegions: [{ x: 0, y: 0, width: 4, height: 4 }],
115
+ capturedAt: "2026-05-24T00:00:00.000Z",
116
+ },
117
+ });
118
+ expect(buildFigmaDesignSource(reference!, "Button")).toEqual({
119
+ kind: "figma",
120
+ label: "Button / Secondary",
121
+ fileKey: "file123",
122
+ nodeId: "4:5",
123
+ nodeUrl: "https://www.figma.com/design/file123/System?node-id=4%3A5",
124
+ imageUrl: "/figma/button-secondary.png",
125
+ size: { width: 120, height: 40 },
126
+ });
127
+
128
+ const report = buildPreviewComparisonReport({
129
+ component,
130
+ variantId: "secondary",
131
+ preview: {
132
+ variantId: "secondary",
133
+ theme: "light",
134
+ viewport: { width: 120, height: 40 },
135
+ size: { width: 120, height: 40 },
136
+ },
137
+ });
138
+
139
+ expect(report).toMatchObject({
140
+ status: "pass",
141
+ design: {
142
+ label: "Button / Secondary",
143
+ imageUrl: "/figma/button-secondary.png",
144
+ },
145
+ preview: {
146
+ label: "Button / Secondary",
147
+ componentKey: "Button",
148
+ variantId: "secondary",
149
+ imageUrl: "/captures/button-secondary.png",
150
+ size: { width: 120, height: 40 },
151
+ capturedAt: "2026-05-24T00:00:00.000Z",
152
+ },
153
+ image: {
154
+ diffPercentage: 0.25,
155
+ diffImageUrl: "/diffs/button-secondary.png",
156
+ changedRegions: [{ x: 0, y: 0, width: 4, height: 4 }],
157
+ },
158
+ styles: {
159
+ match: true,
160
+ properties: [
161
+ {
162
+ property: "backgroundColor",
163
+ design: "#ffffff",
164
+ preview: "rgb(255, 255, 255)",
165
+ match: true,
166
+ },
167
+ {
168
+ property: "borderRadius",
169
+ design: "8px",
170
+ preview: "8px",
171
+ match: true,
172
+ },
173
+ ],
174
+ },
175
+ });
176
+ });
177
+
178
+ it("does not score stored capture evidence when preview state changed", () => {
179
+ const component: PreviewComponentDefinition = {
180
+ componentKey: "Card",
181
+ publicRef: "Card",
182
+ displayName: "Card",
183
+ exportName: "Card",
184
+ importPath: "@acme/ui",
185
+ tier: "primitive",
186
+ previewability: "ready",
187
+ definitionSource: "manual",
188
+ diagnostics: [],
189
+ props: {},
190
+ design: {
191
+ provider: "figma",
192
+ label: "Card / Summary",
193
+ nodeUrl: "https://www.figma.com/design/file123/System?node-id=8%3A9",
194
+ imageUrl: "/figma/card-summary.png",
195
+ size: { width: 360, height: 220 },
196
+ comparison: {
197
+ state: {
198
+ variantId: "summary",
199
+ theme: "light",
200
+ viewport: { width: 360, height: 220 },
201
+ },
202
+ previewImageUrl: "/captures/card-summary.png",
203
+ previewSize: { width: 360, height: 220 },
204
+ designStyles: { backgroundColor: "#ffffff" },
205
+ previewStyles: { backgroundColor: "rgb(255, 255, 255)" },
206
+ diffImageUrl: "/diffs/card-summary.png",
207
+ diffPercentage: 0.2,
208
+ capturedAt: "2026-05-24T00:00:00.000Z",
209
+ },
210
+ },
211
+ variants: [{ id: "summary", name: "Summary", source: "manual", props: {} }],
212
+ };
213
+
214
+ const report = buildPreviewComparisonReport({
215
+ component,
216
+ variantId: "summary",
217
+ preview: {
218
+ variantId: "summary",
219
+ theme: "dark",
220
+ viewport: { width: 360, height: 220 },
221
+ size: { width: 360, height: 220 },
222
+ },
223
+ });
224
+
225
+ expect(report?.status).toBe("review");
226
+ expect(report?.image).toBeUndefined();
227
+ expect(report?.styles).toBeUndefined();
228
+ expect(report?.preview.imageUrl).toBeUndefined();
229
+ expect(report?.findings).toMatchObject([
230
+ {
231
+ code: "stale-comparison-signal",
232
+ severity: "warning",
233
+ },
234
+ ]);
235
+ expect(report?.findings[0]?.message).toContain("theme dark");
236
+ expect(report?.context).toMatchObject({
237
+ scope: "component",
238
+ stale: true,
239
+ staleReasons: ["theme"],
240
+ captured: {
241
+ variantId: "summary",
242
+ theme: "light",
243
+ },
244
+ active: {
245
+ variantId: "summary",
246
+ theme: "dark",
247
+ },
248
+ });
249
+ expect(getPreviewComparisonContextSummary(report!)).toEqual({
250
+ status: "stale",
251
+ label: "Capture context",
252
+ value: "Stale evidence",
253
+ detail: "Captured theme light, viewport 360 x 220; current theme dark, viewport 360 x 220.",
254
+ });
255
+ });
256
+
257
+ it("does not enforce component-level captured variant ids", () => {
258
+ const component: PreviewComponentDefinition = {
259
+ componentKey: "Card",
260
+ publicRef: "Card",
261
+ displayName: "Card",
262
+ exportName: "Card",
263
+ importPath: "@acme/ui",
264
+ tier: "primitive",
265
+ previewability: "ready",
266
+ definitionSource: "manual",
267
+ diagnostics: [],
268
+ props: {},
269
+ design: {
270
+ provider: "figma",
271
+ label: "Card / Summary",
272
+ size: { width: 360, height: 220 },
273
+ comparison: {
274
+ state: {
275
+ variantId: "default",
276
+ theme: "light",
277
+ viewport: { width: 360, height: 220 },
278
+ },
279
+ previewSize: { width: 360, height: 220 },
280
+ diffPercentage: 0.2,
281
+ },
282
+ },
283
+ variants: [
284
+ { id: "default", name: "Default", source: "manual", props: {} },
285
+ { id: "summary", name: "Summary", source: "manual", props: {} },
286
+ ],
287
+ };
288
+
289
+ const report = buildPreviewComparisonReport({
290
+ component,
291
+ variantId: "summary",
292
+ preview: {
293
+ variantId: "summary",
294
+ theme: "light",
295
+ viewport: { width: 360, height: 220 },
296
+ size: { width: 360, height: 220 },
297
+ },
298
+ });
299
+
300
+ expect(report?.status).toBe("pass");
301
+ expect(report?.findings).toEqual([]);
302
+ expect(report?.image?.diffPercentage).toBe(0.2);
303
+ expect(getPreviewComparisonContextSummary(report!)).toMatchObject({
304
+ status: "current",
305
+ value: "Current evidence",
306
+ detail: "Captured theme light, viewport 360 x 220.",
307
+ });
308
+ });
309
+
310
+ it("enforces explicit variant-scoped design references", () => {
311
+ const component: PreviewComponentDefinition = {
312
+ componentKey: "Button",
313
+ publicRef: "Button",
314
+ displayName: "Button",
315
+ exportName: "Button",
316
+ importPath: "@acme/ui",
317
+ tier: "primitive",
318
+ previewability: "ready",
319
+ definitionSource: "manual",
320
+ diagnostics: [],
321
+ props: {},
322
+ variants: [
323
+ { id: "primary", name: "Primary", source: "manual", props: {} },
324
+ { id: "secondary", name: "Secondary", source: "manual", props: {} },
325
+ ],
326
+ };
327
+ const designReference = {
328
+ provider: "figma" as const,
329
+ label: "Button / Primary",
330
+ size: { width: 120, height: 40 },
331
+ comparison: {
332
+ state: {
333
+ variantId: "primary",
334
+ theme: "light",
335
+ viewport: { width: 120, height: 40 },
336
+ },
337
+ previewSize: { width: 120, height: 40 },
338
+ diffPercentage: 0.2,
339
+ },
340
+ };
341
+
342
+ const report = buildPreviewComparisonReport({
343
+ component,
344
+ variantId: "secondary",
345
+ designReference,
346
+ designReferenceScope: "variant",
347
+ preview: {
348
+ variantId: "secondary",
349
+ theme: "light",
350
+ viewport: { width: 120, height: 40 },
351
+ size: { width: 120, height: 40 },
352
+ },
353
+ });
354
+
355
+ expect(report?.status).toBe("review");
356
+ expect(report?.image).toBeUndefined();
357
+ expect(report?.findings).toMatchObject([
358
+ {
359
+ code: "stale-comparison-signal",
360
+ severity: "warning",
361
+ },
362
+ ]);
363
+ expect(report?.findings[0]?.message).toContain("variant secondary");
364
+ });
365
+
366
+ it("scores artifact capture evidence before live iframe measurements", () => {
367
+ const component: PreviewComponentDefinition = {
368
+ componentKey: "Card",
369
+ publicRef: "Card",
370
+ displayName: "Card",
371
+ exportName: "Card",
372
+ importPath: "@acme/ui",
373
+ tier: "primitive",
374
+ previewability: "ready",
375
+ definitionSource: "manual",
376
+ diagnostics: [],
377
+ props: {},
378
+ design: {
379
+ provider: "figma",
380
+ label: "Card / Summary",
381
+ nodeUrl: "https://www.figma.com/design/file123/System?node-id=8%3A9",
382
+ imageUrl: "/figma/card-summary.png",
383
+ size: { width: 360, height: 220 },
384
+ comparison: {
385
+ state: {
386
+ variantId: "summary",
387
+ theme: "light",
388
+ viewport: { width: 360, height: 220 },
389
+ },
390
+ previewImageUrl: "/captures/card-summary.png",
391
+ previewSize: { width: 360, height: 220 },
392
+ diffImageUrl: "/diffs/card-summary.png",
393
+ diffPercentage: 0.2,
394
+ diffPixelCount: 158,
395
+ totalPixels: 79200,
396
+ capturedAt: "2026-05-24T00:00:00.000Z",
397
+ },
398
+ },
399
+ variants: [{ id: "summary", name: "Summary", source: "manual", props: {} }],
400
+ };
401
+
402
+ const report = buildPreviewComparisonReport({
403
+ component,
404
+ variantId: "summary",
405
+ preview: {
406
+ variantId: "summary",
407
+ theme: "light",
408
+ viewport: { width: 360, height: 220 },
409
+ // The current iframe may measure the scaled live box differently.
410
+ // Captured evidence should remain the scored comparison source.
411
+ size: { width: 324, height: 218 },
412
+ },
413
+ });
414
+
415
+ expect(report?.status).toBe("pass");
416
+ expect(report?.score).toBe(98);
417
+ expect(report?.dimensions).toMatchObject({
418
+ match: true,
419
+ preview: { width: 360, height: 220 },
420
+ delta: { width: 0, height: 0 },
421
+ });
422
+ expect(report?.image).toMatchObject({
423
+ match: true,
424
+ diffPercentage: 0.2,
425
+ diffPixelCount: 158,
426
+ totalPixels: 79200,
427
+ diffImageUrl: "/diffs/card-summary.png",
428
+ });
429
+ });
430
+ });
@@ -0,0 +1,72 @@
1
+ import type { PreviewComponentDefinition, PreviewDesignReference } from "./types";
2
+ import type { FigmaDesignComparisonSource } from "./comparison";
3
+
4
+ export function parseFigmaNodeUrl(url: string): { fileKey: string; nodeId: string } | null {
5
+ const parsed = parseUrl(url);
6
+ if (!parsed || !isFigmaHostname(parsed.hostname)) return null;
7
+
8
+ const [, surface, fileKey] = parsed.pathname.split("/");
9
+ if ((surface !== "file" && surface !== "design") || !fileKey) return null;
10
+
11
+ const nodeId = parsed.searchParams.get("node-id");
12
+ if (!nodeId) return null;
13
+
14
+ return { fileKey, nodeId: nodeId.replace(/-/g, ":") };
15
+ }
16
+
17
+ export function getPreviewDesignReference(
18
+ component: PreviewComponentDefinition,
19
+ variantId?: string
20
+ ): PreviewDesignReference | undefined {
21
+ const variant = variantId
22
+ ? component.variants.find((candidate) => candidate.id === variantId)
23
+ : undefined;
24
+
25
+ return normalizePreviewDesignReference(variant?.design ?? component.design);
26
+ }
27
+
28
+ export function buildFigmaDesignSource(
29
+ reference: PreviewDesignReference,
30
+ fallbackLabel: string
31
+ ): FigmaDesignComparisonSource {
32
+ const normalized = normalizePreviewDesignReference(reference);
33
+
34
+ return {
35
+ kind: "figma",
36
+ label: normalized?.label ?? fallbackLabel,
37
+ fileKey: normalized?.fileKey,
38
+ nodeId: normalized?.nodeId,
39
+ nodeUrl: normalized?.nodeUrl,
40
+ imageUrl: normalized?.imageUrl,
41
+ size: normalized?.size,
42
+ };
43
+ }
44
+
45
+ export function normalizePreviewDesignReference(
46
+ reference: PreviewDesignReference | undefined
47
+ ): PreviewDesignReference | undefined {
48
+ if (!reference) return undefined;
49
+ if (reference.fileKey && reference.nodeId) return reference;
50
+ if (!reference.nodeUrl) return reference;
51
+
52
+ const parsed = parseFigmaNodeUrl(reference.nodeUrl);
53
+ if (!parsed) return reference;
54
+
55
+ return {
56
+ ...reference,
57
+ fileKey: reference.fileKey ?? parsed.fileKey,
58
+ nodeId: reference.nodeId ?? parsed.nodeId,
59
+ };
60
+ }
61
+
62
+ function isFigmaHostname(hostname: string): boolean {
63
+ return hostname === "figma.com" || hostname.endsWith(".figma.com");
64
+ }
65
+
66
+ function parseUrl(url: string): URL | null {
67
+ try {
68
+ return new URL(url.includes("://") ? url : `https://${url}`);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
@@ -0,0 +1,59 @@
1
+ import type {
2
+ PreviewComparisonFinding,
3
+ PreviewComparisonReport,
4
+ PreviewComparisonStatus,
5
+ } from "./comparison";
6
+
7
+ export function scoreComparison(parts: {
8
+ image: PreviewComparisonReport["image"] | undefined;
9
+ dimensions: PreviewComparisonReport["dimensions"] | undefined;
10
+ styles: PreviewComparisonReport["styles"] | undefined;
11
+ }): number | null {
12
+ const scores: Array<{ weight: number; score: number }> = [];
13
+
14
+ if (parts.image) {
15
+ scores.push({
16
+ weight: 0.5,
17
+ score: Math.max(0, 100 - parts.image.diffPercentage * 10),
18
+ });
19
+ }
20
+
21
+ if (parts.dimensions) {
22
+ const delta = Math.abs(parts.dimensions.delta.width) + Math.abs(parts.dimensions.delta.height);
23
+ scores.push({ weight: 0.15, score: Math.max(0, 100 - delta * 2) });
24
+ }
25
+
26
+ if (parts.styles) {
27
+ const matches = parts.styles.properties.filter((property) => property.match).length;
28
+ scores.push({ weight: 0.35, score: (matches / parts.styles.properties.length) * 100 });
29
+ }
30
+
31
+ if (scores.length === 0) return null;
32
+
33
+ const totalWeight = scores.reduce((sum, item) => sum + item.weight, 0);
34
+ const weighted = scores.reduce((sum, item) => sum + item.score * item.weight, 0);
35
+ return Math.round(weighted / totalWeight);
36
+ }
37
+
38
+ export function resolveStatus(
39
+ score: number | null,
40
+ findings: PreviewComparisonFinding[]
41
+ ): PreviewComparisonStatus {
42
+ if (score === null) return "incomplete";
43
+ if (findings.some((finding) => finding.severity === "error")) return "fail";
44
+ if (findings.some((finding) => finding.severity === "warning")) return "review";
45
+ if (score >= 90) return "pass";
46
+ if (score >= 65) return "review";
47
+ return "fail";
48
+ }
49
+
50
+ export function summarizeStatus(
51
+ status: PreviewComparisonStatus,
52
+ score: number | null,
53
+ findings: PreviewComparisonFinding[]
54
+ ): string {
55
+ if (score === null) return "Comparison needs at least one captured signal.";
56
+ if (status === "pass") return `Preview matches the Figma reference (${score}/100).`;
57
+ const issueCount = findings.length === 1 ? "1 issue" : `${findings.length} issues`;
58
+ return `Preview needs ${status === "fail" ? "changes" : "review"} (${score}/100, ${issueCount}).`;
59
+ }