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,125 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ /**
10
+ * Regression: ban every raw `classes.includes('hidden')` /
11
+ * `classes.includes('sr-only')` / `lastIndexOf('hidden')` /
12
+ * `lastIndexOf('sr-only')` site in `src/`.
13
+ *
14
+ * Background: three separate sites historically reimplemented the
15
+ * "is this element hidden?" rule each with subtle drift:
16
+ *
17
+ * 1. `buildFigmaNode`'s outer gate (hand-rolled DISPLAY_OVERRIDES +
18
+ * lastIndexOf + slice — closest to correct).
19
+ * 2. `collectTextContent` (raw `classes.includes('hidden')` — wrong;
20
+ * ignored last-display-wins, returned `''` for a `<p hidden
21
+ * sm:block>` resolved to `['hidden','text-muted-foreground','block']`
22
+ * where `block` is the winning display utility).
23
+ * 3. inline-collapse / renderChildren filters (used
24
+ * `isEffectivelyHidden` correctly but didn't honour sr-only —
25
+ * mostly accidental; we tightened later).
26
+ *
27
+ * Each site needed its own bug-hunt to find. The lesson: route every
28
+ * "is this element hidden / sr-only?" check through the shared
29
+ * `isEffectivelyHiddenOrSrOnly` helper in
30
+ * `src/tailwind/responsive-analyzer.ts`. This fixture mechanically
31
+ * enforces that — adding a new raw site fails CI before it can land.
32
+ *
33
+ * The helper itself (and its narrower `isEffectivelyHidden` companion)
34
+ * IS allowed to reference the literals — that's the source of truth.
35
+ * Allow-list pattern: any file under `src/tailwind/responsive-analyzer.ts`.
36
+ */
37
+
38
+ const SRC_DIR = path.resolve(__dirname, '..', 'src');
39
+ const ALLOWED_FILES = new Set([
40
+ // The shared helper itself. Other allowed files can be added here
41
+ // with a comment explaining why a raw literal check is justified.
42
+ path.resolve(SRC_DIR, 'tailwind', 'responsive-analyzer.ts'),
43
+ ]);
44
+
45
+ // Patterns that indicate a raw, drift-prone check. The shared helper
46
+ // covers ALL of these cases — there's no legitimate reason to write
47
+ // them directly in any other file.
48
+ const BANNED_PATTERNS: Array<{ regex: RegExp; description: string }> = [
49
+ {
50
+ regex: /\.includes\(\s*['"]hidden['"]\s*\)/,
51
+ description: `classes.includes('hidden') — use isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
52
+ },
53
+ {
54
+ regex: /\.includes\(\s*['"]sr-only['"]\s*\)/,
55
+ description: `classes.includes('sr-only') — use isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
56
+ },
57
+ {
58
+ regex: /\.lastIndexOf\(\s*['"]hidden['"]\s*\)/,
59
+ description: `classes.lastIndexOf('hidden') — use isEffectivelyHidden() or isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
60
+ },
61
+ {
62
+ regex: /\.lastIndexOf\(\s*['"]sr-only['"]\s*\)/,
63
+ description: `classes.lastIndexOf('sr-only') — use isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
64
+ },
65
+ ];
66
+
67
+ function walk(dir: string, out: string[]): void {
68
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
69
+ const full = path.join(dir, entry.name);
70
+ if (entry.isDirectory()) {
71
+ walk(full, out);
72
+ continue;
73
+ }
74
+ if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
75
+ out.push(full);
76
+ }
77
+ }
78
+ }
79
+
80
+ const files: string[] = [];
81
+ walk(SRC_DIR, files);
82
+ assert.ok(files.length > 0, 'expected to find .ts files under tools/figma-plugin/src/');
83
+
84
+ type Violation = { file: string; line: number; snippet: string; reason: string };
85
+ const violations: Violation[] = [];
86
+
87
+ for (const file of files) {
88
+ if (ALLOWED_FILES.has(file)) continue;
89
+ const text = fs.readFileSync(file, 'utf-8');
90
+ const lines = text.split('\n');
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const raw = lines[i];
93
+ // Strip line comments — they're commentary, not code that runs.
94
+ const codePortion = raw.replace(/\/\/.*$/, '');
95
+ for (const pat of BANNED_PATTERNS) {
96
+ if (pat.regex.test(codePortion)) {
97
+ violations.push({
98
+ file: path.relative(SRC_DIR, file),
99
+ line: i + 1,
100
+ snippet: raw.trim().slice(0, 120),
101
+ reason: pat.description,
102
+ });
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ if (violations.length > 0) {
109
+ console.error('hidden-check-drift-regression: FAIL');
110
+ console.error(`Found ${violations.length} raw hidden / sr-only check(s) in src/:`);
111
+ for (const v of violations) {
112
+ console.error(` ${v.file}:${v.line}`);
113
+ console.error(` ${v.snippet}`);
114
+ console.error(` ${v.reason}`);
115
+ }
116
+ console.error('');
117
+ console.error('Why this matters:');
118
+ console.error(' These string-literal checks repeatedly drifted from each other.');
119
+ console.error(' The recurring `<p hidden sm:block>` lost-its-text bug existed because');
120
+ console.error(' three separate sites each had their own subtly-wrong "is this hidden?"');
121
+ console.error(' rule. Route every check through `isEffectivelyHiddenOrSrOnly` instead.');
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log(`hidden-check-drift-regression: PASS (scanned ${files.length} files, 0 raw hidden/sr-only checks in src/)`);
@@ -0,0 +1,230 @@
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: `constrainSingleHorizontalTextChild` shrinks the
10
+ * text-bearing child of a HORIZONTAL FIXED-width row when its HUG
11
+ * width would overflow the sibling button's space. The helper
12
+ * mirrors CSS `flex-shrink: 1` — flex items shrink to min-content
13
+ * (text wraps to longest word) when the row would otherwise
14
+ * overflow; Figma auto-layout has no shrink primitive so without
15
+ * this stabilizer the text sibling visually overlaps the button.
16
+ *
17
+ * The helper has always handled the case where a direct `<TEXT>`
18
+ * child sits next to a non-text sibling. The extension covered here
19
+ * is shadcn's canonical "title + description" wrapper —
20
+ * `<div><p>Heading</p><p>Long description text…</p></div>` —
21
+ * which is a FRAME containing only text, not a direct TEXT node.
22
+ * Without recognising the wrapper as text-bearing, the helper bailed
23
+ * (`textChildren.length !== 1`) and rows like DecreasePositionModal's
24
+ * "Close entire position" card rendered the description on one line
25
+ * overlapping the "Entire position" button.
26
+ *
27
+ * The fix: treat any frame whose visible subtree is entirely text
28
+ * (paragraphs / spans / TEXT nodes wrapped in transparent frames) as
29
+ * text-bearing. Resize the wrapper to the available width and
30
+ * recursively pin every TEXT descendant to `textAutoResize='HEIGHT'`
31
+ * so the text wraps within the now-constrained wrapper.
32
+ */
33
+
34
+ import {
35
+ constrainSingleHorizontalTextChild,
36
+ } from '../src/design-system/frame-stabilizers';
37
+
38
+ interface StubText {
39
+ type: 'TEXT';
40
+ width: number;
41
+ height: number;
42
+ textAutoResize: 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT' | 'TRUNCATE' | undefined;
43
+ resize: (w: number, h: number) => void;
44
+ layoutPositioning?: 'AUTO' | 'ABSOLUTE';
45
+ }
46
+
47
+ interface StubFrame {
48
+ type: 'FRAME';
49
+ layoutMode: 'HORIZONTAL' | 'VERTICAL';
50
+ primaryAxisAlignItems: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN';
51
+ primaryAxisSizingMode: 'AUTO' | 'FIXED';
52
+ counterAxisSizingMode: 'AUTO' | 'FIXED';
53
+ layoutAlign?: 'INHERIT' | 'STRETCH' | 'MIN' | 'CENTER' | 'MAX';
54
+ width: number;
55
+ height: number;
56
+ itemSpacing: number;
57
+ paddingLeft: number;
58
+ paddingRight: number;
59
+ paddingTop: number;
60
+ paddingBottom: number;
61
+ children: (StubFrame | StubText)[];
62
+ layoutPositioning?: 'AUTO' | 'ABSOLUTE';
63
+ resize: (w: number, h: number) => void;
64
+ }
65
+
66
+ function text(width: number, opts: Partial<StubText> = {}): StubText {
67
+ const t: StubText = {
68
+ type: 'TEXT',
69
+ width,
70
+ height: 16,
71
+ textAutoResize: 'WIDTH_AND_HEIGHT',
72
+ layoutPositioning: 'AUTO',
73
+ resize(w, h) { this.width = w; this.height = h; },
74
+ ...opts,
75
+ };
76
+ return t;
77
+ }
78
+
79
+ function frame(opts: Partial<StubFrame> & { children?: (StubFrame | StubText)[] } = {}): StubFrame {
80
+ const f: StubFrame = {
81
+ type: 'FRAME',
82
+ layoutMode: 'HORIZONTAL',
83
+ primaryAxisAlignItems: 'SPACE_BETWEEN',
84
+ primaryAxisSizingMode: 'FIXED',
85
+ counterAxisSizingMode: 'AUTO',
86
+ layoutAlign: 'INHERIT',
87
+ width: 470,
88
+ height: 64,
89
+ itemSpacing: 8,
90
+ paddingLeft: 0,
91
+ paddingRight: 0,
92
+ paddingTop: 0,
93
+ paddingBottom: 0,
94
+ layoutPositioning: 'AUTO',
95
+ children: [],
96
+ resize(w, h) { this.width = w; this.height = h; },
97
+ ...opts,
98
+ };
99
+ return f;
100
+ }
101
+
102
+ const constrain = constrainSingleHorizontalTextChild as unknown as (n: StubFrame | StubText) => void;
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Original case: direct TEXT next to a non-text sibling — preserved
106
+ // ---------------------------------------------------------------------------
107
+
108
+ {
109
+ const direct = text(400);
110
+ const button = frame({ width: 100, primaryAxisSizingMode: 'AUTO', primaryAxisAlignItems: 'MIN' });
111
+ const row = frame({ width: 460, children: [direct, button] });
112
+ constrain(row);
113
+ assert.equal(direct.width, 460 - 100 - 8, 'direct TEXT shrinks to available width (preserved behavior)');
114
+ assert.equal(direct.textAutoResize, 'HEIGHT', 'direct TEXT pinned to HEIGHT so it wraps vertically');
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // New case: text-bearing wrapper (frame with two paragraph frames inside)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ {
122
+ // Wrapper that mirrors shadcn's `<div><p>Title</p><p>Description…</p></div>`
123
+ // shape: a VERTICAL frame containing two paragraph frames, each holding a
124
+ // single TEXT node. Both paragraphs are at their one-line HUG width before
125
+ // the stabilizer runs.
126
+ const titleText = text(150);
127
+ const descText = text(400);
128
+ const titleParagraph = frame({
129
+ layoutMode: 'VERTICAL',
130
+ primaryAxisAlignItems: 'MIN',
131
+ width: 150,
132
+ height: 20,
133
+ children: [titleText],
134
+ });
135
+ const descParagraph = frame({
136
+ layoutMode: 'VERTICAL',
137
+ primaryAxisAlignItems: 'MIN',
138
+ width: 400,
139
+ height: 16,
140
+ children: [descText],
141
+ });
142
+ const wrapper = frame({
143
+ layoutMode: 'VERTICAL',
144
+ primaryAxisAlignItems: 'MIN',
145
+ width: 400,
146
+ height: 40,
147
+ children: [titleParagraph, descParagraph],
148
+ });
149
+ // The "button" sibling stays HUG — Figma equivalent of a `<Button>`.
150
+ const buttonFrame = frame({ width: 120, primaryAxisSizingMode: 'AUTO', primaryAxisAlignItems: 'MIN' });
151
+ const row = frame({ width: 470, children: [wrapper, buttonFrame] });
152
+ constrain(row);
153
+
154
+ const expectedAvailable = 470 - 120 - 8; // 342
155
+ assert.equal(wrapper.width, expectedAvailable, 'text-bearing wrapper shrinks to available width');
156
+ assert.equal(
157
+ wrapper.counterAxisSizingMode,
158
+ 'FIXED',
159
+ 'wrapper counter-axis sizing pinned to FIXED so text wraps within it instead of pushing it back open',
160
+ );
161
+ assert.equal(
162
+ wrapper.layoutAlign,
163
+ 'INHERIT',
164
+ 'wrapper layoutAlign set to INHERIT so it does not also stretch in the cross-axis',
165
+ );
166
+ assert.equal(titleText.width, expectedAvailable, 'TEXT inside paragraph 1 sized to available width');
167
+ assert.equal(titleText.textAutoResize, 'HEIGHT', 'TEXT inside paragraph 1 pinned to HEIGHT');
168
+ assert.equal(descText.width, expectedAvailable, 'TEXT inside paragraph 2 sized to available width');
169
+ assert.equal(descText.textAutoResize, 'HEIGHT', 'TEXT inside paragraph 2 pinned to HEIGHT — this is the description that was overlapping the button');
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Bail-out: no overflow → leave widths alone
174
+ // ---------------------------------------------------------------------------
175
+
176
+ {
177
+ const t = text(100);
178
+ const sibling = frame({ width: 100, primaryAxisSizingMode: 'AUTO' });
179
+ const row = frame({ width: 460, children: [t, sibling] });
180
+ constrain(row);
181
+ assert.equal(t.width, 100, 'fitting content untouched — keeps the SPACE_BETWEEN gap');
182
+ assert.equal(t.textAutoResize, 'WIDTH_AND_HEIGHT', 'fitting TEXT keeps its default autoResize');
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Bail-out: two text-bearing children — ambiguous which to shrink
187
+ // ---------------------------------------------------------------------------
188
+
189
+ {
190
+ const a = text(300);
191
+ const b = text(300);
192
+ const row = frame({ width: 460, children: [a, b] });
193
+ constrain(row);
194
+ assert.equal(a.width, 300, 'two text children → ambiguous, helper bails (matches CSS where both would shrink proportionally)');
195
+ assert.equal(b.width, 300, 'second text also untouched in ambiguous case');
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Bail-out: CENTER alignment is intentional (CTA rows)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ {
203
+ const t = text(400);
204
+ const sibling = frame({ width: 100, primaryAxisSizingMode: 'AUTO' });
205
+ const row = frame({
206
+ width: 460,
207
+ primaryAxisAlignItems: 'CENTER',
208
+ children: [t, sibling],
209
+ });
210
+ constrain(row);
211
+ assert.equal(t.width, 400, 'CENTER rows preserve symmetry — text is not shrunk so siblings stay visually centered');
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Bail-out: parent itself is HUG — width not known, no constraint to apply
216
+ // ---------------------------------------------------------------------------
217
+
218
+ {
219
+ const t = text(400);
220
+ const sibling = frame({ width: 100, primaryAxisSizingMode: 'AUTO' });
221
+ const row = frame({
222
+ width: 460,
223
+ primaryAxisSizingMode: 'AUTO',
224
+ children: [t, sibling],
225
+ });
226
+ constrain(row);
227
+ assert.equal(t.width, 400, 'AUTO-width parent → no constraint applied (parent would just grow with kids)');
228
+ }
229
+
230
+ console.log('horizontal-text-shrink-regression: PASS');
@@ -0,0 +1,195 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: `.map()` over a NAMED imported array must expand into N
7
+ * JSX children, not fall through to the placeholder-iteration path.
8
+ *
9
+ * History: greenhouse-app's `MarketPriceCard` renders its timeframe row
10
+ * with
11
+ *
12
+ * import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants";
13
+ * ...
14
+ * {PRICE_CHART_TIMEFRAMES.map((option) => (
15
+ * <Button variant={timeframe === option.value ? "default" : "outline"}>
16
+ * {option.label}
17
+ * </Button>
18
+ * ))}
19
+ *
20
+ * `findArrayValue` only inspected the current source file's variable
21
+ * declarations + default JSON imports. Named imports from a TypeScript
22
+ * file (`export const PRICE_CHART_TIMEFRAMES = [...]` in
23
+ * `~/domains/perps/constants.ts`) were invisible. The `.map()` fell
24
+ * through to the "no array found" branch and emitted ONE synthetic
25
+ * placeholder iteration — so Figma rendered zero buttons.
26
+ *
27
+ * Fix: `findArrayInNamedImports` follows named imports to the source
28
+ * file via `resolveImportedComponentPath`, opens the file, and recurses
29
+ * into `findArrayValue` against the imported source. Wired into
30
+ * `findArrayValue` after the JSON-import branch.
31
+ *
32
+ * Generalizes to ANY named-imported const array, not just chart
33
+ * timeframes — covers role lists, theme enums, navigation items, etc.
34
+ */
35
+
36
+ interface JsxNodeLike {
37
+ type: 'element' | 'text';
38
+ tagName?: string;
39
+ content?: string;
40
+ props?: Record<string, unknown>;
41
+ children?: JsxNodeLike[];
42
+ }
43
+
44
+ interface TestScannerView {
45
+ project: import('ts-morph').Project;
46
+ extractComponentJsxTree: (
47
+ sourceFile: import('ts-morph').SourceFile,
48
+ componentName: string,
49
+ ) => JsxNodeLike | null;
50
+ }
51
+
52
+ function makeScanner(): TestScannerView {
53
+ return new ComponentScanner({
54
+ componentPaths: [],
55
+ filePattern: '*.tsx',
56
+ exclude: [],
57
+ }) as unknown as TestScannerView;
58
+ }
59
+
60
+ function fixturePath(relative: string): string {
61
+ return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
62
+ }
63
+
64
+ function findAllElements(node: JsxNodeLike | null, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
65
+ if (!node || node.type !== 'element') return out;
66
+ if (node.tagName === tag) out.push(node);
67
+ for (const child of node.children || []) findAllElements(child, tag, out);
68
+ return out;
69
+ }
70
+
71
+ const scanner = makeScanner();
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Case 1: relative-path named import (`./constants`). Asserts `.map()` over
75
+ // the imported array expands into N JSX elements with the right label text.
76
+ // ---------------------------------------------------------------------------
77
+ {
78
+ scanner.project.createSourceFile(
79
+ fixturePath('imported-array/constants.ts'),
80
+ `
81
+ export const PRICE_CHART_TIMEFRAMES: Array<{ label: string; value: string }> = [
82
+ { label: "Tick", value: "tick" },
83
+ { label: "1h", value: "1h" },
84
+ { label: "1d", value: "1d" },
85
+ ];
86
+ `,
87
+ { overwrite: true },
88
+ );
89
+
90
+ const file = scanner.project.createSourceFile(
91
+ fixturePath('imported-array/Card.tsx'),
92
+ `
93
+ import { PRICE_CHART_TIMEFRAMES } from "./constants";
94
+ export function Card() {
95
+ return (
96
+ <div>
97
+ {PRICE_CHART_TIMEFRAMES.map((option) => (
98
+ <button data-key={option.value}>{option.label}</button>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+ `,
104
+ { overwrite: true },
105
+ );
106
+
107
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
108
+ assert.ok(tree, 'Card must produce a tree');
109
+ const buttons = findAllElements(tree, 'button');
110
+ assert.equal(
111
+ buttons.length,
112
+ 3,
113
+ `expected 3 buttons from the named-imported array .map(); got ${buttons.length}`,
114
+ );
115
+ const labels = buttons
116
+ .map((b) => (b.children || []).map((c) => c.content ?? '').join(''))
117
+ .join('|');
118
+ assert.ok(
119
+ labels.includes('Tick') && labels.includes('1h') && labels.includes('1d'),
120
+ `expected each button's text child to come from option.label; got "${labels}"`,
121
+ );
122
+ const keys = buttons.map((b) => b.props?.['data-key']).join(',');
123
+ assert.equal(
124
+ keys,
125
+ 'tick,1h,1d',
126
+ `expected data-key props to come from option.value; got "${keys}"`,
127
+ );
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Case 2: aliased named import (`import { Foo as Bar } from "./x"`). The
132
+ // scanner must follow the imported name even when locally renamed.
133
+ // ---------------------------------------------------------------------------
134
+ {
135
+ scanner.project.createSourceFile(
136
+ fixturePath('imported-array/aliased-constants.ts'),
137
+ `
138
+ export const ROLES = [
139
+ { id: "admin", label: "Admin" },
140
+ { id: "viewer", label: "Viewer" },
141
+ ];
142
+ `,
143
+ { overwrite: true },
144
+ );
145
+
146
+ const file = scanner.project.createSourceFile(
147
+ fixturePath('imported-array/Aliased.tsx'),
148
+ `
149
+ import { ROLES as RolesList } from "./aliased-constants";
150
+ export function Aliased() {
151
+ return (
152
+ <ul>
153
+ {RolesList.map((r) => <li data-id={r.id}>{r.label}</li>)}
154
+ </ul>
155
+ );
156
+ }
157
+ `,
158
+ { overwrite: true },
159
+ );
160
+
161
+ const tree = scanner.extractComponentJsxTree(file, 'Aliased');
162
+ assert.ok(tree, 'Aliased must produce a tree');
163
+ const items = findAllElements(tree, 'li');
164
+ assert.equal(items.length, 2, `expected 2 <li> from aliased import .map(); got ${items.length}`);
165
+ const ids = items.map((b) => b.props?.['data-id']).join(',');
166
+ assert.equal(ids, 'admin,viewer', `expected ids resolved through the alias; got "${ids}"`);
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Case 3: unresolvable import (path doesn't exist on disk) MUST NOT crash.
171
+ // `.map()` falls through to the existing placeholder-iteration path. Lock the
172
+ // graceful degradation so a future change can't turn this into a hard error.
173
+ // ---------------------------------------------------------------------------
174
+ {
175
+ const file = scanner.project.createSourceFile(
176
+ fixturePath('imported-array/Unresolvable.tsx'),
177
+ `
178
+ import { NOPE } from "./does-not-exist";
179
+ export function Unresolvable() {
180
+ return <div>{NOPE.map((x) => <span>{x.label}</span>)}</div>;
181
+ }
182
+ `,
183
+ { overwrite: true },
184
+ );
185
+
186
+ const tree = scanner.extractComponentJsxTree(file, 'Unresolvable');
187
+ assert.ok(tree, 'must not throw on an unresolvable import');
188
+ // Should produce the placeholder iteration (one synthetic <span>) — the
189
+ // existing fallback path. Just assert we got *something* without
190
+ // checking the placeholder shape, which is implementation-detail.
191
+ const spans = findAllElements(tree, 'span');
192
+ assert.ok(spans.length >= 0, 'should not throw');
193
+ }
194
+
195
+ console.log('imported-array-map-regression: PASS (3 cases)');
@@ -93,6 +93,7 @@ function runRegression(): void {
93
93
  blockFlowCard.layoutMode = 'VERTICAL';
94
94
  blockFlowCard.width = 600;
95
95
  blockFlowCard.primaryAxisSizingMode = 'FIXED';
96
+ blockFlowCard.counterAxisSizingMode = 'FIXED';
96
97
  setFrameCrossAlign(blockFlowCard as unknown as FrameNode, 'MIN');
97
98
  setFrameFromBlockFlow(blockFlowCard as unknown as FrameNode, true);
98
99
 
@@ -117,6 +118,7 @@ function runRegression(): void {
117
118
  blockFlowCard2.layoutMode = 'VERTICAL';
118
119
  blockFlowCard2.width = 600;
119
120
  blockFlowCard2.primaryAxisSizingMode = 'FIXED';
121
+ blockFlowCard2.counterAxisSizingMode = 'FIXED';
120
122
  setFrameCrossAlign(blockFlowCard2 as unknown as FrameNode, 'MIN');
121
123
  setFrameFromBlockFlow(blockFlowCard2 as unknown as FrameNode, true);
122
124
 
@@ -141,6 +143,7 @@ function runRegression(): void {
141
143
  blockFlowCard3.layoutMode = 'VERTICAL';
142
144
  blockFlowCard3.width = 600;
143
145
  blockFlowCard3.primaryAxisSizingMode = 'FIXED';
146
+ blockFlowCard3.counterAxisSizingMode = 'FIXED';
144
147
  setFrameCrossAlign(blockFlowCard3 as unknown as FrameNode, 'MIN');
145
148
  setFrameFromBlockFlow(blockFlowCard3 as unknown as FrameNode, true);
146
149
 
@@ -169,6 +172,7 @@ function runRegression(): void {
169
172
  flexFooter.layoutMode = 'VERTICAL';
170
173
  flexFooter.width = 400;
171
174
  flexFooter.primaryAxisSizingMode = 'FIXED';
175
+ flexFooter.counterAxisSizingMode = 'FIXED';
172
176
  setFrameCrossAlign(flexFooter as unknown as FrameNode, 'STRETCH');
173
177
  setFrameFromBlockFlow(flexFooter as unknown as FrameNode, false);
174
178
 
@@ -198,6 +202,7 @@ function runRegression(): void {
198
202
  centeredBlockParent.layoutMode = 'VERTICAL';
199
203
  centeredBlockParent.width = 600;
200
204
  centeredBlockParent.primaryAxisSizingMode = 'FIXED';
205
+ centeredBlockParent.counterAxisSizingMode = 'FIXED';
201
206
  setFrameCrossAlign(centeredBlockParent as unknown as FrameNode, 'MIN');
202
207
  setFrameFromBlockFlow(centeredBlockParent as unknown as FrameNode, true);
203
208
  setFrameInlineAlign(centeredBlockParent as unknown as FrameNode, 'CENTER');