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,338 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ (globalThis as unknown as { figma: unknown }).figma = {
4
+ notify: () => undefined,
5
+ showUI: () => undefined,
6
+ createFrame: () => makeStubFrame(),
7
+ };
8
+
9
+ import {
10
+ applyFullWidthIfPossible,
11
+ markFullWidthNode,
12
+ markFullHeightNode,
13
+ markAbsoluteNode,
14
+ markCssGridVerticalFrame,
15
+ } from '../src/layout/deferred-layout';
16
+
17
+ // MATRIX REGRESSION — full-width / full-height resolution under varying
18
+ // parent context.
19
+ //
20
+ // `applyFullWidthIfPossible` is the SECOND major pass where the "context-
21
+ // dependent sizing" class of bug recurs. The per-class parser writes a
22
+ // `FULL_WIDTH_NODES` / `FULL_HEIGHT_NODES` mark for `w-full` / `h-full`;
23
+ // this pass later reads the mark + parent context and decides whether to:
24
+ // - set `layoutAlign=STRETCH` (vertical flex parent)
25
+ // - set `layoutGrow=1` (horizontal flex parent)
26
+ // - direct-resize (NONE parent, OR absolute child of any parent)
27
+ //
28
+ // Historical fix sites in this class (see troubleshooting.md and commit
29
+ // d817a58):
30
+ // 1. Absolute child with `h-full w-full` not resized in flex parents —
31
+ // old code only direct-resized when `parent.layoutMode === 'NONE'`,
32
+ // so absolute children of VERTICAL/HORIZONTAL parents got dead
33
+ // `layoutAlign='STRETCH'`/`layoutGrow=1` no-ops. Fix: added direct-
34
+ // resize branch for `childIsAbsolutePositioned`.
35
+ // 2. CSS grid (single-column) children failed to stretch — fix:
36
+ // `CSS_GRID_VERTICAL_FRAMES.has(parent)` implies `hasFullWidth`.
37
+ // 3. SPACE_BETWEEN parents had children consume all space — fix: set
38
+ // `layoutGrow=0` + `primaryAxisSizingMode='AUTO'` on `w-full` children
39
+ // of SPACE_BETWEEN parents.
40
+ // 4. `mx-auto + max-w-N` resized child back to wrapper width — fix:
41
+ // structural check (`parent.name === 'mx-auto'` + `MAX_WIDTH_NODES`)
42
+ // bypasses the HORIZONTAL stretch branch.
43
+ // 5. Reflow re-application stacked grid cards — fix in caller
44
+ // (`HORIZONTAL+WRAP` skip), NOT in this function directly. Out of
45
+ // scope for this matrix; covered by `aspect-percent-position-
46
+ // regression.ts`.
47
+ //
48
+ // Cells assert the final state of `layoutAlign`, `layoutGrow`, and
49
+ // post-resize `width` for every (child intent, parent context) cross
50
+ // product.
51
+
52
+ type StubFrame = {
53
+ type: 'FRAME';
54
+ name: string;
55
+ layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
56
+ layoutPositioning: 'AUTO' | 'ABSOLUTE';
57
+ primaryAxisSizingMode: 'AUTO' | 'FIXED';
58
+ counterAxisSizingMode: 'AUTO' | 'FIXED';
59
+ primaryAxisAlignItems: string;
60
+ counterAxisAlignItems: string;
61
+ layoutAlign: string;
62
+ layoutGrow: number;
63
+ layoutSizingHorizontal: 'HUG' | 'FIXED' | 'FILL';
64
+ layoutSizingVertical: 'HUG' | 'FIXED' | 'FILL';
65
+ counterAxisAlignSelf: string;
66
+ width: number;
67
+ height: number;
68
+ paddingLeft: number;
69
+ paddingRight: number;
70
+ paddingTop: number;
71
+ paddingBottom: number;
72
+ fills: unknown[];
73
+ strokes: unknown[];
74
+ effects: unknown[];
75
+ children: never[];
76
+ appendChild(): void;
77
+ resize(w: number, h: number): void;
78
+ };
79
+
80
+ function makeStubFrame(): StubFrame {
81
+ return {
82
+ type: 'FRAME',
83
+ name: '',
84
+ layoutMode: 'NONE',
85
+ layoutPositioning: 'AUTO',
86
+ primaryAxisSizingMode: 'AUTO',
87
+ counterAxisSizingMode: 'AUTO',
88
+ primaryAxisAlignItems: 'MIN',
89
+ counterAxisAlignItems: 'MIN',
90
+ layoutAlign: 'INHERIT',
91
+ layoutGrow: 0,
92
+ layoutSizingHorizontal: 'HUG',
93
+ layoutSizingVertical: 'HUG',
94
+ counterAxisAlignSelf: 'AUTO',
95
+ width: 0,
96
+ height: 0,
97
+ paddingLeft: 0,
98
+ paddingRight: 0,
99
+ paddingTop: 0,
100
+ paddingBottom: 0,
101
+ fills: [],
102
+ strokes: [],
103
+ effects: [],
104
+ children: [],
105
+ appendChild() { /* no-op */ },
106
+ resize(w, h) { this.width = w; this.height = h; },
107
+ };
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Parent factories. Sized at width=600 so cells can assert numeric resizes
112
+ // where applicable.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ type ParentKind =
116
+ | 'flex-col-fixed-cross' // VERTICAL, counterAxisSizing=FIXED — resize-on-stretch fires
117
+ | 'flex-col-hug-cross' // VERTICAL, counterAxisSizing=AUTO — STRETCH only, no resize
118
+ | 'flex-row-fixed-primary' // HORIZONTAL, primaryAxisSizing=FIXED — grow + resize
119
+ | 'positioning-container' // NONE — direct resize for anything marked w-full
120
+ | 'css-grid-vertical' // VERTICAL + CSS_GRID_VERTICAL_FRAMES → implicit w-full;
121
+ | 'flex-row-space-between'; // HORIZONTAL with SPACE_BETWEEN — special case
122
+
123
+ function makeParent(kind: ParentKind): FrameNode {
124
+ const parent = makeStubFrame();
125
+ parent.width = 600;
126
+ switch (kind) {
127
+ case 'flex-col-fixed-cross':
128
+ parent.layoutMode = 'VERTICAL';
129
+ parent.primaryAxisSizingMode = 'AUTO';
130
+ parent.counterAxisSizingMode = 'FIXED';
131
+ break;
132
+ case 'flex-col-hug-cross':
133
+ parent.layoutMode = 'VERTICAL';
134
+ parent.primaryAxisSizingMode = 'AUTO';
135
+ parent.counterAxisSizingMode = 'AUTO';
136
+ break;
137
+ case 'flex-row-fixed-primary':
138
+ parent.layoutMode = 'HORIZONTAL';
139
+ parent.primaryAxisSizingMode = 'FIXED';
140
+ parent.counterAxisSizingMode = 'AUTO';
141
+ break;
142
+ case 'positioning-container':
143
+ parent.layoutMode = 'NONE';
144
+ break;
145
+ case 'css-grid-vertical':
146
+ parent.layoutMode = 'VERTICAL';
147
+ parent.primaryAxisSizingMode = 'AUTO';
148
+ parent.counterAxisSizingMode = 'FIXED';
149
+ markCssGridVerticalFrame(parent as unknown as FrameNode);
150
+ break;
151
+ case 'flex-row-space-between':
152
+ parent.layoutMode = 'HORIZONTAL';
153
+ parent.primaryAxisSizingMode = 'FIXED';
154
+ parent.primaryAxisAlignItems = 'SPACE_BETWEEN';
155
+ parent.counterAxisSizingMode = 'AUTO';
156
+ break;
157
+ }
158
+ return parent as unknown as FrameNode;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Child factories. Marks set here mimic what tailwind.ts records for the
163
+ // relevant utility classes.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ type ChildKind =
167
+ | 'w-full' // <div class="w-full">
168
+ | 'absolute-w-full' // <div class="absolute w-full">
169
+ | 'absolute-w-h-full' // <div class="absolute h-full w-full"> — SVG overlay
170
+ | 'unmarked'; // <div class="p-4"> — no width directive; sanity check
171
+
172
+ function makeChild(kind: ChildKind): FrameNode {
173
+ const child = makeStubFrame();
174
+ child.width = 24; // simulate Figma default
175
+ child.height = 24;
176
+ switch (kind) {
177
+ case 'w-full':
178
+ markFullWidthNode(child as unknown as FrameNode);
179
+ break;
180
+ case 'absolute-w-full':
181
+ child.layoutPositioning = 'ABSOLUTE';
182
+ markAbsoluteNode(child as unknown as FrameNode);
183
+ markFullWidthNode(child as unknown as FrameNode);
184
+ break;
185
+ case 'absolute-w-h-full':
186
+ child.layoutPositioning = 'ABSOLUTE';
187
+ markAbsoluteNode(child as unknown as FrameNode);
188
+ markFullWidthNode(child as unknown as FrameNode);
189
+ markFullHeightNode(child as unknown as FrameNode);
190
+ break;
191
+ case 'unmarked':
192
+ break;
193
+ }
194
+ return child as unknown as FrameNode;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Truth table. Each cell asserts the parts of the contract that matter for
199
+ // the (child, parent) pair. `undefined` expectations are unchecked.
200
+ // ---------------------------------------------------------------------------
201
+
202
+ interface Cell {
203
+ child: ChildKind;
204
+ parent: ParentKind;
205
+ expect: {
206
+ layoutAlign?: 'INHERIT' | 'STRETCH';
207
+ layoutGrow?: number;
208
+ width?: number;
209
+ height?: number;
210
+ };
211
+ note: string;
212
+ }
213
+
214
+ const CELLS: Cell[] = [
215
+ // ---- w-full block child ------------------------------------------------
216
+ { child: 'w-full', parent: 'flex-col-fixed-cross',
217
+ expect: { layoutAlign: 'STRETCH', width: 600 },
218
+ note: 'w-full in vertical flex with FIXED cross-axis: STRETCH + resize' },
219
+ { child: 'w-full', parent: 'flex-col-hug-cross',
220
+ expect: { layoutAlign: 'STRETCH' },
221
+ note: 'w-full in vertical flex with AUTO cross-axis: STRETCH only, parent will hug to widest' },
222
+ { child: 'w-full', parent: 'flex-row-fixed-primary',
223
+ expect: { layoutGrow: 1, width: 600 },
224
+ note: 'w-full in horizontal flex: layoutGrow=1 + resize to parent.width' },
225
+ { child: 'w-full', parent: 'positioning-container',
226
+ expect: { width: 600 },
227
+ note: 'w-full in NONE parent (positioning container): direct resize' },
228
+ { child: 'w-full', parent: 'css-grid-vertical',
229
+ expect: { layoutAlign: 'STRETCH', width: 600 },
230
+ note: 'CSS-grid vertical (single column): children implicitly stretch' },
231
+ { child: 'w-full', parent: 'flex-row-space-between',
232
+ expect: { layoutGrow: 0 },
233
+ note: 'SPACE_BETWEEN parent: layoutGrow=0 so siblings get spaced (CSS justify-content)' },
234
+
235
+ // ---- absolute w-full child (no h-full) ---------------------------------
236
+ { child: 'absolute-w-full', parent: 'flex-col-fixed-cross',
237
+ expect: { width: 600 },
238
+ note: 'absolute child in vertical flex: direct-resize (per d817a58 fix)' },
239
+ { child: 'absolute-w-full', parent: 'flex-row-fixed-primary',
240
+ expect: { width: 600 },
241
+ note: 'absolute child in horizontal flex: direct-resize (NOT layoutGrow=1; that is a no-op)' },
242
+ { child: 'absolute-w-full', parent: 'positioning-container',
243
+ expect: { width: 600 },
244
+ note: 'absolute child in NONE parent: direct-resize (canonical absolute overlay)' },
245
+
246
+ // ---- absolute w-full + h-full (SVG overlay) ----------------------------
247
+ { child: 'absolute-w-h-full', parent: 'flex-col-fixed-cross',
248
+ expect: { width: 600 },
249
+ note: 'absolute overlay: width resizes regardless of parent layoutMode' },
250
+ { child: 'absolute-w-h-full', parent: 'positioning-container',
251
+ expect: { width: 600 },
252
+ note: 'absolute overlay in aspect-ratio container: width+height both resize (height tested below)' },
253
+
254
+ // ---- unmarked control (sanity) -----------------------------------------
255
+ { child: 'unmarked', parent: 'flex-col-fixed-cross',
256
+ expect: { layoutAlign: 'INHERIT', layoutGrow: 0, width: 24 },
257
+ note: 'no width directive: no changes' },
258
+ { child: 'unmarked', parent: 'flex-row-fixed-primary',
259
+ expect: { layoutAlign: 'INHERIT', layoutGrow: 0, width: 24 },
260
+ note: 'no width directive in horizontal parent: no changes' },
261
+ ];
262
+
263
+ function runRegression(): void {
264
+ const failures: string[] = [];
265
+
266
+ for (const cell of CELLS) {
267
+ const parent = makeParent(cell.parent);
268
+ const child = makeChild(cell.child);
269
+
270
+ applyFullWidthIfPossible(child, parent);
271
+
272
+ const cellId = `${cell.child} × ${cell.parent}`;
273
+ const stub = child as unknown as StubFrame;
274
+
275
+ if (cell.expect.layoutAlign !== undefined) {
276
+ if (stub.layoutAlign !== cell.expect.layoutAlign) {
277
+ failures.push(
278
+ `${cellId}: expected layoutAlign=${cell.expect.layoutAlign}, got ${stub.layoutAlign}\n → ${cell.note}`,
279
+ );
280
+ }
281
+ }
282
+ if (cell.expect.layoutGrow !== undefined) {
283
+ if (stub.layoutGrow !== cell.expect.layoutGrow) {
284
+ failures.push(
285
+ `${cellId}: expected layoutGrow=${cell.expect.layoutGrow}, got ${stub.layoutGrow}\n → ${cell.note}`,
286
+ );
287
+ }
288
+ }
289
+ if (cell.expect.width !== undefined) {
290
+ if (stub.width !== cell.expect.width) {
291
+ failures.push(
292
+ `${cellId}: expected width=${cell.expect.width}, got ${stub.width}\n → ${cell.note}`,
293
+ );
294
+ }
295
+ }
296
+ if (cell.expect.height !== undefined) {
297
+ if (stub.height !== cell.expect.height) {
298
+ failures.push(
299
+ `${cellId}: expected height=${cell.expect.height}, got ${stub.height}\n → ${cell.note}`,
300
+ );
301
+ }
302
+ }
303
+ }
304
+
305
+ // Extra: absolute overlay in NONE parent should resize BOTH axes. The
306
+ // height branch fires after the width branch — assert here so the matrix
307
+ // covers the full-height direct-resize path that fix-sites have touched.
308
+ {
309
+ const parent = makeParent('positioning-container');
310
+ (parent as unknown as StubFrame).height = 400;
311
+ const child = makeChild('absolute-w-h-full');
312
+ applyFullWidthIfPossible(child, parent);
313
+ const stub = child as unknown as StubFrame;
314
+ if (stub.width !== 600) {
315
+ failures.push(`absolute-w-h-full × NONE: width should be 600, got ${stub.width}`);
316
+ }
317
+ if (stub.height !== 400) {
318
+ failures.push(
319
+ `absolute-w-h-full × NONE: height should be 400 (parent.height direct-resize), got ${stub.height}`,
320
+ );
321
+ }
322
+ }
323
+
324
+ if (failures.length > 0) {
325
+ for (const msg of failures) console.error(' ✗ ' + msg);
326
+ assert.fail(`${failures.length} full-width matrix cells failed`);
327
+ }
328
+
329
+ console.log(`full-width-matrix-regression: PASS (${CELLS.length} cells + height assertion)`);
330
+ }
331
+
332
+ try {
333
+ runRegression();
334
+ } catch (err) {
335
+ console.error('full-width-matrix-regression: FAIL');
336
+ console.error(err);
337
+ process.exit(1);
338
+ }
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { extractGridColumns } from '../src/layout';
4
+
5
+ /**
6
+ * Regression: `extractGridColumns` is the single source of truth for
7
+ * "how many columns does this Tailwind class list specify?" — consumed
8
+ * by both the story-bench and preview-builder reflow pipelines.
9
+ *
10
+ * The function intentionally returns `1` for plain `grid` (no explicit
11
+ * `grid-cols-N`) because Tailwind's default for `display: grid` is
12
+ * single-column flow, and the value is useful for responsive previews
13
+ * (so `sm:grid-cols-3` can flip the layout at a breakpoint). The
14
+ * downstream hazard: `applyGridColumnsIfPossible` would
15
+ * unconditionally flip the frame to `HORIZONTAL` + `WRAP` +
16
+ * `counterAxisSizingMode = AUTO`. A "1-column" reflow on a VERTICAL
17
+ * bench produces a single Hug row of children laid side-by-side
18
+ * instead of stacked.
19
+ *
20
+ * History: this bug class recurred at multiple call sites — every
21
+ * place that forwarded a `cols` value to `applyGridColumnsWithReflow`
22
+ * had to remember the `> 1` guard. preview-builder.ts:668 had the
23
+ * guard; story-builder.ts:491 didn't, so every `<div className="grid
24
+ * w-[Npx] gap-N">` story root rendered as a horizontal Hug-width row.
25
+ * The guard now lives inside `applyGridColumnsIfPossible` itself, so
26
+ * the contract is "forward whatever cols you extracted, the helper
27
+ * decides".
28
+ *
29
+ * This file locks the function's return values so the helper's
30
+ * `cols <= 1` early-return keeps its meaning. Drift in either
31
+ * direction (returning `null` for plain grid, or returning `> 1` for a
32
+ * single-column source) would either silently break responsive
33
+ * previews or re-introduce the horizontal-bench bug.
34
+ */
35
+
36
+ // Plain `grid` with no explicit columns → implicit single-column flow.
37
+ // Call sites MUST guard with `> 1` before applying a column reflow.
38
+ assert.equal(
39
+ extractGridColumns(['grid']),
40
+ 1,
41
+ 'plain `grid` → 1 (Tailwind default single-column flow)',
42
+ );
43
+ assert.equal(
44
+ extractGridColumns(['grid', 'gap-2']),
45
+ 1,
46
+ '`grid gap-N` → 1',
47
+ );
48
+ assert.equal(
49
+ extractGridColumns(['grid', 'w-[360px]', 'gap-2']),
50
+ 1,
51
+ '`grid w-[N] gap-N` → 1 (the FormField story shape)',
52
+ );
53
+ assert.equal(
54
+ extractGridColumns(['inline-grid']),
55
+ 1,
56
+ 'plain `inline-grid` → 1',
57
+ );
58
+
59
+ // Explicit `grid-cols-N` → that number, and downstream reflow IS
60
+ // expected to fire.
61
+ assert.equal(
62
+ extractGridColumns(['grid', 'grid-cols-2']),
63
+ 2,
64
+ '`grid grid-cols-2` → 2',
65
+ );
66
+ assert.equal(
67
+ extractGridColumns(['grid', 'grid-cols-3', 'gap-4']),
68
+ 3,
69
+ '`grid grid-cols-3 gap-N` → 3',
70
+ );
71
+ assert.equal(
72
+ extractGridColumns(['grid', 'grid-cols-12']),
73
+ 12,
74
+ 'multi-digit column count parsed',
75
+ );
76
+
77
+ // No grid at all → null. Call sites short-circuit without ambiguity.
78
+ assert.equal(
79
+ extractGridColumns(['flex', 'flex-col', 'gap-2']),
80
+ null,
81
+ 'flex layouts → null',
82
+ );
83
+ assert.equal(
84
+ extractGridColumns(['p-4', 'rounded-md']),
85
+ null,
86
+ 'no display utility → null',
87
+ );
88
+ assert.equal(
89
+ extractGridColumns([]),
90
+ null,
91
+ 'empty class list → null',
92
+ );
93
+ assert.equal(
94
+ extractGridColumns(undefined),
95
+ null,
96
+ 'undefined input → null',
97
+ );
98
+
99
+ // Responsive `sm:grid-cols-N` without a base `grid-cols-N` — the function
100
+ // reports the responsive column count when the BASE already has `grid`.
101
+ // (The bench renders at `base` width by default, so without an
102
+ // `availableWidth` hint, the function returns the responsive value as
103
+ // the prevailing column count.)
104
+ assert.equal(
105
+ extractGridColumns(['grid', 'sm:grid-cols-3']),
106
+ 3,
107
+ 'responsive grid-cols (no base cols) → responsive value',
108
+ );
109
+
110
+ console.log('grid-cols-extraction-regression: PASS (11 cases)');
@@ -0,0 +1,204 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import {
4
+ collectImageSrcs,
5
+ collectImageSrcsFromJsxTree,
6
+ looksLikeImageSrc,
7
+ } from '../src/plugin/image-src-collector';
8
+ import type { ComponentDef } from '../src/components';
9
+
10
+ /**
11
+ * Regression: the prefetch step at the top of generation needs to collect
12
+ * every image `src` reachable from the scanned JSX trees. The old tagName
13
+ * filter (`tag === 'img' || tag === 'Image'`) silently skipped shadcn-style
14
+ * image wrappers like `<AvatarImage>` and `<AvatarPrimitive.Image>` — even
15
+ * though the renderer is willing to draw any leaf element whose `src` prop
16
+ * points at a fetchable path.
17
+ *
18
+ * Symptom: `<Avatar><AvatarImage src="/images/avatar.svg" /></Avatar>`
19
+ * rendered as an empty 40×40 frame in Figma because the src was never
20
+ * prefetched into `imageMap` / `svgMap`. Storybook showed the image fine.
21
+ *
22
+ * Fix: widen the collector to "any node with a `src` prop that looks like a
23
+ * path/URL," matching the renderer's tolerance. This file locks both
24
+ * primitives together — drift between scanner and renderer is what caused
25
+ * the bug in the first place.
26
+ */
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // looksLikeImageSrc — what shapes the prefetch can resolve
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const positiveSrcs: string[] = [
33
+ '/images/avatar.svg',
34
+ '/foo.png',
35
+ '/api/avatar?u=1',
36
+ 'http://localhost:3000/img.png',
37
+ 'https://cdn.example.com/x.jpg',
38
+ './local.svg',
39
+ '../parent.png',
40
+ ];
41
+
42
+ for (const src of positiveSrcs) {
43
+ assert.equal(looksLikeImageSrc(src), true, 'must accept: ' + src);
44
+ }
45
+
46
+ const negativeSrcs: string[] = [
47
+ '',
48
+ 'avatar.svg', // bare filename — no path anchor, can't resolve
49
+ 'src', // literal placeholder some scanners emit
50
+ 'data:image/png;base64,AAAA', // browser handles data URLs directly
51
+ 'blob:http://x/y',
52
+ 'javascript:alert(1)',
53
+ ];
54
+
55
+ for (const src of negativeSrcs) {
56
+ assert.equal(looksLikeImageSrc(src), false, 'must reject: ' + JSON.stringify(src));
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // collectImageSrcsFromJsxTree — walks the tree, captures any leaf with src
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function el(tagName: string, props: Record<string, unknown>, children: unknown[] = []) {
64
+ return { type: 'element', tagName, props, children };
65
+ }
66
+
67
+ // (a) raw <img> still captured
68
+ {
69
+ const out = new Set<string>();
70
+ collectImageSrcsFromJsxTree(el('img', { src: '/foo.png' }), out);
71
+ assert.deepEqual([...out], ['/foo.png'], '<img> src captured');
72
+ }
73
+
74
+ // (b) Next.js <Image> still captured
75
+ {
76
+ const out = new Set<string>();
77
+ collectImageSrcsFromJsxTree(el('Image', { src: '/next.png' }), out);
78
+ assert.deepEqual([...out], ['/next.png'], 'Next.js <Image> src captured');
79
+ }
80
+
81
+ // (c) <AvatarImage> — the regression case
82
+ {
83
+ const out = new Set<string>();
84
+ collectImageSrcsFromJsxTree(el('AvatarImage', { src: '/images/avatar.svg' }), out);
85
+ assert.deepEqual([...out], ['/images/avatar.svg'], 'AvatarImage src captured (new behaviour)');
86
+ }
87
+
88
+ // (d) <AvatarPrimitive.Image> — namespaced primitive
89
+ {
90
+ const out = new Set<string>();
91
+ collectImageSrcsFromJsxTree(el('AvatarPrimitive.Image', { src: '/x.svg' }), out);
92
+ assert.deepEqual([...out], ['/x.svg'], 'namespaced image primitive src captured');
93
+ }
94
+
95
+ // (e) nested children — recurses
96
+ {
97
+ const out = new Set<string>();
98
+ collectImageSrcsFromJsxTree(
99
+ el('Avatar', {}, [
100
+ el('AvatarImage', { src: '/a.svg' }),
101
+ el('AvatarFallback', {}, [{ type: 'text', content: 'CN' }]),
102
+ ]),
103
+ out,
104
+ );
105
+ assert.deepEqual([...out], ['/a.svg'], 'recursed into Avatar wrapper');
106
+ }
107
+
108
+ // (f) multiple images deduped
109
+ {
110
+ const out = new Set<string>();
111
+ collectImageSrcsFromJsxTree(
112
+ el('section', {}, [
113
+ el('img', { src: '/a.png' }),
114
+ el('AvatarImage', { src: '/a.png' }),
115
+ el('img', { src: '/b.png' }),
116
+ ]),
117
+ out,
118
+ );
119
+ assert.deepEqual([...out].sort(), ['/a.png', '/b.png'], 'duplicates collapsed via Set');
120
+ }
121
+
122
+ // (g) literal 'src' placeholder and missing src — ignored
123
+ {
124
+ const out = new Set<string>();
125
+ collectImageSrcsFromJsxTree(
126
+ el('img', { src: 'src' }), // some scanners emit this for unresolved attrs
127
+ out,
128
+ );
129
+ collectImageSrcsFromJsxTree(el('img', {}), out);
130
+ assert.equal(out.size, 0, 'placeholder + missing src do not pollute output');
131
+ }
132
+
133
+ // (h) data: URL — not captured (browser handles directly)
134
+ {
135
+ const out = new Set<string>();
136
+ collectImageSrcsFromJsxTree(
137
+ el('img', { src: 'data:image/png;base64,AAAA' }),
138
+ out,
139
+ );
140
+ assert.equal(out.size, 0, 'data: URL is not a prefetch candidate');
141
+ }
142
+
143
+ // (i) src prop on a wrapping component with children — STILL captured
144
+ // (the renderer's "leaf" rule is enforced there, not here; the collector
145
+ // is intentionally generous so renderer changes never need a follow-up
146
+ // collector change).
147
+ {
148
+ const out = new Set<string>();
149
+ collectImageSrcsFromJsxTree(
150
+ el('CustomFrame', { src: '/wrap.svg' }, [el('span', {}, [])]),
151
+ out,
152
+ );
153
+ assert.deepEqual([...out], ['/wrap.svg'], 'src on non-leaf is still collected (renderer decides usage)');
154
+ }
155
+
156
+ // (j) non-object / null tolerated
157
+ {
158
+ const out = new Set<string>();
159
+ collectImageSrcsFromJsxTree(null, out);
160
+ collectImageSrcsFromJsxTree('not-a-node' as unknown, out);
161
+ collectImageSrcsFromJsxTree(undefined, out);
162
+ assert.equal(out.size, 0, 'null / non-object inputs are no-ops');
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // collectImageSrcs — walks every component's analysis and every story
167
+ // ---------------------------------------------------------------------------
168
+
169
+ {
170
+ const components: ComponentDef[] = [
171
+ {
172
+ analysis: {
173
+ jsxTree: el('Avatar', {}, [el('AvatarImage', { src: '/comp.svg' })]),
174
+ stories: [
175
+ { jsxTree: el('Avatar', {}, [el('AvatarImage', { src: '/story1.svg' })]) },
176
+ { jsxTree: el('Avatar', {}, [el('AvatarImage', { src: '/story2.svg' })]) },
177
+ ],
178
+ },
179
+ } as unknown as ComponentDef,
180
+ {
181
+ analysis: {
182
+ jsxTree: el('Card', {}, [el('img', { src: '/card.png' })]),
183
+ stories: [],
184
+ },
185
+ } as unknown as ComponentDef,
186
+ // Component with no analysis is tolerated.
187
+ { analysis: null } as unknown as ComponentDef,
188
+ ];
189
+
190
+ const srcs = collectImageSrcs(components).sort();
191
+ assert.deepEqual(
192
+ srcs,
193
+ ['/card.png', '/comp.svg', '/story1.svg', '/story2.svg'],
194
+ 'collectImageSrcs walks component analysis + every story, tolerates missing analysis',
195
+ );
196
+ }
197
+
198
+ console.log(
199
+ 'image-src-collector-regression: PASS ('
200
+ + positiveSrcs.length
201
+ + ' positive + '
202
+ + negativeSrcs.length
203
+ + ' negative URL cases + 10 tree cases + 1 component-level case)',
204
+ );