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,298 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ MOBILE_ONLY_DEFAULT_WIDTH,
4
+ VIEWPORT_HEIGHTS,
5
+ collectLeadingSpineClasses,
6
+ detectViewportHeightPattern,
7
+ mobileOnlyRootWidth,
8
+ treeHasDescendantClass,
9
+ treeHasPortalWithFullHeight,
10
+ } from '../src/design-system/story-dimensioning';
11
+ import type { JsxNode } from '../src/tailwind';
12
+ import type { ComponentStory } from '../src/components';
13
+
14
+ /**
15
+ * Regression: the story-dimensioning helpers decide the desktop width and
16
+ * height of every Story Layout frame. Drift here resizes every story
17
+ * differently — mobile-only nav stretches to 900px again (the "empty strip"
18
+ * bug), drawer sheets collapse to 0 height, full-page shells lose their
19
+ * synthetic viewport. Lock the heuristics.
20
+ *
21
+ * Extracted from `src/design-system/story-builder.ts` into
22
+ * `src/design-system/story-dimensioning.ts`. The Figma-API-touching entry
23
+ * points (resolveStoryLayoutWidth / Height) are exercised in real Figma
24
+ * during plugin runs — this fixture covers the pure detectors that decide
25
+ * which width/height bucket a story lands in.
26
+ */
27
+
28
+ function el(props: Record<string, unknown>, children: JsxNode[] = []): JsxNode {
29
+ return { type: 'element', tagName: 'div', props, children } as unknown as JsxNode;
30
+ }
31
+
32
+ function story(jsxTree: JsxNode | null): ComponentStory {
33
+ return { name: 'TestStory', jsxTree } as unknown as ComponentStory;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // collectLeadingSpineClasses — walks single-child spine, stops on fan-out
38
+ // ---------------------------------------------------------------------------
39
+
40
+ // Single-element spine: classes appear in the output.
41
+ {
42
+ const out: string[] = [];
43
+ collectLeadingSpineClasses(el({ className: 'flex flex-col h-full' }), out);
44
+ assert.deepEqual(out, ['flex', 'flex-col', 'h-full'], 'root classes split + collected');
45
+ }
46
+
47
+ // Multi-level spine — each parent has exactly one element child.
48
+ {
49
+ const out: string[] = [];
50
+ const tree = el({ className: 'outer' }, [el({ className: 'middle' }, [el({ className: 'inner' })])]);
51
+ collectLeadingSpineClasses(tree, out);
52
+ assert.deepEqual(out, ['outer', 'middle', 'inner'], 'spine walks through single-child wrappers');
53
+ }
54
+
55
+ // Fan-out stops the walk — siblings are body, not spine.
56
+ {
57
+ const out: string[] = [];
58
+ const tree = el({ className: 'root' }, [
59
+ el({ className: 'child-a' }),
60
+ el({ className: 'child-b' }),
61
+ ]);
62
+ collectLeadingSpineClasses(tree, out);
63
+ assert.deepEqual(out, ['root'], 'fan-out stops the walk — sibling classes are body, not spine');
64
+ }
65
+
66
+ // Depth limit (>4) caps the walk so pathological deep wrappers don't hang.
67
+ {
68
+ const out: string[] = [];
69
+ // Build 6 levels of single-child wrappers.
70
+ let leaf: JsxNode = el({ className: 'L5' });
71
+ for (let i = 4; i >= 0; i--) leaf = el({ className: 'L' + i }, [leaf]);
72
+ collectLeadingSpineClasses(leaf, out);
73
+ // 4 levels max → L0..L3 collected, L4/L5 dropped.
74
+ assert.deepEqual(out, ['L0', 'L1', 'L2', 'L3'], 'depth limit caps spine walk at 4 levels');
75
+ }
76
+
77
+ // Non-element / null / no className → no-op, no throw.
78
+ {
79
+ const out: string[] = [];
80
+ collectLeadingSpineClasses(el({}), out);
81
+ assert.deepEqual(out, [], 'element without className contributes nothing');
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // treeHasDescendantClass — depth-limited descendant scan
86
+ // ---------------------------------------------------------------------------
87
+
88
+ assert.equal(
89
+ treeHasDescendantClass(el({ className: 'flex-1' }), 'flex-1'),
90
+ true,
91
+ 'class on the root counts',
92
+ );
93
+
94
+ assert.equal(
95
+ treeHasDescendantClass(el({ className: 'flex' }, [el({ className: 'flex-1' })]), 'flex-1'),
96
+ true,
97
+ 'class on a direct child is found',
98
+ );
99
+
100
+ assert.equal(
101
+ treeHasDescendantClass(el({ className: 'flex' }), 'flex-1'),
102
+ false,
103
+ 'class absent — returns false (not undefined)',
104
+ );
105
+
106
+ // Depth limit (>6) — pathological deep tree without the class returns false
107
+ // without throwing or hanging.
108
+ {
109
+ let deep: JsxNode = el({ className: 'flex-1' });
110
+ for (let i = 0; i < 10; i++) deep = el({ className: 'wrapper' }, [deep]);
111
+ // The flex-1 is buried 10 levels deep; we cap at 6 so it should not be found.
112
+ assert.equal(
113
+ treeHasDescendantClass(deep, 'flex-1'),
114
+ false,
115
+ 'depth limit (>6) prevents pathological deep scans from finding deeply-nested matches',
116
+ );
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // treeHasPortalWithFullHeight — portal anchor detection
121
+ // ---------------------------------------------------------------------------
122
+
123
+ // Portal with h-full + descendant flex-1 → true (Sheet content shape)
124
+ {
125
+ const tree = el({}, [
126
+ el(
127
+ { __fromPortal: true, className: 'h-full' },
128
+ [el({ className: 'flex-1' })],
129
+ ),
130
+ ]);
131
+ assert.equal(
132
+ treeHasPortalWithFullHeight(tree),
133
+ true,
134
+ 'portal subtree with h-full + descendant flex-1 must trigger drawer-height pattern',
135
+ );
136
+ }
137
+
138
+ // inset-y-0 also qualifies (sheet content variant)
139
+ {
140
+ const tree = el({}, [
141
+ el(
142
+ { __fromPortal: true, className: 'inset-y-0 absolute' },
143
+ [el({ className: 'flex-1' })],
144
+ ),
145
+ ]);
146
+ assert.equal(
147
+ treeHasPortalWithFullHeight(tree),
148
+ true,
149
+ 'portal subtree with inset-y-0 + descendant flex-1 also triggers drawer-height',
150
+ );
151
+ }
152
+
153
+ // Portal without flex-1 descendant → false (no grow cascade to fill)
154
+ {
155
+ const tree = el({}, [
156
+ el({ __fromPortal: true, className: 'h-full' }, [el({ className: 'p-4' })]),
157
+ ]);
158
+ assert.equal(
159
+ treeHasPortalWithFullHeight(tree),
160
+ false,
161
+ 'portal without descendant flex-1 must not trigger drawer-height — nothing would fill',
162
+ );
163
+ }
164
+
165
+ // Portal without h-full → false (no anchor intent)
166
+ {
167
+ const tree = el({}, [
168
+ el({ __fromPortal: true, className: 'p-4' }, [el({ className: 'flex-1' })]),
169
+ ]);
170
+ assert.equal(
171
+ treeHasPortalWithFullHeight(tree),
172
+ false,
173
+ 'portal without h-full/h-screen/inset-y-0 is not a viewport anchor',
174
+ );
175
+ }
176
+
177
+ // Non-portal h-full + flex-1 → false (this case is handled by the spine-h-full
178
+ // path in detectViewportHeightPattern, not the portal path)
179
+ {
180
+ const tree = el({ className: 'h-full' }, [el({ className: 'flex-1' })]);
181
+ assert.equal(
182
+ treeHasPortalWithFullHeight(tree),
183
+ false,
184
+ '__fromPortal must be true — spine h-full is a separate signal path',
185
+ );
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // detectViewportHeightPattern — bucket selection
190
+ // ---------------------------------------------------------------------------
191
+
192
+ // h-screen on the spine → FULL_PAGE (900).
193
+ assert.equal(
194
+ detectViewportHeightPattern(story(el({ className: 'h-screen' })), []),
195
+ VIEWPORT_HEIGHTS.FULL_PAGE,
196
+ 'h-screen on the spine triggers FULL_PAGE (900)',
197
+ );
198
+
199
+ // min-h-screen on the spine → FULL_PAGE.
200
+ assert.equal(
201
+ detectViewportHeightPattern(story(el({ className: 'min-h-screen' })), []),
202
+ VIEWPORT_HEIGHTS.FULL_PAGE,
203
+ 'min-h-screen on the spine triggers FULL_PAGE',
204
+ );
205
+
206
+ // h-full + flex-col on spine + descendant flex-1 → DRAWER (700).
207
+ {
208
+ const tree = el({ className: 'h-full flex flex-col' }, [
209
+ el({ className: 'h-12' }),
210
+ el({ className: 'flex-1' }),
211
+ ]);
212
+ assert.equal(
213
+ detectViewportHeightPattern(story(tree), []),
214
+ VIEWPORT_HEIGHTS.DRAWER,
215
+ 'h-full + flex-col + descendant flex-1 triggers DRAWER (700)',
216
+ );
217
+ }
218
+
219
+ // h-full alone without flex-col → null (not a drawer)
220
+ assert.equal(
221
+ detectViewportHeightPattern(
222
+ story(el({ className: 'h-full' }, [el({ className: 'flex-1' })])),
223
+ [],
224
+ ),
225
+ null,
226
+ 'h-full without flex-col on the spine does not trigger drawer height — needs the vertical-flex commitment',
227
+ );
228
+
229
+ // Layout classes (decorator wrapper) also contribute to spine detection.
230
+ assert.equal(
231
+ detectViewportHeightPattern(story(el({})), ['h-screen']),
232
+ VIEWPORT_HEIGHTS.FULL_PAGE,
233
+ 'layoutClasses from the story decorator wrapper feed into spine detection',
234
+ );
235
+
236
+ // No viewport anchor → null.
237
+ assert.equal(
238
+ detectViewportHeightPattern(story(el({ className: 'p-4' })), []),
239
+ null,
240
+ 'plain story without viewport-anchor classes returns null — natural content height',
241
+ );
242
+
243
+ // Null jsxTree → null without throw.
244
+ assert.equal(
245
+ detectViewportHeightPattern(story(null), []),
246
+ null,
247
+ 'null tree is tolerated (no jsxTree from scanner) — returns null',
248
+ );
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // mobileOnlyRootWidth — {bp}:hidden detection
252
+ // ---------------------------------------------------------------------------
253
+
254
+ // md:hidden on the root → mobile width
255
+ assert.equal(
256
+ mobileOnlyRootWidth(story(el({ className: 'md:hidden flex' })), []),
257
+ MOBILE_ONLY_DEFAULT_WIDTH,
258
+ 'md:hidden on the root signals mobile-only design',
259
+ );
260
+
261
+ // sm/lg/xl/2xl:hidden all qualify
262
+ for (const bp of ['sm', 'lg', 'xl', '2xl'] as const) {
263
+ assert.equal(
264
+ mobileOnlyRootWidth(story(el({ className: bp + ':hidden' })), []),
265
+ MOBILE_ONLY_DEFAULT_WIDTH,
266
+ `${bp}:hidden on the root signals mobile-only design`,
267
+ );
268
+ }
269
+
270
+ // Layout classes alone qualify (decorator-only mobile-only design)
271
+ assert.equal(
272
+ mobileOnlyRootWidth(story(el({})), ['md:hidden']),
273
+ MOBILE_ONLY_DEFAULT_WIDTH,
274
+ 'mobile-only signal can come from layoutClasses too',
275
+ );
276
+
277
+ // No hidden class → null
278
+ assert.equal(
279
+ mobileOnlyRootWidth(story(el({ className: 'flex' })), []),
280
+ null,
281
+ 'no breakpoint-hidden class returns null — desktop default applies',
282
+ );
283
+
284
+ // `hidden` alone (no breakpoint) → null (that's a full hide, not mobile-only)
285
+ assert.equal(
286
+ mobileOnlyRootWidth(story(el({ className: 'hidden' })), []),
287
+ null,
288
+ 'plain `hidden` (no breakpoint prefix) is not a mobile-only signal',
289
+ );
290
+
291
+ // Null tree but layoutClasses present → still works
292
+ assert.equal(
293
+ mobileOnlyRootWidth(story(null), ['xl:hidden']),
294
+ MOBILE_ONLY_DEFAULT_WIDTH,
295
+ 'null tree falls back to layoutClasses scan',
296
+ );
297
+
298
+ console.log('story-dimensioning-regression: PASS');
@@ -0,0 +1,205 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ INSTANCE_FALLBACK_COMPONENTS,
4
+ exceedsStoryJsxComplexityLimits,
5
+ shouldPreferInstanceRendering,
6
+ shouldSkipStoryJsxTree,
7
+ } from '../src/design-system/story-render-strategy';
8
+ import type { JsxNode } from '../src/tailwind';
9
+ import type { ComponentDef, ComponentStory } from '../src/components';
10
+
11
+ /**
12
+ * Regression: the story-render-strategy module decides whether each story
13
+ * renders as a tree (full JSX expansion) or as a symbol instance. Three
14
+ * decision points:
15
+ *
16
+ * - `exceedsStoryJsxComplexityLimits` — the budget guard (320 nodes / 36
17
+ * depth / 1800 class tokens). Blowing one of these silently in a refactor
18
+ * would make the plugin chew on pathological trees and freeze Figma.
19
+ * - `shouldSkipStoryJsxTree` — combines the budget guard with the explicit
20
+ * fallback list (currently just `switch`).
21
+ * - `shouldPreferInstanceRendering` — CVA / state matrix preference.
22
+ *
23
+ * Extracted from `src/design-system/story-builder.ts` into
24
+ * `src/design-system/story-render-strategy.ts`.
25
+ */
26
+
27
+ function el(props: Record<string, unknown> = {}, children: JsxNode[] = []): JsxNode {
28
+ return { type: 'element', tagName: 'div', props, children } as unknown as JsxNode;
29
+ }
30
+
31
+ function story(jsxTree: JsxNode | null, instances?: unknown[]): ComponentStory {
32
+ return { name: 'TestStory', jsxTree, instances: instances ?? [] } as unknown as ComponentStory;
33
+ }
34
+
35
+ function def(overrides: Partial<ComponentDef> & { name: string }): ComponentDef {
36
+ return { type: 'simple', stories: [], ...overrides } as ComponentDef;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // INSTANCE_FALLBACK_COMPONENTS — the explicit fallback list
41
+ // ---------------------------------------------------------------------------
42
+
43
+ assert.equal(
44
+ INSTANCE_FALLBACK_COMPONENTS.has('switch'),
45
+ true,
46
+ 'switch must be on the fallback list — its indicator slot is hard to reconstruct from a JSX tree',
47
+ );
48
+
49
+ // Membership is canonical (no other accidental entries that would change rendering for unrelated components).
50
+ assert.equal(
51
+ INSTANCE_FALLBACK_COMPONENTS.size,
52
+ 1,
53
+ 'fallback list size — adding a new entry is an intentional decision; bump this assertion alongside the addition',
54
+ );
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // exceedsStoryJsxComplexityLimits — node / depth / class-token limits
58
+ // ---------------------------------------------------------------------------
59
+
60
+ // Null / undefined / non-tree inputs return false (defensive).
61
+ assert.equal(exceedsStoryJsxComplexityLimits(null), false, 'null tree is under budget');
62
+ assert.equal(exceedsStoryJsxComplexityLimits(undefined), false, 'undefined tree is under budget');
63
+
64
+ // Simple small tree → under budget.
65
+ {
66
+ const tree = el({}, [el({ className: 'p-4' }), el({ className: 'text-sm' })]);
67
+ assert.equal(
68
+ exceedsStoryJsxComplexityLimits(tree),
69
+ false,
70
+ 'a 3-node tree with 2 class tokens is well under budget',
71
+ );
72
+ }
73
+
74
+ // Tree with >320 nodes → exceeds budget.
75
+ {
76
+ const root = el({}, []);
77
+ const rootChildren = (root as unknown as { children: JsxNode[] }).children;
78
+ for (let i = 0; i < 400; i++) rootChildren.push(el({ className: 'x' }));
79
+ assert.equal(
80
+ exceedsStoryJsxComplexityLimits(root),
81
+ true,
82
+ 'tree with 400+ nodes exceeds STORY_JSX_NODE_LIMIT (320)',
83
+ );
84
+ }
85
+
86
+ // Tree depth >36 → exceeds budget.
87
+ {
88
+ let deep: JsxNode = el({});
89
+ for (let i = 0; i < 40; i++) deep = el({}, [deep]);
90
+ assert.equal(
91
+ exceedsStoryJsxComplexityLimits(deep),
92
+ true,
93
+ 'tree depth >36 exceeds STORY_JSX_DEPTH_LIMIT',
94
+ );
95
+ }
96
+
97
+ // Tree with >1800 class tokens → exceeds budget.
98
+ {
99
+ // 5 children each with a className containing ~400 tokens → 2000 total tokens.
100
+ const wideClass = Array.from({ length: 400 }, (_, i) => `c${i}`).join(' ');
101
+ const root = el({ className: 'root' }, [
102
+ el({ className: wideClass }),
103
+ el({ className: wideClass }),
104
+ el({ className: wideClass }),
105
+ el({ className: wideClass }),
106
+ el({ className: wideClass }),
107
+ ]);
108
+ assert.equal(
109
+ exceedsStoryJsxComplexityLimits(root),
110
+ true,
111
+ 'tree with >1800 class tokens exceeds STORY_JSX_CLASS_TOKEN_LIMIT',
112
+ );
113
+ }
114
+
115
+ // Modest tree right at the edge → under budget.
116
+ {
117
+ // 100 children with 10 class tokens each = 1000 class tokens — well under 1800.
118
+ const root = el({}, []);
119
+ const rootChildren = (root as unknown as { children: JsxNode[] }).children;
120
+ for (let i = 0; i < 100; i++) {
121
+ rootChildren.push(el({ className: 'a b c d e f g h i j' }));
122
+ }
123
+ assert.equal(
124
+ exceedsStoryJsxComplexityLimits(root),
125
+ false,
126
+ 'a 101-node tree with 1000 class tokens is under all three limits',
127
+ );
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // shouldSkipStoryJsxTree — fallback list + complexity guard combined
132
+ // ---------------------------------------------------------------------------
133
+
134
+ // No tree → no skip needed (the tree path won't run anyway).
135
+ assert.equal(
136
+ shouldSkipStoryJsxTree(def({ name: 'Button' }), story(null)),
137
+ false,
138
+ 'no jsxTree → no skip decision needed',
139
+ );
140
+
141
+ // Tree present, def on fallback list → skip.
142
+ assert.equal(
143
+ shouldSkipStoryJsxTree(def({ name: 'switch' }), story(el({}))),
144
+ true,
145
+ 'fallback-list def with tree must skip tree rendering',
146
+ );
147
+
148
+ // Tree present, simple component, small tree → do not skip.
149
+ assert.equal(
150
+ shouldSkipStoryJsxTree(def({ name: 'Button' }), story(el({ className: 'p-4' }))),
151
+ false,
152
+ 'simple component with small tree must NOT skip — tree rendering wins for fidelity',
153
+ );
154
+
155
+ // Tree present, simple component, oversized tree → skip due to complexity.
156
+ {
157
+ const root = el({}, []);
158
+ const rootChildren = (root as unknown as { children: JsxNode[] }).children;
159
+ for (let i = 0; i < 400; i++) rootChildren.push(el({}));
160
+ assert.equal(
161
+ shouldSkipStoryJsxTree(def({ name: 'Button' }), story(root)),
162
+ true,
163
+ 'oversized tree must skip even for normal components',
164
+ );
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // shouldPreferInstanceRendering — CVA / state matrix preference
169
+ // ---------------------------------------------------------------------------
170
+
171
+ // No def or no story → false (defensive).
172
+ assert.equal(
173
+ shouldPreferInstanceRendering(null as unknown as ComponentDef, story(null)),
174
+ false,
175
+ 'null def returns false defensively',
176
+ );
177
+
178
+ // Empty defName → false.
179
+ assert.equal(
180
+ shouldPreferInstanceRendering(def({ name: '' }), story(null)),
181
+ false,
182
+ 'def without name returns false',
183
+ );
184
+
185
+ // CVA component with no matching instance → false (nothing to render as instance).
186
+ assert.equal(
187
+ shouldPreferInstanceRendering(
188
+ def({ name: 'Button', type: 'cva' }),
189
+ story(null, [{ componentName: 'OtherComponent' }]),
190
+ ),
191
+ false,
192
+ 'CVA def whose story has no matching instance returns false (no instance to render)',
193
+ );
194
+
195
+ // Switch is on the fallback list — even with matching instance, preference is false (uses different path).
196
+ assert.equal(
197
+ shouldPreferInstanceRendering(
198
+ def({ name: 'switch', type: 'state' }),
199
+ story(null, [{ componentName: 'switch', props: {} }]),
200
+ ),
201
+ false,
202
+ 'switch is on the fallback list and should never prefer instance rendering — uses its own path',
203
+ );
204
+
205
+ console.log('story-render-strategy-regression: PASS');
@@ -0,0 +1,147 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { shouldStretchToParentWidth } from '../src/layout/width-solver';
4
+
5
+ /**
6
+ * Regression: `shouldStretchToParentWidth(tag, classes)` decides whether a
7
+ * block-level element should layoutAlign=STRETCH inside a VERTICAL parent
8
+ * — the rule that turns a `<section>` into a full-width hero banner.
9
+ *
10
+ * The bug class: every "explicit width" predicate in the plugin must agree
11
+ * on what counts as explicit. `hasExplicitSize` understood `size-N` /
12
+ * `size-full`. `shouldStretchToParentWidth` didn't — so a `<div
13
+ * className="size-10 rounded-full">` (the shadcn Avatar.Root) got
14
+ * STRETCH'd to 900px-wide-pill inside a VERTICAL story root, while the
15
+ * generic-element depth=0 override correctly left it alone. Two
16
+ * independent predicates, two different answers, one broken Avatar.
17
+ *
18
+ * This file locks the predicate so any future drift triggers a fixture
19
+ * failure instead of a Figma rendering regression.
20
+ */
21
+
22
+ // Block tag with no width signal → STRETCH (default block behaviour).
23
+ assert.equal(
24
+ shouldStretchToParentWidth('div', ['flex', 'gap-4']),
25
+ true,
26
+ 'plain div with layout classes → stretches to parent width',
27
+ );
28
+ assert.equal(
29
+ shouldStretchToParentWidth('section', ['bg-card']),
30
+ true,
31
+ 'plain section → stretches',
32
+ );
33
+
34
+ // Inline tags — never stretch.
35
+ assert.equal(
36
+ shouldStretchToParentWidth('span', ['flex']),
37
+ false,
38
+ 'span (non-BLOCK_TAG) does not stretch',
39
+ );
40
+
41
+ // Out-of-flow positioning — never stretch.
42
+ assert.equal(
43
+ shouldStretchToParentWidth('div', ['absolute', 'inset-0']),
44
+ false,
45
+ 'absolute-positioned div does not stretch',
46
+ );
47
+
48
+ // Explicit width — never stretch.
49
+ assert.equal(
50
+ shouldStretchToParentWidth('div', ['w-10']),
51
+ false,
52
+ 'w-N suppresses stretch',
53
+ );
54
+ assert.equal(
55
+ shouldStretchToParentWidth('div', ['w-[200px]']),
56
+ false,
57
+ 'arbitrary w-[N] suppresses stretch',
58
+ );
59
+ assert.equal(
60
+ shouldStretchToParentWidth('div', ['max-w-md']),
61
+ false,
62
+ 'max-w-* suppresses stretch (caps width)',
63
+ );
64
+ assert.equal(
65
+ shouldStretchToParentWidth('div', ['min-w-0']),
66
+ false,
67
+ 'min-w-* suppresses stretch',
68
+ );
69
+
70
+ // self-* — never stretch (explicit alignment trumps).
71
+ assert.equal(
72
+ shouldStretchToParentWidth('div', ['self-start']),
73
+ false,
74
+ 'self-* alignment suppresses stretch',
75
+ );
76
+
77
+ // size-N (the regression case) — must NOT stretch. This used to slip
78
+ // through because the bailout only checked `w-*`. Result: shadcn
79
+ // `<Avatar className="size-10 ...">` rendered as a 900-wide pill at
80
+ // story root.
81
+ assert.equal(
82
+ shouldStretchToParentWidth('div', ['relative', 'flex', 'size-10', 'shrink-0', 'overflow-hidden', 'rounded-full']),
83
+ false,
84
+ 'size-N (Avatar.Root pattern) must suppress stretch — was the FallbackOnly bug',
85
+ );
86
+ assert.equal(
87
+ shouldStretchToParentWidth('div', ['size-8']),
88
+ false,
89
+ 'size-8 alone suppresses stretch',
90
+ );
91
+ assert.equal(
92
+ shouldStretchToParentWidth('div', ['size-[40px]']),
93
+ false,
94
+ 'arbitrary size-[N] suppresses stretch',
95
+ );
96
+
97
+ // Inline displays — never stretch.
98
+ assert.equal(
99
+ shouldStretchToParentWidth('div', ['inline-flex']),
100
+ false,
101
+ 'inline-flex suppresses stretch',
102
+ );
103
+
104
+ // Note: `size-full` is normalised by `splitClassName` into `w-full h-full`
105
+ // before it reaches this predicate, so it never appears here as a token.
106
+ // Its suppression is exercised via the `w-full` case below.
107
+ assert.equal(
108
+ shouldStretchToParentWidth('div', ['w-full', 'h-full']),
109
+ false,
110
+ 'w-full suppresses stretch (the post-normalisation form of size-full)',
111
+ );
112
+
113
+ // DropdownMenuItem class shape: scanner emits these wrappers as
114
+ // `kind: 'component'` with tagName `DropdownMenuPrimitive.Item`. The
115
+ // ui-builder wrapperFrame branch calls `shouldStretchToParentWidth('div',
116
+ // classes)` to mirror the kind:'element' decision — without it, items
117
+ // inside a `w-56` Content stay HUG-width. Lock the predicate so the call
118
+ // site keeps producing true for the canonical Item / Label class lists.
119
+ assert.equal(
120
+ shouldStretchToParentWidth('div', [
121
+ 'focus:bg-accent', 'focus:text-accent-foreground',
122
+ 'relative', 'flex', 'cursor-default', 'items-center', 'gap-2',
123
+ 'rounded-sm', 'px-2', 'py-1.5', 'text-sm',
124
+ 'data-[disabled]:opacity-50', 'data-[inset]:pl-8',
125
+ ]),
126
+ true,
127
+ 'DropdownMenuItem class list → stretches in vertical block-flow parent',
128
+ );
129
+ assert.equal(
130
+ shouldStretchToParentWidth('div', [
131
+ 'px-2', 'py-1.5', 'text-sm', 'font-medium', 'data-[inset]:pl-8',
132
+ ]),
133
+ true,
134
+ 'DropdownMenuLabel class list → stretches in vertical block-flow parent',
135
+ );
136
+ // DropdownMenuContent itself has `min-w-[8rem]` which IS a width signal —
137
+ // so Content does NOT stretch (the consumer's `w-56` sets its width
138
+ // instead). Lock that boundary.
139
+ assert.equal(
140
+ shouldStretchToParentWidth('div', [
141
+ 'bg-popover', 'min-w-[8rem]', 'rounded-md', 'border', 'p-1', 'shadow-md',
142
+ ]),
143
+ false,
144
+ 'DropdownMenuContent (min-w-* present) → does NOT stretch — consumer width wins',
145
+ );
146
+
147
+ console.log('stretch-to-parent-width-regression: PASS (17 cases)');