inkbridge 0.1.0-beta.2 → 0.1.0-beta.21

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 (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -0,0 +1,315 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import {
4
+ cvaInstanceHasOverridingJsxChildren,
5
+ cvaInstanceHasOverridingState,
6
+ getFirstElementJsxChildTagName,
7
+ MASTER_ICON_NAME_KEY,
8
+ tryCreateCvaComponentInstance,
9
+ } from '../src/components/component-instance';
10
+
11
+ // Regression: the smart fall-back in tryCreateCvaComponentInstance.
12
+ //
13
+ // Background: a CVA component used with JSX-element children (an icon)
14
+ // can't be cleanly represented as a symbol instance, because Figma's
15
+ // instance-property API can't per-instance-swap SVG vectors. So when an
16
+ // instance's icon DIFFERS from the icon recorded on the master, we must
17
+ // fall back to frame rendering. When they MATCH, the symbol instance
18
+ // renders the same icon as the master would, so it's safe to use.
19
+ //
20
+ // History: an initial fix returned null for ANY CVA instance with element
21
+ // children. That correctly preserved per-instance icons via frame
22
+ // rendering but cost the symbol benefit for every Toggle/Toggle-group
23
+ // instance — even cases like `Toggle.Sizes` where every item used the
24
+ // same icon as the master. The smart fall-back keeps the symbol path
25
+ // when icons match, falls back when they differ.
26
+ //
27
+ // This file tests:
28
+ // 1. The pure helpers (predicate + tagName extractor).
29
+ // 2. The end-to-end function with a minimal stubbed backend, asserting
30
+ // that match vs. differ produce instance vs. null per the contract.
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helper-level cases
34
+ // ---------------------------------------------------------------------------
35
+
36
+ // cvaInstanceHasOverridingJsxChildren
37
+ {
38
+ assert.equal(cvaInstanceHasOverridingJsxChildren(null), false, 'null props');
39
+ assert.equal(cvaInstanceHasOverridingJsxChildren({}), false, 'empty props');
40
+ assert.equal(
41
+ cvaInstanceHasOverridingJsxChildren({ __jsxChildren: [{ type: 'text', content: 'x' }] }),
42
+ false,
43
+ 'text-only children do not override',
44
+ );
45
+ assert.equal(
46
+ cvaInstanceHasOverridingJsxChildren({
47
+ __jsxChildren: [{ type: 'element', tagName: 'Bold' }],
48
+ }),
49
+ true,
50
+ 'a single element child overrides',
51
+ );
52
+ }
53
+
54
+ // getFirstElementJsxChildTagName
55
+ {
56
+ assert.equal(getFirstElementJsxChildTagName(null), null, 'null returns null');
57
+ assert.equal(getFirstElementJsxChildTagName('not-an-array'), null, 'non-array returns null');
58
+ assert.equal(getFirstElementJsxChildTagName([]), null, 'empty array returns null');
59
+ assert.equal(
60
+ getFirstElementJsxChildTagName([{ type: 'text', content: 'Hello' }]),
61
+ null,
62
+ 'text-only returns null (the predicate-side guard)',
63
+ );
64
+ assert.equal(
65
+ getFirstElementJsxChildTagName([{ type: 'element', tagName: 'Italic' }]),
66
+ 'Italic',
67
+ 'extracts first element tagName',
68
+ );
69
+ assert.equal(
70
+ getFirstElementJsxChildTagName([
71
+ { type: 'text', content: ' ' },
72
+ { type: 'element', tagName: 'Bold' },
73
+ { type: 'element', tagName: 'Italic' },
74
+ ]),
75
+ 'Bold',
76
+ 'extracts FIRST element tagName, skipping leading text',
77
+ );
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // End-to-end cases — minimal stubbed backend + figmaInstance.
82
+ // These are the cases the kind/type bug slipped through earlier: a unit
83
+ // test of the helper passes while the full function silently mis-routes.
84
+ // ---------------------------------------------------------------------------
85
+
86
+ interface StubBackend {
87
+ enableSymbolMasters: boolean;
88
+ splitClassName: (s: string | undefined) => string[];
89
+ ensureCvaComponentSet: () => unknown;
90
+ getInstantiableComponent: () => StubComponent | null;
91
+ getCvaSelectionFromInstance: () => Record<string, string>;
92
+ toFigmaVariantPropertyName: (k: string) => string;
93
+ toFigmaVariantPropertyValue: (v: string) => string;
94
+ getInstanceTextOverride: () => string | null;
95
+ applyTextOverrideToInstance: () => boolean;
96
+ setGeneratedSymbolDebugData: () => void;
97
+ }
98
+
99
+ interface StubComponent {
100
+ type: 'COMPONENT';
101
+ iconNamePluginData: string;
102
+ getPluginData(key: string): string;
103
+ createInstance(): StubInstance;
104
+ }
105
+
106
+ interface StubInstance {
107
+ type: 'INSTANCE';
108
+ setProperties(_p: Record<string, string>): void;
109
+ fills: unknown[];
110
+ }
111
+
112
+ function makeStubComponent(iconName: string): StubComponent {
113
+ return {
114
+ type: 'COMPONENT',
115
+ iconNamePluginData: iconName,
116
+ getPluginData(key: string): string {
117
+ if (key === MASTER_ICON_NAME_KEY) return this.iconNamePluginData;
118
+ return '';
119
+ },
120
+ createInstance(): StubInstance {
121
+ return {
122
+ type: 'INSTANCE',
123
+ setProperties() { /* noop */ },
124
+ fills: [],
125
+ };
126
+ },
127
+ };
128
+ }
129
+
130
+ function makeBackend(master: StubComponent | null): StubBackend {
131
+ return {
132
+ enableSymbolMasters: true,
133
+ splitClassName: (s) => (s ? s.split(/\s+/).filter(Boolean) : []),
134
+ ensureCvaComponentSet: () => ({}),
135
+ getInstantiableComponent: () => master,
136
+ getCvaSelectionFromInstance: () => ({}),
137
+ toFigmaVariantPropertyName: (k) => k,
138
+ toFigmaVariantPropertyValue: (v) => v,
139
+ getInstanceTextOverride: () => null,
140
+ applyTextOverrideToInstance: () => false,
141
+ setGeneratedSymbolDebugData: () => { /* noop */ },
142
+ };
143
+ }
144
+
145
+ const cvaDef = {
146
+ type: 'cva',
147
+ name: 'Toggle',
148
+ variants: { variant: ['default', 'outline'], size: ['default'] },
149
+ defaultVariants: { variant: 'default', size: 'default' },
150
+ } as const;
151
+
152
+ // (a) Instance icon matches master icon → tryCreate succeeds (returns InstanceNode).
153
+ {
154
+ const master = makeStubComponent('Bold');
155
+ const result = tryCreateCvaComponentInstance(
156
+ cvaDef as unknown as Parameters<typeof tryCreateCvaComponentInstance>[0],
157
+ {
158
+ componentName: 'Toggle',
159
+ props: {
160
+ __jsxChildren: [{ type: 'element', tagName: 'Bold' }],
161
+ },
162
+ },
163
+ 'default',
164
+ {} as Parameters<typeof tryCreateCvaComponentInstance>[3],
165
+ makeBackend(master) as unknown as Parameters<typeof tryCreateCvaComponentInstance>[4],
166
+ );
167
+ assert.ok(result, 'matching icon must produce a symbol instance, got null');
168
+ }
169
+
170
+ // (b) Instance icon differs from master icon → tryCreate returns null (fall back).
171
+ {
172
+ const master = makeStubComponent('Bold');
173
+ const result = tryCreateCvaComponentInstance(
174
+ cvaDef as unknown as Parameters<typeof tryCreateCvaComponentInstance>[0],
175
+ {
176
+ componentName: 'Toggle',
177
+ props: {
178
+ __jsxChildren: [{ type: 'element', tagName: 'Italic' }],
179
+ },
180
+ },
181
+ 'default',
182
+ {} as Parameters<typeof tryCreateCvaComponentInstance>[3],
183
+ makeBackend(master) as unknown as Parameters<typeof tryCreateCvaComponentInstance>[4],
184
+ );
185
+ assert.equal(result, null, 'differing icon must trigger fall back (null), got an instance');
186
+ }
187
+
188
+ // (c) No element children (text only) → fall-back does NOT trigger; the
189
+ // symbol-instance path proceeds even if master has a recorded icon
190
+ // (text-override path handles the content).
191
+ {
192
+ const master = makeStubComponent('Bold');
193
+ const result = tryCreateCvaComponentInstance(
194
+ cvaDef as unknown as Parameters<typeof tryCreateCvaComponentInstance>[0],
195
+ {
196
+ componentName: 'Toggle',
197
+ props: {
198
+ __jsxChildren: [{ type: 'text', content: 'Click me' }],
199
+ },
200
+ },
201
+ 'default',
202
+ {} as Parameters<typeof tryCreateCvaComponentInstance>[3],
203
+ makeBackend(master) as unknown as Parameters<typeof tryCreateCvaComponentInstance>[4],
204
+ );
205
+ assert.ok(result, 'text-only children must NOT trigger fall back, got null');
206
+ }
207
+
208
+ // (d) Element child present but master has no recorded icon name (older
209
+ // build, or no story-content for this variant) → fall back. Avoids
210
+ // silently picking the wrong icon at instance time.
211
+ {
212
+ const master = makeStubComponent('');
213
+ const result = tryCreateCvaComponentInstance(
214
+ cvaDef as unknown as Parameters<typeof tryCreateCvaComponentInstance>[0],
215
+ {
216
+ componentName: 'Toggle',
217
+ props: {
218
+ __jsxChildren: [{ type: 'element', tagName: 'Bold' }],
219
+ },
220
+ },
221
+ 'default',
222
+ {} as Parameters<typeof tryCreateCvaComponentInstance>[3],
223
+ makeBackend(master) as unknown as Parameters<typeof tryCreateCvaComponentInstance>[4],
224
+ );
225
+ assert.equal(result, null, 'unknown master icon must fall back, got an instance');
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // State-overriding props (defaultPressed, defaultChecked, disabled, ...)
230
+ // MUST also trigger fall-back, even when the icon matches. The master was
231
+ // built from one story whose state is baked in — Figma instances can't
232
+ // per-instance-activate the data-[X]:* CSS at render time.
233
+ // ---------------------------------------------------------------------------
234
+
235
+ // cvaInstanceHasOverridingState helper checks
236
+ {
237
+ assert.equal(cvaInstanceHasOverridingState(null), false, 'null props');
238
+ assert.equal(cvaInstanceHasOverridingState({}), false, 'empty props');
239
+ assert.equal(
240
+ cvaInstanceHasOverridingState({ defaultPressed: 'true' }),
241
+ true,
242
+ 'defaultPressed="true" triggers',
243
+ );
244
+ assert.equal(
245
+ cvaInstanceHasOverridingState({ defaultPressed: 'false' }),
246
+ false,
247
+ 'defaultPressed="false" does NOT trigger — instance not in pressed state',
248
+ );
249
+ assert.equal(
250
+ cvaInstanceHasOverridingState({ defaultPressed: true }),
251
+ true,
252
+ 'boolean defaultPressed=true triggers',
253
+ );
254
+ assert.equal(
255
+ cvaInstanceHasOverridingState({ defaultPressed: '' }),
256
+ true,
257
+ 'bare attribute (defaultPressed without value) = empty string = truthy',
258
+ );
259
+ assert.equal(
260
+ cvaInstanceHasOverridingState({ disabled: 'true' }),
261
+ true,
262
+ 'disabled triggers (visual state baked into master would differ)',
263
+ );
264
+ assert.equal(
265
+ cvaInstanceHasOverridingState({ 'aria-disabled': 'true' }),
266
+ false,
267
+ 'aria-disabled alone is metadata — does NOT trigger fall-back',
268
+ );
269
+ assert.equal(
270
+ cvaInstanceHasOverridingState({ defaultChecked: 'true' }),
271
+ true,
272
+ 'defaultChecked triggers (checkboxes / radios)',
273
+ );
274
+ }
275
+
276
+ // End-to-end: matching icon BUT defaultPressed=true → fall back so the
277
+ // variant engine can activate data-[pressed]:* per-instance at render time.
278
+ {
279
+ const master = makeStubComponent('Bold');
280
+ const result = tryCreateCvaComponentInstance(
281
+ cvaDef as unknown as Parameters<typeof tryCreateCvaComponentInstance>[0],
282
+ {
283
+ componentName: 'Toggle',
284
+ props: {
285
+ __jsxChildren: [{ type: 'element', tagName: 'Bold' }],
286
+ defaultPressed: 'true',
287
+ },
288
+ },
289
+ 'default',
290
+ {} as Parameters<typeof tryCreateCvaComponentInstance>[3],
291
+ makeBackend(master) as unknown as Parameters<typeof tryCreateCvaComponentInstance>[4],
292
+ );
293
+ assert.equal(result, null, 'icon matches BUT defaultPressed=true must still fall back, got an instance');
294
+ }
295
+
296
+ // And the negative control: icon matches AND no state props → still uses
297
+ // the symbol instance (pure happy path).
298
+ {
299
+ const master = makeStubComponent('Bold');
300
+ const result = tryCreateCvaComponentInstance(
301
+ cvaDef as unknown as Parameters<typeof tryCreateCvaComponentInstance>[0],
302
+ {
303
+ componentName: 'Toggle',
304
+ props: {
305
+ __jsxChildren: [{ type: 'element', tagName: 'Bold' }],
306
+ },
307
+ },
308
+ 'default',
309
+ {} as Parameters<typeof tryCreateCvaComponentInstance>[3],
310
+ makeBackend(master) as unknown as Parameters<typeof tryCreateCvaComponentInstance>[4],
311
+ );
312
+ assert.ok(result, 'icon matches and no state props → symbol instance (regression on (a))');
313
+ }
314
+
315
+ console.log('cva-master-icon-regression: PASS (4 helper + 4 e2e + 9 state-helper + 2 state-e2e cases)');
@@ -0,0 +1,129 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { expandActiveConditionalVariants } from '../src/design-system/node-variants';
4
+ import type { NodeIR } from '../src/tailwind';
5
+
6
+ // Regression: Tailwind v4 `data-[X]:` conditional variants must resolve
7
+ // when the consumer expresses the state via the JSX prop convention
8
+ // (e.g. `defaultPressed`, `defaultChecked`, `disabled`) rather than a
9
+ // literal `data-X` attribute. Without this, `data-[pressed]:bg-accent`
10
+ // on a `<Toggle defaultPressed>` never activates — the variant engine
11
+ // only sees `defaultPressed="true"`, never an actual `data-pressed`.
12
+ //
13
+ // History: pressed state on the Toggle.Pressed story rendered as default
14
+ // (no `bg-accent`) because the engine only matched literal `data-*`. The
15
+ // fix adds a DATA_ATTR_PROP_ALIASES table inside `getNodePropValue` so a
16
+ // `data-*` lookup falls back to known prop-convention aliases.
17
+
18
+ function makeComponentNode(props: Record<string, string>, classes: string[]): NodeIR {
19
+ return {
20
+ kind: 'component',
21
+ tagName: 'TogglePrimitive',
22
+ tagLower: 'toggleprimitive',
23
+ props,
24
+ classes: classes.slice(),
25
+ children: [],
26
+ };
27
+ }
28
+
29
+ interface Case {
30
+ name: string;
31
+ props: Record<string, string>;
32
+ classes: string[];
33
+ expectedAdded: string[];
34
+ }
35
+
36
+ const CASES: Case[] = [
37
+ // ---- data-pressed (Toggle/Toggle-group pressed state) -------------------
38
+ {
39
+ name: 'defaultPressed=true activates data-[pressed]:* classes',
40
+ props: { defaultPressed: 'true' },
41
+ classes: ['data-[pressed]:bg-accent', 'data-[pressed]:text-accent-foreground'],
42
+ expectedAdded: ['bg-accent', 'text-accent-foreground'],
43
+ },
44
+ {
45
+ name: 'pressed=true (alternate prop name) activates data-[pressed]:*',
46
+ props: { pressed: 'true' },
47
+ classes: ['data-[pressed]:bg-accent'],
48
+ expectedAdded: ['bg-accent'],
49
+ },
50
+ {
51
+ name: 'defaultPressed=false does NOT activate data-[pressed]:*',
52
+ props: { defaultPressed: 'false' },
53
+ classes: ['data-[pressed]:bg-accent'],
54
+ expectedAdded: [],
55
+ },
56
+ {
57
+ name: 'literal data-pressed attribute still works (and takes precedence)',
58
+ props: { 'data-pressed': 'true' },
59
+ classes: ['data-[pressed]:bg-accent'],
60
+ expectedAdded: ['bg-accent'],
61
+ },
62
+
63
+ // ---- data-disabled ------------------------------------------------------
64
+ {
65
+ name: 'disabled=true activates data-[disabled]:* classes',
66
+ props: { disabled: 'true' },
67
+ classes: ['data-[disabled]:opacity-50', 'data-[disabled]:pointer-events-none'],
68
+ expectedAdded: ['opacity-50', 'pointer-events-none'],
69
+ },
70
+ {
71
+ name: 'aria-disabled=true also activates data-[disabled]:*',
72
+ props: { 'aria-disabled': 'true' },
73
+ classes: ['data-[disabled]:opacity-50'],
74
+ expectedAdded: ['opacity-50'],
75
+ },
76
+
77
+ // ---- data-checked -------------------------------------------------------
78
+ {
79
+ name: 'defaultChecked=true activates data-[checked]:* classes',
80
+ props: { defaultChecked: 'true' },
81
+ classes: ['data-[checked]:bg-primary'],
82
+ expectedAdded: ['bg-primary'],
83
+ },
84
+
85
+ // ---- data-open ----------------------------------------------------------
86
+ {
87
+ name: 'defaultOpen=true activates data-[open]:* classes',
88
+ props: { defaultOpen: 'true' },
89
+ classes: ['data-[open]:rotate-180'],
90
+ expectedAdded: ['rotate-180'],
91
+ },
92
+
93
+ // ---- negative control --------------------------------------------------
94
+ {
95
+ name: 'unknown prop alias does NOT leak (e.g. defaultPressed does not activate data-[foo])',
96
+ props: { defaultPressed: 'true' },
97
+ classes: ['data-[foo]:bg-red-500'],
98
+ expectedAdded: [],
99
+ },
100
+
101
+ // ---- bare Tailwind v4 syntax (data-pressed: without brackets) ----------
102
+ {
103
+ name: 'bare `data-pressed:` selector also resolves via the alias',
104
+ props: { defaultPressed: 'true' },
105
+ classes: ['data-pressed:font-bold'],
106
+ expectedAdded: ['font-bold'],
107
+ },
108
+ ];
109
+
110
+ let failed = 0;
111
+ for (const c of CASES) {
112
+ const node = makeComponentNode(c.props, c.classes);
113
+ const result = expandActiveConditionalVariants(c.classes, node);
114
+ const addedUtilities = result.filter((cls) => !c.classes.includes(cls));
115
+ const sortedActual = addedUtilities.slice().sort();
116
+ const sortedExpected = c.expectedAdded.slice().sort();
117
+ if (JSON.stringify(sortedActual) !== JSON.stringify(sortedExpected)) {
118
+ console.error(` ✗ ${c.name}`);
119
+ console.error(` expected added: ${JSON.stringify(sortedExpected)}`);
120
+ console.error(` actual added: ${JSON.stringify(sortedActual)}`);
121
+ failed++;
122
+ }
123
+ }
124
+
125
+ if (failed > 0) {
126
+ assert.fail(`${failed}/${CASES.length} data-attr-prop-alias cases failed`);
127
+ }
128
+
129
+ console.log(`data-attr-prop-alias-regression: PASS (${CASES.length} cases)`);
@@ -0,0 +1,102 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { getNodeLayoutComputed } from '../src/layout/width-solver';
4
+ import type { NodeIR } from '../src/tailwind';
5
+
6
+ /**
7
+ * Regression: at depth=0 (story root) `buildFigmaNode` overrides the frame
8
+ * width with `context.maxWidth` (typically 900 — the page canvas) so that
9
+ * page-level containers fill the canvas. That override must NOT fire when
10
+ * the root element has an explicit width class (size-N / w-N / size-full
11
+ * etc.), or a 40×40 avatar becomes a 900-wide pill.
12
+ *
13
+ * The override is gated on `layoutComputed.hasExplicitSize`. This file
14
+ * locks the predicate: every variant of "explicit size" that downstream
15
+ * authors reasonably reach for must register as `hasExplicitSize=true`,
16
+ * and nothing else must.
17
+ *
18
+ * History: shadcn's `Avatar` is `<AvatarPrimitive.Root className="relative
19
+ * flex size-10 ...">`. `flattenComponentNodes` rewrites compound primitives
20
+ * to `kind: 'element', tagLower: 'div'` so they fall into the generic
21
+ * container path. There, the `depth === 0` width override kicked in and
22
+ * resized the 40×40 root to 900×40. The fix: skip the override when the
23
+ * element class list carries an explicit width. This regression keeps that
24
+ * decision intact.
25
+ */
26
+
27
+ function el(classes: string[]): NodeIR {
28
+ return {
29
+ kind: 'element',
30
+ tagName: 'div',
31
+ tagLower: 'div',
32
+ props: {},
33
+ classes,
34
+ children: [],
35
+ } as unknown as NodeIR;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Positive cases — these MUST register as hasExplicitSize.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const positives: Array<[string, string[]]> = [
43
+ ['size-10 (shadcn Avatar)', ['relative', 'flex', 'size-10', 'shrink-0']],
44
+ ['size-8 (small avatar)', ['size-8']],
45
+ ['size-12.5 (decimal allowed)', ['size-12.5']],
46
+ ['size-[40px] (arbitrary)', ['size-[40px]']],
47
+ ['size-full (avatar Image/Fallback)', ['size-full']],
48
+ ['w-10 (width only)', ['w-10']],
49
+ ['h-10 (height only)', ['h-10']],
50
+ ['w-[200px] (arbitrary width)', ['w-[200px]']],
51
+ ['h-[64px] (arbitrary height)', ['h-[64px]']],
52
+ ['w-full', ['w-full']],
53
+ ['h-full', ['h-full']],
54
+ ['w-1/2 (fractional)', ['w-1/2']],
55
+ ];
56
+
57
+ for (const [label, classes] of positives) {
58
+ const computed = getNodeLayoutComputed(el(classes));
59
+ assert.equal(
60
+ computed.hasExplicitSize,
61
+ true,
62
+ 'positive case must register hasExplicitSize=true: ' + label,
63
+ );
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Negative cases — must NOT register. A page-level container with only
68
+ // layout / padding / colors needs the depth=0 canvas-width override to
69
+ // fire, otherwise full-page stories would collapse to hug width.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const negatives: Array<[string, string[]]> = [
73
+ ['empty class list', []],
74
+ ['layout only', ['flex', 'flex-col', 'gap-4']],
75
+ ['padding only', ['p-6', 'pt-8']],
76
+ ['bg + text only', ['bg-card', 'text-foreground']],
77
+ ['max-w-* alone (a cap, not a fixed width)', ['max-w-md']],
78
+ ['min-w-* alone', ['min-w-0']],
79
+ // size-* token alias like `size-full` is positive, but `size-fit` etc.
80
+ // are not handled by the plugin's sizing parser today — keep them OUT
81
+ // of the predicate until they are wired through so we don't silently
82
+ // suppress the canvas-width override for shapes the renderer can't size.
83
+ ['size-fit (not supported as fixed size)', ['size-fit']],
84
+ ['size-auto', ['size-auto']],
85
+ ];
86
+
87
+ for (const [label, classes] of negatives) {
88
+ const computed = getNodeLayoutComputed(el(classes));
89
+ assert.equal(
90
+ computed.hasExplicitSize,
91
+ false,
92
+ 'negative case must register hasExplicitSize=false: ' + label,
93
+ );
94
+ }
95
+
96
+ console.log(
97
+ 'explicit-size-root-regression: PASS ('
98
+ + positives.length
99
+ + ' positive + '
100
+ + negatives.length
101
+ + ' negative cases)',
102
+ );
@@ -0,0 +1,113 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { extractFontName } from '../src/tokens/tokens';
4
+
5
+ /**
6
+ * Regression: `extractFontName` in `src/tokens/tokens.ts` walks a
7
+ * comma-separated font stack and returns the first entry that ISN'T a
8
+ * system keyword / generic family / web-safe fallback / emoji-fallback
9
+ * font. The returned name is what's passed to `figma.loadFontAsync` —
10
+ * a wrong pick (e.g. "Apple Color Emoji" from Tailwind's default sans
11
+ * stack, or "ui-sans-serif" from a stack with no quoted font) throws
12
+ * "couldn't load font" inside the Figma sandbox.
13
+ *
14
+ * Bug that motivated this fixture: Tailwind's default sans-serif
15
+ * value is `ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
16
+ * 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`. The original
17
+ * keyword set didn't include the emoji-fallback entries, so the resolver
18
+ * skipped past the three generic-family entries and picked "Apple Color
19
+ * Emoji" as the body font. The fix adds the four emoji fallbacks to
20
+ * SYSTEM_FONT_KEYWORDS.
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Quoted intended font in a Tailwind-style stack
25
+ // ---------------------------------------------------------------------------
26
+
27
+ assert.equal(
28
+ extractFontName('"Open Sans", ui-sans-serif, system-ui, sans-serif'),
29
+ 'Open Sans',
30
+ 'quoted family wins over generic-family fallbacks',
31
+ );
32
+
33
+ assert.equal(
34
+ extractFontName("'Inter', system-ui, sans-serif"),
35
+ 'Inter',
36
+ 'single-quoted name is unwrapped',
37
+ );
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tailwind default sans stack — the canonical "emoji bug" case
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const tailwindDefaultSans =
44
+ "ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'";
45
+
46
+ assert.equal(
47
+ extractFontName(tailwindDefaultSans),
48
+ null,
49
+ 'stack of only generic-families + emoji-fallbacks returns null so the caller falls back',
50
+ );
51
+
52
+ // Variants of the same emoji-fallback names with/without quotes
53
+ assert.equal(extractFontName('Apple Color Emoji'), null);
54
+ assert.equal(extractFontName('"Apple Color Emoji"'), null);
55
+ assert.equal(extractFontName("'Segoe UI Emoji'"), null);
56
+ assert.equal(extractFontName('Noto Color Emoji'), null);
57
+ assert.equal(extractFontName('Segoe UI Symbol'), null);
58
+
59
+ // Case-insensitive: extractFontName lowercases before comparing
60
+ assert.equal(extractFontName('APPLE COLOR EMOJI'), null);
61
+
62
+ // Real-world: intended font wins even with the emoji fallbacks appended
63
+ assert.equal(
64
+ extractFontName(`"Open Sans", ${tailwindDefaultSans}`),
65
+ 'Open Sans',
66
+ 'quoted intended font wins over the full Tailwind default tail',
67
+ );
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Generic-family / system keywords are skipped
71
+ // ---------------------------------------------------------------------------
72
+
73
+ assert.equal(extractFontName('ui-sans-serif'), null);
74
+ assert.equal(extractFontName('system-ui'), null);
75
+ assert.equal(extractFontName('sans-serif'), null);
76
+ assert.equal(extractFontName('-apple-system'), null);
77
+ assert.equal(
78
+ extractFontName('Arial, Helvetica, sans-serif'),
79
+ null,
80
+ 'web-safe-only stacks return null — the consumer hadn\'t picked a design font',
81
+ );
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // CSS variable references — return the derived name if no intended font
85
+ // is present, but a real font name still wins
86
+ // ---------------------------------------------------------------------------
87
+
88
+ assert.equal(
89
+ extractFontName('var(--font-open-sans), ui-sans-serif, sans-serif'),
90
+ 'Open Sans',
91
+ 'var(--font-foo) derives a font name when nothing else matches',
92
+ );
93
+
94
+ assert.equal(
95
+ extractFontName('var(--font-geist-mono), monospace'),
96
+ 'Geist Mono',
97
+ );
98
+
99
+ assert.equal(
100
+ extractFontName('"Inter", var(--font-fallback), sans-serif'),
101
+ 'Inter',
102
+ 'real quoted font beats var() fallback',
103
+ );
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Edge cases
107
+ // ---------------------------------------------------------------------------
108
+
109
+ assert.equal(extractFontName(''), null);
110
+ assert.equal(extractFontName(undefined), null);
111
+ assert.equal(extractFontName(null), null);
112
+
113
+ console.log('font-family-extract-regression: ok');
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { inferFontWeight, rankStylesForRequest } from '../src/font-style-resolver';
2
+ import { inferFontWeight, rankStylesForRequest } from '../src/text/font-style-resolver';
3
3
 
4
4
  assert.equal(inferFontWeight('Regular'), 400);
5
5
  assert.equal(inferFontWeight('Medium'), 500);