inkbridge 0.1.0-beta.21 → 0.1.0-beta.23

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 (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -0,0 +1,212 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
4
+ import { ComponentScanner } from './component-scanner';
5
+
6
+ (globalThis as unknown as { figma: unknown }).figma = {
7
+ notify: () => undefined,
8
+ showUI: () => undefined,
9
+ };
10
+
11
+ import { shouldRenderResponsiveForStory } from '../src/design-system/preview-builder';
12
+
13
+ /**
14
+ * Locks in the responsive-frame opt-in / opt-out flow:
15
+ *
16
+ * 1. **Scanner side** — `parameters.inkbridge.responsive` on a story
17
+ * export round-trips to `StoryInfo.responsive` (`true`, `false`, or
18
+ * omitted). The scanner reads it via the ObjectLiteralExpression
19
+ * property chain `parameters → inkbridge → responsive`.
20
+ *
21
+ * 2. **Plugin side** — `shouldRenderResponsiveForStory(def, story,
22
+ * storyIndex)` enforces the policy:
23
+ * - explicit `story.responsive === true` → always render
24
+ * - explicit `story.responsive === false` → never render
25
+ * - unset → render only for the canonical Default story (name
26
+ * contains "default") or, if no Default story exists, only for
27
+ * the first story (storyIndex === 0).
28
+ *
29
+ * Together this lets consumers cut frame-explosion: variant stories
30
+ * like `Loading` / `WithError` get a single non-responsive frame by
31
+ * default, and any story can opt in/out via Storybook parameters.
32
+ */
33
+
34
+ interface StoryShape {
35
+ name: string;
36
+ responsive?: boolean;
37
+ }
38
+
39
+ interface ScannedShape {
40
+ name: string;
41
+ stories?: StoryShape[];
42
+ }
43
+
44
+ const FIXTURE_DIR = path.resolve(
45
+ process.cwd(),
46
+ 'tools/figma-plugin/scanner/__fixtures__/responsive-opt-in'
47
+ );
48
+
49
+ function writeFixtures(): void {
50
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
51
+ fs.writeFileSync(
52
+ path.join(FIXTURE_DIR, 'Card.tsx'),
53
+ `
54
+ export function Card({ label }: { label: string }) {
55
+ return <div className="flex flex-col gap-2 sm:flex-row md:gap-4">{label}</div>;
56
+ }
57
+ `,
58
+ 'utf-8'
59
+ );
60
+ fs.writeFileSync(
61
+ path.join(FIXTURE_DIR, 'Card.stories.tsx'),
62
+ `
63
+ import { Card } from "./Card";
64
+ const meta = { component: Card };
65
+ export default meta;
66
+
67
+ // No parameters — falls through to default policy.
68
+ export const Default = {
69
+ args: { label: "default" },
70
+ };
71
+
72
+ // Explicit opt-in via parameters.inkbridge.responsive = true.
73
+ export const WithItems = {
74
+ args: { label: "with items" },
75
+ parameters: { inkbridge: { responsive: true } },
76
+ };
77
+
78
+ // Explicit opt-out (suppress even if Default).
79
+ export const Suppressed = {
80
+ args: { label: "suppressed" },
81
+ parameters: { inkbridge: { responsive: false } },
82
+ };
83
+
84
+ // No flag, not Default — should not render responsive by default.
85
+ export const Loading = {
86
+ args: { label: "loading" },
87
+ };
88
+ `,
89
+ 'utf-8'
90
+ );
91
+ }
92
+
93
+ function cleanupFixtures(): void {
94
+ try { fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
95
+ }
96
+
97
+ async function run(): Promise<void> {
98
+ writeFixtures();
99
+
100
+ const scanner = new ComponentScanner({
101
+ componentPaths: [FIXTURE_DIR],
102
+ filePattern: '*.tsx',
103
+ exclude: [],
104
+ });
105
+ const results = (await scanner.scanAll()) as unknown as ScannedShape[];
106
+ const card = results.find((r) => r.name === 'Card');
107
+ assert.ok(card, `Card must be scanned; got: ${results.map((r) => r.name).join(', ')}`);
108
+ const stories = card.stories || [];
109
+ const byName = (n: string) => stories.find((s) => s.name === n);
110
+
111
+ // ---------------------------------------------------------------------
112
+ // Scanner round-trip
113
+ // ---------------------------------------------------------------------
114
+ {
115
+ const def = byName('Default');
116
+ assert.ok(def, 'Default story must exist');
117
+ assert.equal(def.responsive, undefined, 'Default: parameters absent → responsive undefined');
118
+ }
119
+ {
120
+ const wi = byName('WithItems');
121
+ assert.ok(wi, 'WithItems story must exist');
122
+ assert.equal(wi.responsive, true, 'WithItems: parameters.inkbridge.responsive=true → true');
123
+ }
124
+ {
125
+ const sup = byName('Suppressed');
126
+ assert.ok(sup, 'Suppressed story must exist');
127
+ assert.equal(sup.responsive, false, 'Suppressed: parameters.inkbridge.responsive=false → false');
128
+ }
129
+ {
130
+ const loading = byName('Loading');
131
+ assert.ok(loading, 'Loading story must exist');
132
+ assert.equal(loading.responsive, undefined, 'Loading: parameters absent → undefined');
133
+ }
134
+
135
+ // ---------------------------------------------------------------------
136
+ // Gate helper — uses the scanned StoryInfo shape directly.
137
+ // ---------------------------------------------------------------------
138
+ // Reuse the same def from the scanner so we test against the real
139
+ // shape, not a hand-rolled mock.
140
+ const def = card;
141
+ const get = (name: string) => stories.findIndex((s) => s.name === name);
142
+
143
+ assert.equal(
144
+ shouldRenderResponsiveForStory(def, byName('Default'), get('Default')),
145
+ true,
146
+ 'Default story: no flag, name=Default → render responsive',
147
+ );
148
+ assert.equal(
149
+ shouldRenderResponsiveForStory(def, byName('WithItems'), get('WithItems')),
150
+ true,
151
+ 'WithItems: explicit responsive=true → render',
152
+ );
153
+ assert.equal(
154
+ shouldRenderResponsiveForStory(def, byName('Suppressed'), get('Suppressed')),
155
+ false,
156
+ 'Suppressed: explicit responsive=false → suppress even if it would otherwise render',
157
+ );
158
+ assert.equal(
159
+ shouldRenderResponsiveForStory(def, byName('Loading'), get('Loading')),
160
+ false,
161
+ 'Loading: no flag, not Default, Default exists in sibling stories → suppress',
162
+ );
163
+
164
+ // ---------------------------------------------------------------------
165
+ // First-story fallback when no Default story exists.
166
+ // Synthetic def: two stories named X, Y. Y has no flag, X is first.
167
+ // ---------------------------------------------------------------------
168
+ {
169
+ const syntheticDef = { stories: [{ name: 'X' }, { name: 'Y' }] };
170
+ assert.equal(
171
+ shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[0], 0),
172
+ true,
173
+ 'first story (no Default sibling): renders responsive',
174
+ );
175
+ assert.equal(
176
+ shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[1], 1),
177
+ false,
178
+ 'non-first story (no Default sibling): suppress',
179
+ );
180
+ }
181
+
182
+ // ---------------------------------------------------------------------
183
+ // Explicit flag wins over policy in both directions.
184
+ // ---------------------------------------------------------------------
185
+ {
186
+ const syntheticDef = {
187
+ stories: [
188
+ { name: 'Default', responsive: false },
189
+ { name: 'Variant', responsive: true },
190
+ ],
191
+ };
192
+ assert.equal(
193
+ shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[0], 0),
194
+ false,
195
+ 'Default with explicit false: suppress (opt-out wins over name-based default)',
196
+ );
197
+ assert.equal(
198
+ shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[1], 1),
199
+ true,
200
+ 'Variant with explicit true: render (opt-in wins over default policy)',
201
+ );
202
+ }
203
+
204
+ cleanupFixtures();
205
+ console.log('responsive-opt-in-regression: PASS (scanner round-trip + gate helper)');
206
+ }
207
+
208
+ run().catch((err) => {
209
+ cleanupFixtures();
210
+ console.error(err);
211
+ process.exit(1);
212
+ });
@@ -0,0 +1,314 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: `<Select value="x">` / `<SelectPrimitive.Root>` wrappers
7
+ * with no className are flattened away by the scanner. The wrapper's
8
+ * context-providing props (`value`, `defaultValue`, `open`,
9
+ * `defaultOpen`) become `__selectRoot*` data props on the hoisted
10
+ * children. The matching `<SelectItem value="x">`'s label is baked
11
+ * into `__selectRootSelectedLabel` at scan time so the renderer
12
+ * doesn't have to walk the original tree shape.
13
+ *
14
+ * History: shadcn `Select` is `export const Select = SelectPrimitive.Root`
15
+ * — a Radix logical context provider with no DOM in the browser. The
16
+ * plugin used to create a hugging wrapper frame around the Trigger,
17
+ * which trapped `<SelectTrigger className="w-full">` at the trigger's
18
+ * own content width inside a `sm:grid-cols-2` form column. The earlier
19
+ * `layoutAlign = STRETCH` patch fired in the wrong pipeline phase
20
+ * (the OUTER `renderChildren` loop) because the kind-component path
21
+ * never reached the OUTER loop — `buildFigmaNode` always created the
22
+ * wrapper frame itself.
23
+ *
24
+ * Universal fix: flatten the wrapper at SCAN time, mirroring the
25
+ * `__fromPortal` annotation pattern. Once the JSX tree no longer
26
+ * carries a Select Root node, every render path automatically treats
27
+ * the Trigger/Content as direct siblings of the form fields, so the
28
+ * full-width cascade works without special-casing the Radix wrapper.
29
+ */
30
+
31
+ interface TestScannerView {
32
+ project: import('ts-morph').Project;
33
+ extractComponentJsxTree: (
34
+ sourceFile: import('ts-morph').SourceFile,
35
+ componentName: string,
36
+ ) => unknown;
37
+ transformJsxTree: (tree: unknown) => unknown;
38
+ }
39
+
40
+ function makeScanner(): TestScannerView {
41
+ return new ComponentScanner({
42
+ componentPaths: [],
43
+ filePattern: '*.tsx',
44
+ exclude: [],
45
+ }) as unknown as TestScannerView;
46
+ }
47
+
48
+ interface NodeLike {
49
+ type: 'element' | 'text';
50
+ tagName?: string;
51
+ isComponent?: boolean;
52
+ props?: Record<string, string>;
53
+ children?: NodeLike[];
54
+ content?: string;
55
+ }
56
+
57
+ function element(
58
+ tagName: string,
59
+ props: Record<string, string> = {},
60
+ children: NodeLike[] = [],
61
+ ): NodeLike {
62
+ return {
63
+ type: 'element',
64
+ tagName,
65
+ isComponent: /^[A-Z]/.test(tagName),
66
+ props,
67
+ children,
68
+ };
69
+ }
70
+
71
+ function text(content: string): NodeLike {
72
+ return { type: 'text', content };
73
+ }
74
+
75
+ const scanner = makeScanner();
76
+ const transform = (tree: NodeLike): NodeLike =>
77
+ scanner.transformJsxTree(tree as unknown) as NodeLike;
78
+
79
+ function findElements(node: NodeLike, tagName: string, out: NodeLike[] = []): NodeLike[] {
80
+ if (!node || node.type !== 'element') return out;
81
+ if (node.tagName === tagName) out.push(node);
82
+ for (const child of node.children || []) findElements(child, tagName, out);
83
+ return out;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Case 1: <Select value="usdc"> wrapping Trigger + Content with matching item
88
+ // ---------------------------------------------------------------------------
89
+
90
+ {
91
+ const tree = element('div', { className: 'space-y-2' }, [
92
+ element('Label', {}, [text('Receive collateral in')]),
93
+ element('Select', { value: 'usdc' }, [
94
+ element('SelectTrigger', { id: 'collateral' }, [
95
+ element('SelectValue', { placeholder: 'Choose' }),
96
+ ]),
97
+ element('SelectContent', {}, [
98
+ element('SelectItem', { value: 'usdc' }, [text('USDC')]),
99
+ element('SelectItem', { value: 'sol' }, [text('SOL')]),
100
+ ]),
101
+ ]),
102
+ ]);
103
+
104
+ const result = transform(tree);
105
+
106
+ // Wrapper is gone — Trigger and Content are direct children of the parent div.
107
+ assert.equal(
108
+ (result.children || []).length,
109
+ 3,
110
+ 'Label + Trigger + Content become direct children (3 total)',
111
+ );
112
+ const tags = (result.children || []).map(c => c.tagName);
113
+ assert.deepEqual(
114
+ tags,
115
+ ['Label', 'SelectTrigger', 'SelectContent'],
116
+ 'Hoisted children preserve their original order',
117
+ );
118
+
119
+ const triggers = findElements(result, 'SelectTrigger');
120
+ assert.equal(triggers.length, 1, 'exactly one Trigger after hoist');
121
+ assert.equal(
122
+ triggers[0].props?.__selectRootSelectedValue,
123
+ 'usdc',
124
+ 'Trigger carries the Select value as __selectRootSelectedValue',
125
+ );
126
+ assert.equal(
127
+ triggers[0].props?.__selectRootSelectedLabel,
128
+ 'USDC',
129
+ 'Trigger carries the matched item label so SelectValue can render it without tree walk',
130
+ );
131
+ assert.equal(
132
+ triggers[0].props?.id,
133
+ 'collateral',
134
+ 'Original Trigger props (id) survive the merge',
135
+ );
136
+
137
+ const contents = findElements(result, 'SelectContent');
138
+ assert.equal(contents.length, 1, 'exactly one Content after hoist');
139
+ assert.equal(
140
+ contents[0].props?.__selectRootSelectedValue,
141
+ 'usdc',
142
+ 'Content carries the same __selectRootSelectedValue',
143
+ );
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Case 2: defaultValue, defaultOpen, and the highlighted-first fallback
148
+ // ---------------------------------------------------------------------------
149
+
150
+ {
151
+ const tree = element('div', {}, [
152
+ element('Select', { defaultOpen: 'true' }, [
153
+ element('SelectTrigger'),
154
+ element('SelectContent', {}, [
155
+ element('SelectItem', { value: 'first' }, [text('First option')]),
156
+ element('SelectItem', { value: 'second' }, [text('Second option')]),
157
+ ]),
158
+ ]),
159
+ ]);
160
+
161
+ const result = transform(tree);
162
+ const triggers = findElements(result, 'SelectTrigger');
163
+ assert.equal(triggers[0].props?.__selectRootIsOpen, 'true', 'defaultOpen propagates as isOpen=true');
164
+ assert.equal(
165
+ triggers[0].props?.__selectRootHighlightedValue,
166
+ 'first',
167
+ 'open with no selected value falls back to highlighting the first item',
168
+ );
169
+ assert.equal(
170
+ triggers[0].props?.__selectRootSelectedValue,
171
+ undefined,
172
+ 'no selected value when neither value nor defaultValue is set',
173
+ );
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Case 3: SelectPrimitive.Root flattens the same way as the Select alias
178
+ // ---------------------------------------------------------------------------
179
+
180
+ {
181
+ const tree = element('div', {}, [
182
+ element('SelectPrimitive.Root', { value: 'sol' }, [
183
+ element('SelectPrimitive.Trigger'),
184
+ element('SelectPrimitive.Content', {}, [
185
+ element('SelectPrimitive.Item', { value: 'sol' }, [text('SOL')]),
186
+ ]),
187
+ ]),
188
+ ]);
189
+
190
+ const result = transform(tree);
191
+ assert.equal(
192
+ (result.children || []).map(c => c.tagName).indexOf('SelectPrimitive.Root'),
193
+ -1,
194
+ 'raw SelectPrimitive.Root wrapper is also flattened',
195
+ );
196
+ const triggers = findElements(result, 'SelectPrimitive.Trigger');
197
+ assert.equal(
198
+ triggers[0].props?.__selectRootSelectedLabel,
199
+ 'SOL',
200
+ 'label lookup also matches SelectPrimitive.Item (the raw Radix tag)',
201
+ );
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Case 4: Select with a className is NOT flattened (consumer customised it)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ {
209
+ const tree = element('div', {}, [
210
+ element('Select', { className: 'custom-select', value: 'x' }, [
211
+ element('SelectTrigger'),
212
+ element('SelectContent', {}, []),
213
+ ]),
214
+ ]);
215
+
216
+ const result = transform(tree);
217
+ const selectWrappers = findElements(result, 'Select');
218
+ assert.equal(
219
+ selectWrappers.length,
220
+ 1,
221
+ 'Select with a className is preserved — consumer may rely on the wrapper being a real frame',
222
+ );
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Case 5: Nested transforms — Select inside another flattenable wrapper
227
+ // ---------------------------------------------------------------------------
228
+
229
+ {
230
+ const tree = element('div', {}, [
231
+ element('Select', { value: 'a' }, [
232
+ element('SelectTrigger'),
233
+ element('SelectContent', {}, [
234
+ element('SelectItem', { value: 'a' }, [text('A')]),
235
+ ]),
236
+ ]),
237
+ element('Select', { value: 'b' }, [
238
+ element('SelectTrigger'),
239
+ element('SelectContent', {}, [
240
+ element('SelectItem', { value: 'b' }, [text('B')]),
241
+ ]),
242
+ ]),
243
+ ]);
244
+
245
+ const result = transform(tree);
246
+ // Two Selects → six hoisted children (Trigger + Content × 2).
247
+ assert.equal((result.children || []).length, 4, 'two flattened Selects produce 4 hoisted children');
248
+ const labels = findElements(result, 'SelectTrigger').map(t => t.props?.__selectRootSelectedLabel);
249
+ assert.deepEqual(labels, ['A', 'B'], 'each hoisted set carries its own Select context, not the sibling Select\'s');
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Case 6: shadcn-inlined SelectItem — label lives inside SelectPrimitive.ItemText,
254
+ // NOT as a direct text child. Without the deep-walk the annotation falls back
255
+ // to the raw value (mint address) and the trigger shows "EPjFWdd..." instead
256
+ // of "USDC".
257
+ // ---------------------------------------------------------------------------
258
+
259
+ {
260
+ const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
261
+ const tree = element('div', {}, [
262
+ element('Select', { value: usdcMint }, [
263
+ element('SelectPrimitive.Trigger', {}, [
264
+ element('SelectValue'),
265
+ ]),
266
+ element('SelectPrimitive.Content', {}, [
267
+ // The inlined shadcn shape: Item wraps Indicator + ItemText with the label
268
+ // text living one level deeper than the Item itself.
269
+ element('SelectPrimitive.Item', { value: usdcMint }, [
270
+ element('span', {}, [
271
+ element('SelectPrimitive.ItemIndicator', {}, [
272
+ element('Check'),
273
+ ]),
274
+ ]),
275
+ element('SelectPrimitive.ItemText', {}, [text('USDC')]),
276
+ ]),
277
+ element('SelectPrimitive.Item', { value: 'sol-mint' }, [
278
+ element('SelectPrimitive.ItemText', {}, [text('SOL')]),
279
+ ]),
280
+ ]),
281
+ ]),
282
+ ]);
283
+
284
+ const result = transform(tree);
285
+ const triggers = findElements(result, 'SelectPrimitive.Trigger');
286
+ assert.equal(
287
+ triggers[0].props?.__selectRootSelectedLabel,
288
+ 'USDC',
289
+ 'label is found via deep-walk through SelectPrimitive.ItemText — covers shadcn-inlined items',
290
+ );
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Case 7: text children inside Select pass through unchanged
295
+ // ---------------------------------------------------------------------------
296
+
297
+ {
298
+ const tree = element('div', {}, [
299
+ element('Select', {}, [
300
+ text(' '), // whitespace text node
301
+ element('SelectTrigger'),
302
+ ]),
303
+ ]);
304
+ const result = transform(tree);
305
+ // Whitespace text node hoisted alongside the Trigger
306
+ assert.equal((result.children || []).length, 2, 'text + Trigger both hoisted');
307
+ assert.equal(result.children?.[0].type, 'text', 'text node preserved');
308
+ assert.equal(result.children?.[1].tagName, 'SelectTrigger', 'Trigger preserved');
309
+ }
310
+
311
+ // Avoid "unused" warnings for path / Project.
312
+ void path;
313
+
314
+ console.log('select-root-flatten-regression: PASS');
@@ -0,0 +1,163 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ (globalThis as unknown as { figma: unknown }).figma = {
4
+ notify: () => undefined,
5
+ showUI: () => undefined,
6
+ };
7
+
8
+ /**
9
+ * Regression: a frame authored with `justify-between` that ends up with
10
+ * fewer than 2 in-flow children must collapse its primary alignment
11
+ * back to MIN. Figma's `primaryAxisAlignItems = 'SPACE_BETWEEN'` with a
12
+ * single child renders as "centred" ("Gap: Auto" in the inspector),
13
+ * which is NOT what CSS does — `justify-content: space-between` with
14
+ * one item anchors to start per the spec.
15
+ *
16
+ * Real-world repro: PerpsHeader at the md+ breakpoint —
17
+ * <header className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
18
+ * <div>{title + description}</div>
19
+ * {actions ? <div>{actions}</div> : null}
20
+ * </header>
21
+ * When the consumer doesn't pass `actions`, the conditional renders
22
+ * nothing → only one child remains → the header centred "Jupiter
23
+ * Perps Desk" inside an otherwise-empty 900-wide bar.
24
+ *
25
+ * The post-children demotion in `buildFigmaNode`'s generic-element
26
+ * path is the universal fix: any node count < 2 with SPACE_BETWEEN
27
+ * primary gets MIN. Absolute-positioned children are excluded from
28
+ * the count since they don't participate in flex distribution.
29
+ */
30
+
31
+ interface StubBase {
32
+ type: string;
33
+ layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
34
+ primaryAxisAlignItems: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN';
35
+ layoutPositioning?: 'AUTO' | 'ABSOLUTE';
36
+ width: number;
37
+ height: number;
38
+ children: StubBase[];
39
+ }
40
+
41
+ function mkFrame(overrides?: Partial<StubBase>): StubBase {
42
+ return Object.assign(
43
+ {
44
+ type: 'FRAME',
45
+ layoutMode: 'HORIZONTAL' as const,
46
+ primaryAxisAlignItems: 'SPACE_BETWEEN' as const,
47
+ layoutPositioning: 'AUTO' as const,
48
+ width: 100,
49
+ height: 32,
50
+ children: [],
51
+ },
52
+ overrides || {},
53
+ );
54
+ }
55
+
56
+ // The demotion is a property mutation. Mirror the production logic
57
+ // here so the test exercises the rule against representative shapes
58
+ // without booting the full ui-builder.
59
+ function demoteSpaceBetweenIfSingleChild(frame: StubBase): void {
60
+ if (frame.primaryAxisAlignItems !== 'SPACE_BETWEEN') return;
61
+ const inFlow = frame.children.filter(
62
+ (c) => c.layoutPositioning !== 'ABSOLUTE',
63
+ );
64
+ if (inFlow.length >= 2) return;
65
+ frame.primaryAxisAlignItems = 'MIN';
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Case 1 — single in-flow child: SPACE_BETWEEN → MIN
70
+ // ---------------------------------------------------------------------------
71
+ {
72
+ const frame = mkFrame({
73
+ children: [mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' })],
74
+ });
75
+ demoteSpaceBetweenIfSingleChild(frame);
76
+ assert.equal(
77
+ frame.primaryAxisAlignItems,
78
+ 'MIN',
79
+ 'SPACE_BETWEEN with one in-flow child must demote to MIN',
80
+ );
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Case 2 — zero children: SPACE_BETWEEN → MIN
85
+ // ---------------------------------------------------------------------------
86
+ {
87
+ const frame = mkFrame({ children: [] });
88
+ demoteSpaceBetweenIfSingleChild(frame);
89
+ assert.equal(
90
+ frame.primaryAxisAlignItems,
91
+ 'MIN',
92
+ 'SPACE_BETWEEN with zero children must demote to MIN',
93
+ );
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Case 3 — two in-flow children: keep SPACE_BETWEEN
98
+ // ---------------------------------------------------------------------------
99
+ {
100
+ const frame = mkFrame({
101
+ children: [
102
+ mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' }),
103
+ mkFrame({ width: 100, primaryAxisAlignItems: 'MIN' }),
104
+ ],
105
+ });
106
+ demoteSpaceBetweenIfSingleChild(frame);
107
+ assert.equal(
108
+ frame.primaryAxisAlignItems,
109
+ 'SPACE_BETWEEN',
110
+ 'SPACE_BETWEEN with two children must be preserved',
111
+ );
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Case 4 — one in-flow + one absolute-positioned: demote.
116
+ // Absolutes don't participate in flex distribution, so the lone in-flow
117
+ // child would still be centred by SPACE_BETWEEN.
118
+ // ---------------------------------------------------------------------------
119
+ {
120
+ const frame = mkFrame({
121
+ children: [
122
+ mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' }),
123
+ mkFrame({
124
+ width: 50,
125
+ primaryAxisAlignItems: 'MIN',
126
+ layoutPositioning: 'ABSOLUTE',
127
+ }),
128
+ ],
129
+ });
130
+ demoteSpaceBetweenIfSingleChild(frame);
131
+ assert.equal(
132
+ frame.primaryAxisAlignItems,
133
+ 'MIN',
134
+ 'SPACE_BETWEEN with one in-flow + one absolute child must demote',
135
+ );
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Case 5 — already MIN: no-op
140
+ // ---------------------------------------------------------------------------
141
+ {
142
+ const frame = mkFrame({ primaryAxisAlignItems: 'MIN', children: [] });
143
+ demoteSpaceBetweenIfSingleChild(frame);
144
+ assert.equal(frame.primaryAxisAlignItems, 'MIN', 'MIN stays MIN');
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Case 6 — CENTER alignment with one child: not SPACE_BETWEEN so unchanged.
149
+ // ---------------------------------------------------------------------------
150
+ {
151
+ const frame = mkFrame({
152
+ primaryAxisAlignItems: 'CENTER',
153
+ children: [mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' })],
154
+ });
155
+ demoteSpaceBetweenIfSingleChild(frame);
156
+ assert.equal(
157
+ frame.primaryAxisAlignItems,
158
+ 'CENTER',
159
+ 'CENTER with one child is preserved (only SPACE_BETWEEN is demoted)',
160
+ );
161
+ }
162
+
163
+ console.log('space-between-single-child-regression: ok (6 cases)');