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.
- package/README.md +29 -0
- package/code.js +15 -15
- package/manifest.json +1 -2
- package/package.json +40 -22
- package/scanner/border-dash-pattern-regression.ts +163 -0
- package/scanner/child-sizing-matrix-regression.ts +9 -0
- package/scanner/cli.ts +21 -5
- package/scanner/component-scanner.ts +1333 -77
- package/scanner/conditional-map-branch-regression.ts +180 -0
- package/scanner/css-token-reader.ts +66 -5
- package/scanner/dialog-content-gate-regression.ts +195 -0
- package/scanner/expression-evaluator-regression.ts +432 -0
- package/scanner/framework-adapter-shadcn-regression.ts +157 -1
- package/scanner/hidden-check-drift-regression.ts +125 -0
- package/scanner/horizontal-text-shrink-regression.ts +230 -0
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/inline-flex-regression.ts +5 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/portal-class-strip-regression.ts +109 -0
- package/scanner/responsive-hidden-inline-regression.ts +226 -0
- package/scanner/responsive-opt-in-regression.ts +212 -0
- package/scanner/select-root-flatten-regression.ts +314 -0
- package/scanner/space-between-single-child-regression.ts +163 -0
- package/scanner/story-args-resolution-regression.ts +311 -0
- package/scanner/story-dimensioning-regression.ts +76 -1
- package/scanner/style-map.ts +57 -0
- package/scanner/table-column-alignment-regression.ts +355 -0
- package/scanner/ternary-fragment-branch-regression.ts +196 -0
- package/scanner/text-truncate-regression.ts +481 -0
- package/scanner/types.ts +13 -0
- package/src/components/component-gen.ts +21 -38
- package/src/design-system/cva-master.ts +11 -18
- package/src/design-system/design-system.ts +36 -7
- package/src/design-system/frame-stabilizers.ts +109 -12
- package/src/design-system/preview-builder.ts +38 -0
- package/src/design-system/selectable-state.ts +8 -1
- package/src/design-system/story-builder.ts +62 -32
- package/src/design-system/story-dimensioning.ts +14 -3
- package/src/design-system/tag-predicates.ts +8 -0
- package/src/design-system/typography.ts +26 -0
- package/src/design-system/ui-builder.ts +188 -60
- package/src/effects/icon-builder.ts +8 -0
- package/src/framework-adapters/shadcn.ts +113 -0
- package/src/github/github.ts +22 -4
- package/src/layout/index.ts +4 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/layout-parser.ts +36 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/layout/table-layout.ts +271 -0
- package/src/layout/text-truncate-pass.ts +151 -0
- package/src/layout/width-solver.ts +63 -17
- package/src/main.ts +37 -124
- package/src/plugin/config.ts +21 -0
- package/src/plugin/packs/pack-provider.ts +20 -4
- package/src/plugin/packs/packs.ts +14 -0
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/jsx-utils.ts +39 -0
- package/src/tailwind/node-ir.ts +8 -1
- package/src/tailwind/responsive-analyzer.ts +57 -3
- package/src/tailwind/tailwind.ts +344 -51
- package/src/text/index.ts +1 -0
- package/src/text/inline-text.ts +112 -12
- package/src/text/text-builder.ts +2 -2
- package/src/text/text-truncate.ts +101 -0
- package/src/tokens/tokens.ts +107 -16
- package/src/tokens/variables.ts +203 -46
- package/templates/scan-components-route.ts +8 -0
- package/ui.html +144 -43
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
import { applyTableColumnAlignment } from '../src/layout/table-layout';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression: HTML `<table>` columns must align across rows when
|
|
12
|
+
* rendered to Figma. Without the post-pass, each `<tr>` is an
|
|
13
|
+
* independent HORIZONTAL frame and cells size to their own content,
|
|
14
|
+
* so the table looks ragged. The pass walks the built tree, finds
|
|
15
|
+
* each `<table>`, takes per-column max-content width across all
|
|
16
|
+
* rows (incl. thead/tbody/tfoot), and resizes every cell at that
|
|
17
|
+
* column to the column's max.
|
|
18
|
+
*
|
|
19
|
+
* Real-world repro: greenhouse-app `RecentTradesCard` — rendering as
|
|
20
|
+
* "$172,800,00$2,500.00" because price (175,200,000,000) was wider
|
|
21
|
+
* than price (3,245,000,000) on another row, so the Size column
|
|
22
|
+
* started at a different X-offset per row and visually overlapped
|
|
23
|
+
* the longer Price.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Stub-frame harness — minimal FrameNode mock with parent links + names.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
interface StubFrame {
|
|
31
|
+
type: 'FRAME';
|
|
32
|
+
name: string;
|
|
33
|
+
layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
|
|
34
|
+
layoutPositioning: 'AUTO' | 'ABSOLUTE';
|
|
35
|
+
primaryAxisSizingMode: 'AUTO' | 'FIXED';
|
|
36
|
+
counterAxisSizingMode: 'AUTO' | 'FIXED';
|
|
37
|
+
layoutGrow: number;
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
parent: StubFrame | null;
|
|
41
|
+
children: StubFrame[];
|
|
42
|
+
pluginData: Record<string, string>;
|
|
43
|
+
appendChild(child: StubFrame): void;
|
|
44
|
+
resize(w: number, h: number): void;
|
|
45
|
+
setPluginData(key: string, value: string): void;
|
|
46
|
+
getPluginData(key: string): string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mkFrame(name: string): StubFrame {
|
|
50
|
+
const data: Record<string, string> = {};
|
|
51
|
+
const f: StubFrame = {
|
|
52
|
+
type: 'FRAME',
|
|
53
|
+
name,
|
|
54
|
+
layoutMode: 'NONE',
|
|
55
|
+
layoutPositioning: 'AUTO',
|
|
56
|
+
primaryAxisSizingMode: 'AUTO',
|
|
57
|
+
counterAxisSizingMode: 'AUTO',
|
|
58
|
+
layoutGrow: 0,
|
|
59
|
+
width: 0,
|
|
60
|
+
height: 0,
|
|
61
|
+
parent: null,
|
|
62
|
+
children: [],
|
|
63
|
+
pluginData: data,
|
|
64
|
+
appendChild(child) {
|
|
65
|
+
child.parent = this;
|
|
66
|
+
this.children.push(child);
|
|
67
|
+
},
|
|
68
|
+
resize(w, h) {
|
|
69
|
+
this.width = w;
|
|
70
|
+
this.height = h;
|
|
71
|
+
},
|
|
72
|
+
setPluginData(key, value) {
|
|
73
|
+
data[key] = value;
|
|
74
|
+
},
|
|
75
|
+
getPluginData(key) {
|
|
76
|
+
return data[key] || '';
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
return f;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface CellInit {
|
|
83
|
+
width: number;
|
|
84
|
+
colSpan?: number;
|
|
85
|
+
layoutGrow?: number;
|
|
86
|
+
/** Override the cell tag — defaults to `td`. */
|
|
87
|
+
tag?: 'td' | 'th';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cell(init: CellInit): StubFrame {
|
|
91
|
+
const c = mkFrame(init.tag || 'td');
|
|
92
|
+
c.layoutMode = 'HORIZONTAL';
|
|
93
|
+
c.width = init.width;
|
|
94
|
+
c.height = 24;
|
|
95
|
+
c.layoutGrow = init.layoutGrow ?? 1;
|
|
96
|
+
if (init.colSpan && init.colSpan > 1) {
|
|
97
|
+
c.setPluginData('inkbridge:col-span', String(init.colSpan));
|
|
98
|
+
} else {
|
|
99
|
+
c.setPluginData('inkbridge:col-span', '1');
|
|
100
|
+
}
|
|
101
|
+
return c;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function row(cells: StubFrame[]): StubFrame {
|
|
105
|
+
const r = mkFrame('tr');
|
|
106
|
+
r.layoutMode = 'HORIZONTAL';
|
|
107
|
+
for (const c of cells) r.appendChild(c);
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function rowGroup(tag: 'thead' | 'tbody' | 'tfoot', rows: StubFrame[]): StubFrame {
|
|
112
|
+
const g = mkFrame(tag);
|
|
113
|
+
g.layoutMode = 'VERTICAL';
|
|
114
|
+
for (const r of rows) g.appendChild(r);
|
|
115
|
+
return g;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function table(rowGroups: StubFrame[]): StubFrame {
|
|
119
|
+
const t = mkFrame('table');
|
|
120
|
+
t.layoutMode = 'VERTICAL';
|
|
121
|
+
for (const g of rowGroups) t.appendChild(g);
|
|
122
|
+
return t;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const asNode = (f: StubFrame) => f as unknown as SceneNode;
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Cell 1 — RecentTradesCard shape: 3 rows with different column widths.
|
|
129
|
+
// All columns must converge to per-column max-content.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
{
|
|
132
|
+
const t = table([
|
|
133
|
+
rowGroup('thead', [row([
|
|
134
|
+
cell({ width: 50, tag: 'th' }), // Action
|
|
135
|
+
cell({ width: 30, tag: 'th' }), // Side
|
|
136
|
+
cell({ width: 40, tag: 'th' }), // Price
|
|
137
|
+
cell({ width: 30, tag: 'th' }), // Size
|
|
138
|
+
])]),
|
|
139
|
+
rowGroup('tbody', [
|
|
140
|
+
row([
|
|
141
|
+
cell({ width: 60 }), // Increase
|
|
142
|
+
cell({ width: 30 }), // long
|
|
143
|
+
cell({ width: 180 }), // $172,800,000,000.00 — widest
|
|
144
|
+
cell({ width: 50 }), // $2,500.00
|
|
145
|
+
]),
|
|
146
|
+
row([
|
|
147
|
+
cell({ width: 60 }), // Decrease
|
|
148
|
+
cell({ width: 30 }), // long
|
|
149
|
+
cell({ width: 180 }), // $175,200,000,000.00
|
|
150
|
+
cell({ width: 50 }), // $1,250.00
|
|
151
|
+
]),
|
|
152
|
+
row([
|
|
153
|
+
cell({ width: 60 }), // Increase
|
|
154
|
+
cell({ width: 40 }), // short — widest
|
|
155
|
+
cell({ width: 130 }), // $3,245,000,000.00
|
|
156
|
+
cell({ width: 60 }), // $1,500.00 — widest
|
|
157
|
+
]),
|
|
158
|
+
]),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const stats = applyTableColumnAlignment(asNode(t));
|
|
162
|
+
|
|
163
|
+
assert.equal(stats.tablesProcessed, 1, 'one table found');
|
|
164
|
+
|
|
165
|
+
// Walk every row, every cell — assert they match expected per-column max.
|
|
166
|
+
const expected = [60, 40, 180, 60];
|
|
167
|
+
const allRows: StubFrame[] = [];
|
|
168
|
+
for (const group of t.children) {
|
|
169
|
+
for (const r of group.children) allRows.push(r);
|
|
170
|
+
}
|
|
171
|
+
assert.equal(allRows.length, 4, '4 rows total (1 thead + 3 tbody)');
|
|
172
|
+
for (let r = 0; r < allRows.length; r++) {
|
|
173
|
+
const cells = allRows[r].children;
|
|
174
|
+
for (let c = 0; c < cells.length; c++) {
|
|
175
|
+
assert.equal(
|
|
176
|
+
cells[c].width,
|
|
177
|
+
expected[c],
|
|
178
|
+
`row ${r} col ${c}: expected ${expected[c]} got ${cells[c].width}`,
|
|
179
|
+
);
|
|
180
|
+
assert.equal(cells[c].layoutGrow, 0, `row ${r} col ${c}: layoutGrow should be reset to 0`);
|
|
181
|
+
assert.equal(cells[c].primaryAxisSizingMode, 'FIXED', `row ${r} col ${c}: primary FIXED`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Cell 2 — Single-row table: no alignment needed.
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
{
|
|
190
|
+
const t = table([
|
|
191
|
+
rowGroup('tbody', [
|
|
192
|
+
row([
|
|
193
|
+
cell({ width: 80 }),
|
|
194
|
+
cell({ width: 120 }),
|
|
195
|
+
]),
|
|
196
|
+
]),
|
|
197
|
+
]);
|
|
198
|
+
const original = [t.children[0].children[0].children[0].width, t.children[0].children[0].children[1].width];
|
|
199
|
+
const stats = applyTableColumnAlignment(asNode(t));
|
|
200
|
+
assert.equal(stats.cellsResized, 0, 'no resizes for single-row table');
|
|
201
|
+
assert.equal(t.children[0].children[0].children[0].width, original[0], 'untouched');
|
|
202
|
+
assert.equal(t.children[0].children[0].children[1].width, original[1], 'untouched');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Cell 3 — Empty-state row with colspan=N must NOT influence per-column max.
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
{
|
|
209
|
+
const t = table([
|
|
210
|
+
rowGroup('thead', [row([
|
|
211
|
+
cell({ width: 40, tag: 'th' }),
|
|
212
|
+
cell({ width: 60, tag: 'th' }),
|
|
213
|
+
])]),
|
|
214
|
+
rowGroup('tbody', [
|
|
215
|
+
row([
|
|
216
|
+
cell({ width: 50 }),
|
|
217
|
+
cell({ width: 90 }),
|
|
218
|
+
]),
|
|
219
|
+
row([
|
|
220
|
+
cell({ width: 999, colSpan: 2 }), // empty-state span — must NOT push column 0 to 999
|
|
221
|
+
]),
|
|
222
|
+
]),
|
|
223
|
+
]);
|
|
224
|
+
const stats = applyTableColumnAlignment(asNode(t));
|
|
225
|
+
assert.equal(stats.tablesProcessed, 1);
|
|
226
|
+
// Column 0 max = max(40, 50) = 50; column 1 max = max(60, 90) = 90.
|
|
227
|
+
// The colspan=2 cell is excluded from both column maxes.
|
|
228
|
+
assert.equal(t.children[0].children[0].children[0].width, 50, 'col 0: max is 50, not 999');
|
|
229
|
+
assert.equal(t.children[0].children[0].children[1].width, 90, 'col 1: max is 90');
|
|
230
|
+
assert.equal(t.children[1].children[0].children[0].width, 50, 'tbody row 0 col 0: 50');
|
|
231
|
+
assert.equal(t.children[1].children[0].children[1].width, 90, 'tbody row 0 col 1: 90');
|
|
232
|
+
// The colspan cell itself stays at its natural width — skipped by the pass.
|
|
233
|
+
assert.equal(t.children[1].children[1].children[0].width, 999, 'colspan cell untouched');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Cell 4 — Cells already at correct width: no-op resize, no stats bump.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
{
|
|
240
|
+
const t = table([
|
|
241
|
+
rowGroup('tbody', [
|
|
242
|
+
row([cell({ width: 100 }), cell({ width: 80 })]),
|
|
243
|
+
row([cell({ width: 100 }), cell({ width: 80 })]),
|
|
244
|
+
]),
|
|
245
|
+
]);
|
|
246
|
+
const stats = applyTableColumnAlignment(asNode(t));
|
|
247
|
+
assert.equal(stats.tablesProcessed, 1);
|
|
248
|
+
assert.equal(stats.cellsResized, 0, 'cells already aligned: zero resizes');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Cell 5 — Table nested in non-table parent: pass walks past wrappers.
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
{
|
|
255
|
+
const wrapper = mkFrame('div');
|
|
256
|
+
const t = table([
|
|
257
|
+
rowGroup('tbody', [
|
|
258
|
+
row([cell({ width: 30 }), cell({ width: 40 })]),
|
|
259
|
+
row([cell({ width: 50 }), cell({ width: 60 })]),
|
|
260
|
+
]),
|
|
261
|
+
]);
|
|
262
|
+
wrapper.appendChild(t);
|
|
263
|
+
const stats = applyTableColumnAlignment(asNode(wrapper));
|
|
264
|
+
assert.equal(stats.tablesProcessed, 1, 'table found inside wrapper');
|
|
265
|
+
assert.equal(t.children[0].children[0].children[0].width, 50, 'col 0 unified');
|
|
266
|
+
assert.equal(t.children[0].children[1].children[1].width, 60, 'col 1 unified');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Cell 6 — Columns sum exceeds the table's available width: scale down
|
|
271
|
+
// proportionally to fit. Mirrors `table-fixed` + `min-w-full` browser
|
|
272
|
+
// behaviour. Without this the table overflows its parent card right-edge
|
|
273
|
+
// in Figma (no scrollbar to mask it as in the browser).
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
{
|
|
276
|
+
// Parent card with FIXED width = 400; table sits inside.
|
|
277
|
+
const card = mkFrame('div');
|
|
278
|
+
card.layoutMode = 'VERTICAL';
|
|
279
|
+
card.counterAxisSizingMode = 'FIXED';
|
|
280
|
+
card.width = 400;
|
|
281
|
+
card.height = 200;
|
|
282
|
+
|
|
283
|
+
const t = table([
|
|
284
|
+
rowGroup('tbody', [
|
|
285
|
+
row([cell({ width: 200 }), cell({ width: 200 }), cell({ width: 200 })]), // sum = 600
|
|
286
|
+
row([cell({ width: 200 }), cell({ width: 200 }), cell({ width: 200 })]),
|
|
287
|
+
]),
|
|
288
|
+
]);
|
|
289
|
+
card.appendChild(t);
|
|
290
|
+
|
|
291
|
+
applyTableColumnAlignment(asNode(card));
|
|
292
|
+
|
|
293
|
+
// Sum of per-column max = 600, available = 400 → scale = 2/3.
|
|
294
|
+
// Each 200 → floor(200 * 2/3) = 133.
|
|
295
|
+
for (const tr of t.children[0].children) {
|
|
296
|
+
for (const c of tr.children) {
|
|
297
|
+
assert.equal(c.width, 133, `scaled-down cell width: expected 133 got ${c.width}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const tableTotal = t.children[0].children[0].children.reduce((acc, c) => acc + c.width, 0);
|
|
301
|
+
assert.ok(tableTotal <= 400, `scaled total ${tableTotal} must fit available 400`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Cell 7 — Columns sum is smaller than available: expand UP to fill the
|
|
306
|
+
// table width. Browser parity with `min-w-full table-fixed` — the table
|
|
307
|
+
// fills its parent, columns distribute the extra. Without this the
|
|
308
|
+
// row separator borders stop mid-card in Figma.
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
{
|
|
311
|
+
const card = mkFrame('div');
|
|
312
|
+
card.layoutMode = 'VERTICAL';
|
|
313
|
+
card.counterAxisSizingMode = 'FIXED';
|
|
314
|
+
card.width = 800;
|
|
315
|
+
|
|
316
|
+
const t = table([
|
|
317
|
+
rowGroup('tbody', [
|
|
318
|
+
row([cell({ width: 100 }), cell({ width: 80 })]), // sum = 180
|
|
319
|
+
row([cell({ width: 100 }), cell({ width: 80 })]),
|
|
320
|
+
]),
|
|
321
|
+
]);
|
|
322
|
+
card.appendChild(t);
|
|
323
|
+
|
|
324
|
+
applyTableColumnAlignment(asNode(card));
|
|
325
|
+
|
|
326
|
+
// Sum = 180, available = 800 → scale = 800/180 ≈ 4.444.
|
|
327
|
+
// 100 * 4.444 = 444 (floor), 80 * 4.444 = 355 (floor). 444+355=799 (~800).
|
|
328
|
+
assert.equal(t.children[0].children[0].children[0].width, 444, 'expand: col 0 grows from 100 to 444');
|
|
329
|
+
assert.equal(t.children[0].children[0].children[1].width, 355, 'expand: col 1 grows from 80 to 355');
|
|
330
|
+
const total = t.children[0].children[0].children.reduce((acc, c) => acc + c.width, 0);
|
|
331
|
+
assert.ok(total <= 800, `expanded total ${total} stays within 800`);
|
|
332
|
+
// Both rows mirror each other so alignment is preserved.
|
|
333
|
+
assert.equal(t.children[0].children[1].children[0].width, 444, 'row 1 col 0 matches row 0');
|
|
334
|
+
assert.equal(t.children[0].children[1].children[1].width, 355, 'row 1 col 1 matches row 0');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Cell 8 — No definite-width ancestor: leave columns at natural widths.
|
|
339
|
+
// (Pass cannot infer "fill what" without an anchor.)
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
{
|
|
342
|
+
const t = table([
|
|
343
|
+
rowGroup('tbody', [
|
|
344
|
+
row([cell({ width: 50 }), cell({ width: 80 })]),
|
|
345
|
+
row([cell({ width: 60 }), cell({ width: 80 })]),
|
|
346
|
+
]),
|
|
347
|
+
]);
|
|
348
|
+
// Orphan table — no parent with FIXED width.
|
|
349
|
+
applyTableColumnAlignment(asNode(t));
|
|
350
|
+
// No expansion / no shrink — just per-column max.
|
|
351
|
+
assert.equal(t.children[0].children[0].children[0].width, 60, 'orphan: col 0 = max(50, 60) = 60');
|
|
352
|
+
assert.equal(t.children[0].children[0].children[1].width, 80, 'orphan: col 1 = max(80, 80) = 80');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
console.log('table-column-alignment-regression: ok (8 cells)');
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ComponentScanner } from './component-scanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: ternaries inside JSX expression containers must expand
|
|
7
|
+
* both `<>…</>` (Fragment) branches AND nested `cond ? a : b` ternary
|
|
8
|
+
* branches. The previous `pushChosenJsxBranch` only handled
|
|
9
|
+
* `JsxElement` / `JsxSelfClosingElement`; fragments and nested
|
|
10
|
+
* ternaries silently fell through to `resolveExpressionValue` (which
|
|
11
|
+
* returns undefined for JSX) and the entire branch was dropped from
|
|
12
|
+
* the scan tree.
|
|
13
|
+
*
|
|
14
|
+
* Two real-world cases this fix lights up at once:
|
|
15
|
+
*
|
|
16
|
+
* 1. Submit button body — `isLongSide ? <><svg/>Long / Buy</> :
|
|
17
|
+
* <><svg/>Short / Sell</>`. Both branches are Fragments. Without
|
|
18
|
+
* Fragment support the Button rendered as a frame with no
|
|
19
|
+
* children (no icon, no label) in IncreasePositionModal.
|
|
20
|
+
*
|
|
21
|
+
* 2. Multi-state status card — `previewLoading ? <skel/> :
|
|
22
|
+
* previewMetrics ? <metrics/> : <empty/>`. The outer-false
|
|
23
|
+
* branch is itself a ConditionalExpression. Without nested
|
|
24
|
+
* ternary handling the Position impact card showed only its
|
|
25
|
+
* header and the entire metrics block was missing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
interface TestScannerView {
|
|
29
|
+
project: import('ts-morph').Project;
|
|
30
|
+
extractComponentJsxTree: (
|
|
31
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
32
|
+
componentName: string,
|
|
33
|
+
) => unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface JsxNodeLike {
|
|
37
|
+
type: 'element' | 'text';
|
|
38
|
+
tagName?: string;
|
|
39
|
+
content?: string;
|
|
40
|
+
props?: Record<string, string>;
|
|
41
|
+
children?: JsxNodeLike[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fixturePath(rel: string): string {
|
|
45
|
+
return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', rel);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findAllTexts(node: JsxNodeLike | null, out: string[] = []): string[] {
|
|
49
|
+
if (!node) return out;
|
|
50
|
+
if (node.type === 'text' && typeof node.content === 'string') {
|
|
51
|
+
const t = node.content.trim();
|
|
52
|
+
if (t.length > 0) out.push(t);
|
|
53
|
+
}
|
|
54
|
+
for (const c of node.children || []) findAllTexts(c, out);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findElements(node: JsxNodeLike | null, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
|
|
59
|
+
if (!node || node.type !== 'element') return out;
|
|
60
|
+
if (node.tagName === tag) out.push(node);
|
|
61
|
+
for (const c of node.children || []) findElements(c, tag, out);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const scanner = new ComponentScanner({
|
|
66
|
+
componentPaths: [],
|
|
67
|
+
filePattern: '*.tsx',
|
|
68
|
+
exclude: [],
|
|
69
|
+
}) as unknown as TestScannerView;
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Case 1: Fragment branch — `isLong ? <><svg/>Long</> : <><svg/>Short</>`
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
const file = scanner.project.createSourceFile(
|
|
77
|
+
fixturePath('ternary-fragment/SubmitButton.tsx'),
|
|
78
|
+
`
|
|
79
|
+
export function SubmitButton({ isLong }: { isLong: boolean }) {
|
|
80
|
+
return (
|
|
81
|
+
<button>
|
|
82
|
+
{isLong ? (
|
|
83
|
+
<>
|
|
84
|
+
<svg data-marker="long-arrow" viewBox="0 0 24 24"><path d="M0 0" /></svg>
|
|
85
|
+
Long / Buy
|
|
86
|
+
</>
|
|
87
|
+
) : (
|
|
88
|
+
<>
|
|
89
|
+
<svg data-marker="short-arrow" viewBox="0 0 24 24"><path d="M0 0" /></svg>
|
|
90
|
+
Short / Sell
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
</button>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
export function Story() {
|
|
97
|
+
return <SubmitButton isLong={true} />;
|
|
98
|
+
}
|
|
99
|
+
`,
|
|
100
|
+
{ overwrite: true },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const tree = scanner.extractComponentJsxTree(file, 'Story') as JsxNodeLike;
|
|
104
|
+
const button = findElements(tree, 'button')[0];
|
|
105
|
+
assert.ok(button, 'button element survives the expansion');
|
|
106
|
+
const svgs = findElements(button, 'svg');
|
|
107
|
+
assert.equal(svgs.length, 1, 'fragment children flattened into the button — exactly one svg from the chosen branch');
|
|
108
|
+
assert.equal(
|
|
109
|
+
svgs[0].props?.['data-marker'],
|
|
110
|
+
'long-arrow',
|
|
111
|
+
'isLong=true picked the truthy fragment, not the short one',
|
|
112
|
+
);
|
|
113
|
+
const texts = findAllTexts(button);
|
|
114
|
+
assert.ok(texts.includes('Long / Buy'), 'fragment text child surfaces as a direct text node of the button');
|
|
115
|
+
assert.ok(!texts.includes('Short / Sell'), 'falsy branch is NOT emitted alongside the truthy one');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Case 2: Nested ternary — `a ? <X/> : b ? <Y/> : <Z/>` with b truthy
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
const file = scanner.project.createSourceFile(
|
|
124
|
+
fixturePath('ternary-fragment/StatusCard.tsx'),
|
|
125
|
+
`
|
|
126
|
+
interface Metrics { value: string; }
|
|
127
|
+
export function StatusCard({ loading, metrics }: { loading: boolean; metrics: Metrics | null }) {
|
|
128
|
+
return (
|
|
129
|
+
<section>
|
|
130
|
+
{loading ? (
|
|
131
|
+
<div data-marker="loading-skeleton" />
|
|
132
|
+
) : metrics ? (
|
|
133
|
+
<div data-marker="metrics-body">
|
|
134
|
+
<p data-marker="metric-row">Entry $172.41</p>
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
<p data-marker="empty-state">No data</p>
|
|
138
|
+
)}
|
|
139
|
+
</section>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
export function Story() {
|
|
143
|
+
return <StatusCard loading={false} metrics={{ value: "anything" }} />;
|
|
144
|
+
}
|
|
145
|
+
`,
|
|
146
|
+
{ overwrite: true },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const tree = scanner.extractComponentJsxTree(file, 'Story') as JsxNodeLike;
|
|
150
|
+
// Section must have exactly one direct meaningful child — the metrics body.
|
|
151
|
+
const skeletons = findElements(tree, 'div').filter((d) => d.props?.['data-marker'] === 'loading-skeleton');
|
|
152
|
+
const metricsBody = findElements(tree, 'div').filter((d) => d.props?.['data-marker'] === 'metrics-body');
|
|
153
|
+
const emptyState = findElements(tree, 'p').filter((p) => p.props?.['data-marker'] === 'empty-state');
|
|
154
|
+
assert.equal(skeletons.length, 0, 'loading=false → skeleton branch NOT emitted');
|
|
155
|
+
assert.equal(metricsBody.length, 1, 'metrics truthy → metrics body emitted (nested ternary unpacked)');
|
|
156
|
+
assert.equal(emptyState.length, 0, 'metrics truthy → empty-state NOT emitted');
|
|
157
|
+
const metricRow = findElements(metricsBody[0], 'p').find((p) => p.props?.['data-marker'] === 'metric-row');
|
|
158
|
+
assert.ok(metricRow, 'children inside the chosen nested branch render normally');
|
|
159
|
+
const texts = findAllTexts(metricRow!);
|
|
160
|
+
assert.ok(texts.includes('Entry $172.41'), 'static text inside the chosen nested branch survives');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Case 3: Nested ternary where the deepest false branch wins
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
const file = scanner.project.createSourceFile(
|
|
169
|
+
fixturePath('ternary-fragment/EmptyState.tsx'),
|
|
170
|
+
`
|
|
171
|
+
export function StatusCard({ loading, metrics }: { loading: boolean; metrics: unknown }) {
|
|
172
|
+
return (
|
|
173
|
+
<section>
|
|
174
|
+
{loading ? (
|
|
175
|
+
<div data-marker="loading-skeleton" />
|
|
176
|
+
) : metrics ? (
|
|
177
|
+
<div data-marker="metrics-body" />
|
|
178
|
+
) : (
|
|
179
|
+
<p data-marker="empty-state">No data</p>
|
|
180
|
+
)}
|
|
181
|
+
</section>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
export function Story() {
|
|
185
|
+
return <StatusCard loading={false} metrics={null} />;
|
|
186
|
+
}
|
|
187
|
+
`,
|
|
188
|
+
{ overwrite: true },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const tree = scanner.extractComponentJsxTree(file, 'Story') as JsxNodeLike;
|
|
192
|
+
const empty = findElements(tree, 'p').filter((p) => p.props?.['data-marker'] === 'empty-state');
|
|
193
|
+
assert.equal(empty.length, 1, 'metrics=null falls through the inner ternary to the empty-state branch');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log('ternary-fragment-branch-regression: PASS');
|