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,227 @@
1
+ import assert from 'node:assert/strict';
2
+ import { makeEmptyIR } from '../src/layout/parser/ir';
3
+ import { parseSizing } from '../src/layout/parser/sizing';
4
+
5
+ /**
6
+ * Locks in the Tailwind sizing → LayoutIR mapping that previously
7
+ * lived inside LayoutParser as a private static method. Phase 3 of
8
+ * the layout-parser split moved this logic into `parser/sizing.ts` —
9
+ * this fixture catches accidental behavioural regressions.
10
+ *
11
+ * Covers: w-* / h-* (scale, arbitrary, fractional), size-*, w-full,
12
+ * h-full / h-screen / min-h-screen, max-w-N, named max-w (xs..7xl,
13
+ * prose, screen-*).
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Fixed width / height — scale values
18
+ // ---------------------------------------------------------------------------
19
+ {
20
+ const ir = makeEmptyIR();
21
+ parseSizing(['w-20'], ir);
22
+ assert.equal(ir.widthMode, 'FIXED');
23
+ assert.equal(ir.fixedWidth, 80);
24
+ assert.equal(ir.heightMode, 'HUG');
25
+ }
26
+ {
27
+ const ir = makeEmptyIR();
28
+ parseSizing(['h-10'], ir);
29
+ assert.equal(ir.heightMode, 'FIXED');
30
+ assert.equal(ir.fixedHeight, 40);
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Arbitrary values [Xpx], [Yrem], [Zem]
35
+ // ---------------------------------------------------------------------------
36
+ {
37
+ const ir = makeEmptyIR();
38
+ parseSizing(['w-[100px]', 'h-[1.5rem]'], ir);
39
+ assert.equal(ir.widthMode, 'FIXED');
40
+ assert.equal(ir.fixedWidth, 100);
41
+ assert.equal(ir.heightMode, 'FIXED');
42
+ assert.equal(ir.fixedHeight, 24); // 1.5rem * 16
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Fractional widths (FILL with widthFraction)
47
+ // ---------------------------------------------------------------------------
48
+ {
49
+ const ir = makeEmptyIR();
50
+ parseSizing(['w-1/2'], ir);
51
+ assert.equal(ir.widthMode, 'FILL');
52
+ assert.equal(ir.widthFraction, 0.5);
53
+ }
54
+ {
55
+ const ir = makeEmptyIR();
56
+ parseSizing(['w-2/3'], ir);
57
+ assert.equal(ir.widthMode, 'FILL');
58
+ assert.ok(Math.abs((ir.widthFraction ?? 0) - 2 / 3) < 1e-9);
59
+ }
60
+ {
61
+ const ir = makeEmptyIR();
62
+ parseSizing(['h-3/4'], ir);
63
+ assert.equal(ir.heightMode, 'FILL');
64
+ assert.equal(ir.heightFraction, 0.75);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // size-N → both axes FIXED at the same value
69
+ // ---------------------------------------------------------------------------
70
+ {
71
+ const ir = makeEmptyIR();
72
+ parseSizing(['size-12'], ir);
73
+ assert.equal(ir.widthMode, 'FIXED');
74
+ assert.equal(ir.heightMode, 'FIXED');
75
+ assert.equal(ir.fixedWidth, 48);
76
+ assert.equal(ir.fixedHeight, 48);
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // w-full / h-full / h-screen / min-h-screen → FILL
81
+ // ---------------------------------------------------------------------------
82
+ {
83
+ const ir = makeEmptyIR();
84
+ parseSizing(['w-full'], ir);
85
+ assert.equal(ir.widthMode, 'FILL');
86
+ assert.equal(ir.heightMode, 'HUG');
87
+ }
88
+ {
89
+ const ir = makeEmptyIR();
90
+ parseSizing(['h-full'], ir);
91
+ assert.equal(ir.heightMode, 'FILL');
92
+ }
93
+ {
94
+ const ir = makeEmptyIR();
95
+ parseSizing(['h-screen'], ir);
96
+ assert.equal(ir.heightMode, 'FILL');
97
+ }
98
+ {
99
+ const ir = makeEmptyIR();
100
+ parseSizing(['min-h-screen'], ir);
101
+ assert.equal(ir.heightMode, 'FILL');
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // max-w with scale value — collapses to fixed width
106
+ // ---------------------------------------------------------------------------
107
+ {
108
+ const ir = makeEmptyIR();
109
+ parseSizing(['max-w-96'], ir);
110
+ assert.equal(ir.widthMode, 'FIXED');
111
+ assert.equal(ir.fixedWidth, 384);
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Named max-w tokens
116
+ // ---------------------------------------------------------------------------
117
+ {
118
+ const cases: Array<[string, number]> = [
119
+ ['max-w-xs', 320],
120
+ ['max-w-sm', 384],
121
+ ['max-w-md', 448],
122
+ ['max-w-lg', 512],
123
+ ['max-w-xl', 576],
124
+ ['max-w-2xl', 672],
125
+ ['max-w-3xl', 768],
126
+ ['max-w-4xl', 896],
127
+ ['max-w-5xl', 1024],
128
+ ['max-w-6xl', 1152],
129
+ ['max-w-7xl', 1280],
130
+ ['max-w-prose', 640],
131
+ ['max-w-screen-sm', 640],
132
+ ['max-w-screen-md', 768],
133
+ ['max-w-screen-lg', 1024],
134
+ ['max-w-screen-xl', 1280],
135
+ ['max-w-screen-2xl', 1536],
136
+ ];
137
+ for (const [cls, expected] of cases) {
138
+ const ir = makeEmptyIR();
139
+ parseSizing([cls], ir);
140
+ assert.equal(ir.widthMode, 'FIXED', cls);
141
+ assert.equal(ir.fixedWidth, expected, cls);
142
+ }
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Combinations — w + h together
147
+ // ---------------------------------------------------------------------------
148
+ {
149
+ const ir = makeEmptyIR();
150
+ parseSizing(['w-full', 'max-w-7xl'], ir);
151
+ // max-w fires AFTER w-full in the loop and overrides to FIXED at 1280.
152
+ assert.equal(ir.widthMode, 'FIXED');
153
+ assert.equal(ir.fixedWidth, 1280);
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Arbitrary percent widths / heights — w-[60%], h-[40%]
158
+ // Used by the shadcn Progress adapter (indicator inside track) and by any
159
+ // consumer who writes percent widths directly in className.
160
+ // ---------------------------------------------------------------------------
161
+ {
162
+ const cases: Array<[string, number]> = [
163
+ ['w-[0%]', 0],
164
+ ['w-[33%]', 0.33],
165
+ ['w-[60%]', 0.6],
166
+ ['w-[100%]', 1],
167
+ ['w-[50.5%]', 0.505],
168
+ ];
169
+ for (const [cls, expected] of cases) {
170
+ const ir = makeEmptyIR();
171
+ parseSizing([cls], ir);
172
+ assert.equal(ir.widthMode, 'FILL', cls);
173
+ assert.ok(
174
+ Math.abs((ir.widthFraction ?? -1) - expected) < 1e-9,
175
+ `${cls} → widthFraction ${expected} (got ${ir.widthFraction})`,
176
+ );
177
+ }
178
+ }
179
+
180
+ {
181
+ const ir = makeEmptyIR();
182
+ parseSizing(['h-[40%]'], ir);
183
+ assert.equal(ir.heightMode, 'FILL', 'h-[40%] → heightMode=FILL');
184
+ assert.ok(
185
+ Math.abs((ir.heightFraction ?? -1) - 0.4) < 1e-9,
186
+ 'h-[40%] → heightFraction=0.4',
187
+ );
188
+ }
189
+
190
+ // Out-of-range percents clamp to [0, 1].
191
+ {
192
+ const ir = makeEmptyIR();
193
+ parseSizing(['w-[250%]'], ir);
194
+ assert.equal(ir.widthFraction, 1, 'w-[250%] clamps to 1');
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // min-h-* / min-w-* — pixel floors. shadcn Textarea uses `min-h-20` to keep
199
+ // a multi-line area visible even when empty; the renderer enforces this
200
+ // via Figma's auto-layout minHeight property plus a resize floor.
201
+ // ---------------------------------------------------------------------------
202
+ {
203
+ const cases: Array<[string, 'minHeight' | 'minWidth', number]> = [
204
+ ['min-h-20', 'minHeight', 80],
205
+ ['min-h-10', 'minHeight', 40],
206
+ ['min-h-[120px]', 'minHeight', 120],
207
+ ['min-h-[8rem]', 'minHeight', 128],
208
+ ['min-w-32', 'minWidth', 128],
209
+ ['min-w-[8rem]', 'minWidth', 128],
210
+ ];
211
+ for (const [cls, key, expected] of cases) {
212
+ const ir = makeEmptyIR();
213
+ parseSizing([cls], ir);
214
+ assert.equal(ir[key], expected, `${cls} → ir.${key}=${expected}`);
215
+ }
216
+ }
217
+
218
+ // min-h-screen is already special-cased upstream (FILL mode); make sure the
219
+ // new min-h-N matcher doesn't shadow it.
220
+ {
221
+ const ir = makeEmptyIR();
222
+ parseSizing(['min-h-screen'], ir);
223
+ assert.equal(ir.heightMode, 'FILL', 'min-h-screen still maps to FILL');
224
+ assert.equal(ir.minHeight, undefined, 'min-h-screen does NOT set a pixel minHeight');
225
+ }
226
+
227
+ console.log('layout-sizing-regression: ok');
@@ -0,0 +1,135 @@
1
+ import assert from 'node:assert/strict';
2
+ import { makeEmptyIR } from '../src/layout/parser/ir';
3
+ import { resolveSpacing } from '../src/layout/parser/spacing-scale';
4
+ import { parseGap, parsePadding } from '../src/layout/parser/spacing';
5
+
6
+ /**
7
+ * Locks in the Tailwind spacing → LayoutIR mapping that previously
8
+ * lived inside LayoutParser as private static methods. Phase 2 of the
9
+ * layout-parser split moved this logic into `parser/spacing.ts` and
10
+ * `parser/spacing-scale.ts` — this fixture catches accidental
11
+ * behavioural regressions introduced by future edits to either file.
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // resolveSpacing — pure value resolution
16
+ // ---------------------------------------------------------------------------
17
+ {
18
+ // Tailwind scale
19
+ assert.equal(resolveSpacing('0'), 0);
20
+ assert.equal(resolveSpacing('px'), 1);
21
+ assert.equal(resolveSpacing('0.5'), 2);
22
+ assert.equal(resolveSpacing('1'), 4);
23
+ assert.equal(resolveSpacing('4'), 16);
24
+ assert.equal(resolveSpacing('8'), 32);
25
+ assert.equal(resolveSpacing('96'), 384);
26
+
27
+ // Arbitrary values
28
+ assert.equal(resolveSpacing('[12px]'), 12);
29
+ assert.equal(resolveSpacing('[1rem]'), 16);
30
+ assert.equal(resolveSpacing('[1.5rem]'), 24);
31
+ assert.equal(resolveSpacing('[2em]'), 32);
32
+ assert.equal(resolveSpacing('[24]'), 24); // unitless arbitrary defaults to px
33
+
34
+ // Bare numbers off the scale → multiplied by 4
35
+ assert.equal(resolveSpacing('100'), 400);
36
+
37
+ // Unknown
38
+ assert.equal(resolveSpacing('???'), 0);
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // parseGap — gap-* / gap-x-* / gap-y-* / space-x-* / space-y-*
43
+ // ---------------------------------------------------------------------------
44
+ {
45
+ // gap-N sets all three
46
+ const ir = makeEmptyIR();
47
+ parseGap(['gap-4'], ir);
48
+ assert.equal(ir.gap, 16);
49
+ assert.equal(ir.gapX, 16);
50
+ assert.equal(ir.gapY, 16);
51
+ }
52
+ {
53
+ // gap-x in a HORIZONTAL layout becomes the primary `ir.gap`
54
+ const ir = makeEmptyIR();
55
+ ir.layoutMode = 'HORIZONTAL';
56
+ parseGap(['gap-x-2'], ir);
57
+ assert.equal(ir.gap, 8);
58
+ assert.equal(ir.gapX, 8);
59
+ assert.equal(ir.gapY, undefined);
60
+ }
61
+ {
62
+ // gap-x in a VERTICAL layout sets gapX but does NOT update primary gap
63
+ const ir = makeEmptyIR();
64
+ ir.layoutMode = 'VERTICAL';
65
+ parseGap(['gap-x-2'], ir);
66
+ assert.equal(ir.gap, 0);
67
+ assert.equal(ir.gapX, 8);
68
+ }
69
+ {
70
+ // gap-y in VERTICAL flow becomes primary gap
71
+ const ir = makeEmptyIR();
72
+ ir.layoutMode = 'VERTICAL';
73
+ parseGap(['gap-y-3'], ir);
74
+ assert.equal(ir.gap, 12);
75
+ assert.equal(ir.gapY, 12);
76
+ }
77
+ {
78
+ // space-x in HORIZONTAL becomes gap; space-y in HORIZONTAL is ignored
79
+ const ir = makeEmptyIR();
80
+ ir.layoutMode = 'HORIZONTAL';
81
+ parseGap(['space-x-4', 'space-y-8'], ir);
82
+ assert.equal(ir.gap, 16);
83
+ }
84
+ {
85
+ // Arbitrary value in gap
86
+ const ir = makeEmptyIR();
87
+ parseGap(['gap-[10px]'], ir);
88
+ assert.equal(ir.gap, 10);
89
+ assert.equal(ir.gapX, 10);
90
+ assert.equal(ir.gapY, 10);
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // parsePadding — p-* / px-* / py-* / pt/pr/pb/pl-*
95
+ // ---------------------------------------------------------------------------
96
+ {
97
+ // p-N sets all four
98
+ const ir = makeEmptyIR();
99
+ parsePadding(['p-6'], ir);
100
+ assert.equal(ir.paddingTop, 24);
101
+ assert.equal(ir.paddingRight, 24);
102
+ assert.equal(ir.paddingBottom, 24);
103
+ assert.equal(ir.paddingLeft, 24);
104
+ }
105
+ {
106
+ // px-N + py-N + per-side overrides
107
+ const ir = makeEmptyIR();
108
+ parsePadding(['px-4', 'py-2', 'pt-8'], ir);
109
+ // px-4 left + right
110
+ assert.equal(ir.paddingLeft, 16);
111
+ assert.equal(ir.paddingRight, 16);
112
+ // py-2 sets bottom; pt-8 overrides top
113
+ assert.equal(ir.paddingBottom, 8);
114
+ assert.equal(ir.paddingTop, 32);
115
+ }
116
+ {
117
+ // Arbitrary padding
118
+ const ir = makeEmptyIR();
119
+ parsePadding(['p-[20px]'], ir);
120
+ assert.equal(ir.paddingTop, 20);
121
+ assert.equal(ir.paddingRight, 20);
122
+ assert.equal(ir.paddingBottom, 20);
123
+ assert.equal(ir.paddingLeft, 20);
124
+ }
125
+ {
126
+ // pl/pr individual
127
+ const ir = makeEmptyIR();
128
+ parsePadding(['pl-3', 'pr-5'], ir);
129
+ assert.equal(ir.paddingLeft, 12);
130
+ assert.equal(ir.paddingRight, 20);
131
+ assert.equal(ir.paddingTop, 0);
132
+ assert.equal(ir.paddingBottom, 0);
133
+ }
134
+
135
+ console.log('layout-spacing-regression: ok');
@@ -0,0 +1,331 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: function-body-local `const` declarations whose initializer
7
+ * depends on a prop value must be resolved when the identifier is
8
+ * referenced in a child JSX `className`. Without this, the common pattern
9
+ *
10
+ * const StatusPill = ({ sourceLabel }) => {
11
+ * const dotClass =
12
+ * sourceLabel === "db" ? "bg-emerald-500"
13
+ * : sourceLabel === "live" ? "bg-sky-400"
14
+ * : "bg-muted-foreground";
15
+ * return <span className={cn("h-2 w-2 rounded-full", dotClass)} />;
16
+ * };
17
+ *
18
+ * collapses to just `"h-2 w-2 rounded-full"` in the scanned output —
19
+ * the conditional color silently disappears.
20
+ *
21
+ * History: the inkbridge greenhouse-app `DataSourcesCard` shipped this
22
+ * pattern for its source-status pill. Stories passed `overallSourceLabel:
23
+ * "live"`, the runtime evaluated `dotClass = "bg-sky-400"`, but Figma
24
+ * rendered every pill with a black dot (the `bg-muted-foreground` fallback
25
+ * was being picked up incorrectly because the `cn(base, dotClass)` call
26
+ * lost the `dotClass` argument entirely).
27
+ *
28
+ * Root cause: `resolveExpressionValue` looked up identifiers in
29
+ * `propsContext` (function params), then fell back to
30
+ * `SourceFile.getVariableDeclaration` — which only searches MODULE-LEVEL
31
+ * declarations. Function-body `const`s were invisible.
32
+ *
33
+ * Fix: walk ancestor scopes (Block / CaseClause / DefaultClause) from the
34
+ * identifier site outward and resolve the matching `const`'s initializer
35
+ * against the current `propsContext`. This is the universal version of the
36
+ * pattern — applies to any local-const-derived className, not just pill
37
+ * colors.
38
+ */
39
+
40
+ interface JsxNodeLike {
41
+ type: 'element' | 'text';
42
+ tagName?: string;
43
+ content?: string;
44
+ props?: Record<string, unknown>;
45
+ children?: JsxNodeLike[];
46
+ }
47
+
48
+ interface TestScannerView {
49
+ project: import('ts-morph').Project;
50
+ extractComponentJsxTree: (
51
+ sourceFile: import('ts-morph').SourceFile,
52
+ componentName: string,
53
+ ) => JsxNodeLike | null;
54
+ }
55
+
56
+ function makeScanner(): TestScannerView {
57
+ return new ComponentScanner({
58
+ componentPaths: [],
59
+ filePattern: '*.tsx',
60
+ exclude: [],
61
+ }) as unknown as TestScannerView;
62
+ }
63
+
64
+ function fixturePath(relative: string): string {
65
+ return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
66
+ }
67
+
68
+ function findElement(node: JsxNodeLike | null, tag: string): JsxNodeLike | null {
69
+ if (!node || node.type !== 'element') return null;
70
+ if (node.tagName === tag) return node;
71
+ for (const child of node.children || []) {
72
+ const found = findElement(child, tag);
73
+ if (found) return found;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function classList(node: JsxNodeLike | null): string[] {
79
+ const cls = node?.props?.className;
80
+ if (typeof cls !== 'string') return [];
81
+ return cls.split(/\s+/).filter(Boolean);
82
+ }
83
+
84
+ const scanner = makeScanner();
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Case 1: the canonical StatusPill shape. A wrapper component renders a
88
+ // local-defined inner component with a literal prop value. The inner has a
89
+ // local `const dotClass = sourceLabel === "..." ? "class" : ...` whose
90
+ // resolved class must appear in the rendered `<span>`'s className.
91
+ // ---------------------------------------------------------------------------
92
+ {
93
+ const file = scanner.project.createSourceFile(
94
+ fixturePath('local-const-classname-status-pill.tsx'),
95
+ `
96
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
97
+
98
+ const StatusPill = ({ sourceLabel }: { sourceLabel: "db" | "live" | "inactive" }) => {
99
+ const dotClass =
100
+ sourceLabel === "db"
101
+ ? "bg-emerald-500"
102
+ : sourceLabel === "live"
103
+ ? "bg-sky-400"
104
+ : "bg-muted-foreground";
105
+ return <span data-slot="dot" className={cn("h-2 w-2 rounded-full", dotClass)} />;
106
+ };
107
+
108
+ export function Card() {
109
+ return <StatusPill sourceLabel="live" />;
110
+ }
111
+ `,
112
+ { overwrite: true },
113
+ );
114
+
115
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
116
+ assert.ok(tree, 'Card must produce a tree');
117
+ const dot = findElement(tree, 'span');
118
+ assert.ok(dot, 'must find the rendered <span>');
119
+ const classes = classList(dot);
120
+ assert.ok(
121
+ classes.includes('bg-sky-400'),
122
+ `dot must inherit bg-sky-400 from the "live" branch of the local-const ternary; got: ${classes.join(' ')}`,
123
+ );
124
+ assert.ok(classes.includes('h-2'), 'base utilities must survive cn() resolution');
125
+ assert.ok(classes.includes('rounded-full'), 'base utilities must survive cn() resolution');
126
+ // The non-chosen branches must NOT appear.
127
+ assert.ok(!classes.includes('bg-emerald-500'), 'losing branch "db" must not appear');
128
+ assert.ok(!classes.includes('bg-muted-foreground'), 'losing branch fallback must not appear');
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Case 2: literal pass-through with a different prop name. Verifies the
133
+ // resolver picks the right branch when a wrapper forwards a literal value
134
+ // (the StatusPill shape but a different name, to catch regressions tied to
135
+ // hard-coded identifier matching).
136
+ // ---------------------------------------------------------------------------
137
+ {
138
+ const file = scanner.project.createSourceFile(
139
+ fixturePath('local-const-classname-passthrough.tsx'),
140
+ `
141
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
142
+
143
+ const StatusPill = ({ tone }: { tone: "ok" | "warn" }) => {
144
+ const dotClass = tone === "warn" ? "bg-amber-500" : "bg-emerald-500";
145
+ return <span className={cn("dot", dotClass)} />;
146
+ };
147
+
148
+ export function Card() {
149
+ return <StatusPill tone="warn" />;
150
+ }
151
+ `,
152
+ { overwrite: true },
153
+ );
154
+
155
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
156
+ assert.ok(tree, 'Card must produce a tree');
157
+ const dot = findElement(tree, 'span');
158
+ assert.ok(dot, 'must find the rendered <span>');
159
+ const classes = classList(dot);
160
+ assert.ok(
161
+ classes.includes('bg-amber-500'),
162
+ `the "warn" branch must be selected; got: ${classes.join(' ')}`,
163
+ );
164
+ assert.ok(!classes.includes('bg-emerald-500'), 'the losing branch must not appear');
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Case 3: forward-reference TDZ. A const declared AFTER the JSX reference
169
+ // site in the same scope MUST NOT be picked up — that would resolve a
170
+ // real TDZ error as if it succeeded, masking bugs.
171
+ // ---------------------------------------------------------------------------
172
+ {
173
+ const file = scanner.project.createSourceFile(
174
+ fixturePath('local-const-classname-tdz.tsx'),
175
+ `
176
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
177
+
178
+ export function Card() {
179
+ // Reference comes BEFORE the declaration — JS would throw at runtime.
180
+ // The scanner must not silently "fix" it.
181
+ // @ts-ignore — intentional TDZ for the test.
182
+ const out = <span className={cn("base", lateClass)} />;
183
+ const lateClass = "should-not-appear";
184
+ return out;
185
+ }
186
+ `,
187
+ { overwrite: true },
188
+ );
189
+
190
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
191
+ assert.ok(tree, 'Card must produce a tree');
192
+ const span = findElement(tree, 'span');
193
+ assert.ok(span, 'must find <span>');
194
+ const classes = classList(span);
195
+ assert.ok(
196
+ !classes.includes('should-not-appear'),
197
+ 'forward-declared const must not be resolved (TDZ semantics)',
198
+ );
199
+ assert.ok(classes.includes('base'), 'literal class survives');
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Case 4: cycle protection. `const A = B` and `const B = A` must not
204
+ // infinite-loop — both should resolve to undefined and the className
205
+ // should fall back to just the literal base.
206
+ // ---------------------------------------------------------------------------
207
+ {
208
+ const file = scanner.project.createSourceFile(
209
+ fixturePath('local-const-classname-cycle.tsx'),
210
+ `
211
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
212
+
213
+ export function Card() {
214
+ const A = B;
215
+ const B = A;
216
+ return <span className={cn("base", A)} />;
217
+ }
218
+ `,
219
+ { overwrite: true },
220
+ );
221
+
222
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
223
+ assert.ok(tree, 'Card must produce a tree');
224
+ const span = findElement(tree, 'span');
225
+ assert.ok(span, 'must find <span>');
226
+ const classes = classList(span);
227
+ assert.ok(classes.includes('base'), 'literal class survives cycle protection');
228
+ // A and B are mutually recursive — neither should resolve to a literal
229
+ // string. The cn() call should silently drop the unresolved arg.
230
+ assert.equal(classes.length, 1, `only the literal "base" should remain; got: ${classes.join(' ')}`);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Case 5: scope-locality. Two functions in the same file both have a local
235
+ // `const colorClass = ...` with different values. The resolver must pick
236
+ // the one in the enclosing scope, not bleed across functions.
237
+ // ---------------------------------------------------------------------------
238
+ {
239
+ const file = scanner.project.createSourceFile(
240
+ fixturePath('local-const-classname-scope.tsx'),
241
+ `
242
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
243
+
244
+ const Inner = () => {
245
+ const colorClass = "text-sky-500";
246
+ return <span data-which="inner" className={cn("a", colorClass)} />;
247
+ };
248
+
249
+ export function Card() {
250
+ const colorClass = "text-rose-500";
251
+ return (
252
+ <div>
253
+ <span data-which="outer" className={cn("b", colorClass)} />
254
+ <Inner />
255
+ </div>
256
+ );
257
+ }
258
+ `,
259
+ { overwrite: true },
260
+ );
261
+
262
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
263
+ assert.ok(tree, 'Card must produce a tree');
264
+
265
+ // Find the two <span>s by their data-which marker.
266
+ function findByMarker(node: JsxNodeLike | null, value: string): JsxNodeLike | null {
267
+ if (!node || node.type !== 'element') return null;
268
+ if (node.props?.['data-which'] === value) return node;
269
+ for (const child of node.children || []) {
270
+ const found = findByMarker(child, value);
271
+ if (found) return found;
272
+ }
273
+ return null;
274
+ }
275
+ const outer = findByMarker(tree, 'outer');
276
+ const inner = findByMarker(tree, 'inner');
277
+ assert.ok(outer, 'outer <span> must be present');
278
+ assert.ok(inner, 'inner <span> must be present');
279
+
280
+ const outerClasses = classList(outer);
281
+ const innerClasses = classList(inner);
282
+ assert.ok(outerClasses.includes('text-rose-500'), `outer scope wins for outer span; got: ${outerClasses.join(' ')}`);
283
+ assert.ok(!outerClasses.includes('text-sky-500'), 'inner-scope value must not bleed into outer span');
284
+ assert.ok(innerClasses.includes('text-sky-500'), `inner scope wins for inner span; got: ${innerClasses.join(' ')}`);
285
+ assert.ok(!innerClasses.includes('text-rose-500'), 'outer-scope value must not be picked when inner scope shadows');
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Case 6: text-children expressions. `<span>{statusText}</span>` where
290
+ // `statusText` is a local-const ternary must render the resolved text.
291
+ // History: the original DataSourcesCard fix landed for className identifiers
292
+ // but the main `buildJsxTree` flow had a separate, narrower lookup for JSX
293
+ // text-children that only checked module-level vars — so the "Live - DB"
294
+ // label inside the StatusPill was missing even after the dot color worked.
295
+ // ---------------------------------------------------------------------------
296
+ {
297
+ const file = scanner.project.createSourceFile(
298
+ fixturePath('local-const-text-children.tsx'),
299
+ `
300
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
301
+
302
+ const StatusPill = ({ sourceLabel }: { sourceLabel: "db" | "live" | "inactive" }) => {
303
+ const statusText =
304
+ sourceLabel === "db"
305
+ ? "Live - DB"
306
+ : sourceLabel === "live"
307
+ ? "Live - API"
308
+ : "Inactive";
309
+ return <span data-slot="label">{statusText}</span>;
310
+ };
311
+
312
+ export function Card() {
313
+ return <StatusPill sourceLabel="db" />;
314
+ }
315
+ `,
316
+ { overwrite: true },
317
+ );
318
+
319
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
320
+ assert.ok(tree, 'Card must produce a tree');
321
+ const span = findElement(tree, 'span');
322
+ assert.ok(span, 'must find <span>');
323
+ const textChildren = (span.children || []).filter((c) => c.type === 'text');
324
+ const combined = textChildren.map((c) => c.content ?? '').join('');
325
+ assert.ok(
326
+ combined.includes('Live - DB'),
327
+ `text child must resolve to "Live - DB" via local-const lookup; got: ${JSON.stringify(textChildren)}`,
328
+ );
329
+ }
330
+
331
+ console.log('local-const-className-regression: PASS (6 cases)');