inkbridge 0.1.0-beta.21 → 0.1.0-beta.22
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/code.js +10 -10
- package/package.json +5 -2
- package/scanner/component-scanner.ts +132 -2
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/story-args-resolution-regression.ts +270 -0
- package/src/design-system/story-builder.ts +12 -0
- package/src/layout/index.ts +2 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/tailwind.ts +29 -8
|
@@ -0,0 +1,333 @@
|
|
|
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 { hasDefiniteWidth, isUnanchoredStretchChild } from '../src/layout/intrinsic-sizing';
|
|
9
|
+
import { breakHugStretchDeadlocks } from '../src/layout/intrinsic-applier';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Locks in the intrinsic-sizing predicates + applier.
|
|
13
|
+
*
|
|
14
|
+
* Canonical real-world repro: greenhouse-app `MarketPriceCard` timeframe
|
|
15
|
+
* row at lg / xl. The wrap row sits at the bottom of a chain like:
|
|
16
|
+
*
|
|
17
|
+
* Story Layout VERTICAL counter=FIXED w=1024 ← anchored (authored)
|
|
18
|
+
* └── Card root VERTICAL layoutAlign=STRETCH ← propagated
|
|
19
|
+
* └── CardHdr HORIZONTAL primary=FIXED ← authored FIXED width
|
|
20
|
+
* └── R-grp VERTICAL layoutGrow=0 hug ← chain BREAKS
|
|
21
|
+
* └── mid VERTICAL layoutAlign=STRETCH ← would-be propagated, but R-grp is HUG
|
|
22
|
+
* └── wrap HORIZONTAL+WRAP layoutAlign=STRETCH
|
|
23
|
+
*
|
|
24
|
+
* Everything from R-grp down is unanchored. `mid` and `wrap` each carry
|
|
25
|
+
* `layoutAlign=STRETCH` but their parents' width chain is HUG-of-children.
|
|
26
|
+
* Figma resolves the deadlock by stamping the frame-creation default
|
|
27
|
+
* (~100px) at the topmost HUG and cascading STRETCH down to it.
|
|
28
|
+
*
|
|
29
|
+
* The applier flips each unanchored-STRETCH child's `layoutAlign` to
|
|
30
|
+
* `INHERIT` and resets its horizontal sizing axis to AUTO so the chain
|
|
31
|
+
* resolves to natural HUG widths from the bottom up — the same result
|
|
32
|
+
* CSS computes via max-content.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Stub-frame harness — minimal FrameNode mock with parent links.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
interface StubFrame {
|
|
40
|
+
type: 'FRAME';
|
|
41
|
+
name: string;
|
|
42
|
+
layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
|
|
43
|
+
layoutPositioning: 'AUTO' | 'ABSOLUTE';
|
|
44
|
+
primaryAxisSizingMode: 'AUTO' | 'FIXED';
|
|
45
|
+
counterAxisSizingMode: 'AUTO' | 'FIXED';
|
|
46
|
+
primaryAxisAlignItems: string;
|
|
47
|
+
counterAxisAlignItems: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'BASELINE';
|
|
48
|
+
layoutAlign: 'INHERIT' | 'STRETCH' | 'MIN' | 'CENTER' | 'MAX';
|
|
49
|
+
layoutGrow: number;
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
parent: StubFrame | null;
|
|
53
|
+
children: StubFrame[];
|
|
54
|
+
appendChild(child: StubFrame): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mkFrame(name: string): StubFrame {
|
|
58
|
+
return {
|
|
59
|
+
type: 'FRAME',
|
|
60
|
+
name,
|
|
61
|
+
layoutMode: 'NONE',
|
|
62
|
+
layoutPositioning: 'AUTO',
|
|
63
|
+
primaryAxisSizingMode: 'AUTO',
|
|
64
|
+
counterAxisSizingMode: 'AUTO',
|
|
65
|
+
primaryAxisAlignItems: 'MIN',
|
|
66
|
+
counterAxisAlignItems: 'MIN',
|
|
67
|
+
layoutAlign: 'INHERIT',
|
|
68
|
+
layoutGrow: 0,
|
|
69
|
+
width: 100,
|
|
70
|
+
height: 100,
|
|
71
|
+
parent: null,
|
|
72
|
+
children: [],
|
|
73
|
+
appendChild(child) {
|
|
74
|
+
child.parent = this;
|
|
75
|
+
this.children.push(child);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface FrameInit {
|
|
81
|
+
name: string;
|
|
82
|
+
mode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
|
|
83
|
+
primaryFixed?: boolean;
|
|
84
|
+
counterFixed?: boolean;
|
|
85
|
+
layoutAlign?: 'INHERIT' | 'STRETCH' | 'MIN' | 'CENTER' | 'MAX';
|
|
86
|
+
layoutGrow?: number;
|
|
87
|
+
width?: number;
|
|
88
|
+
height?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function newFrame(init: FrameInit): StubFrame {
|
|
92
|
+
const f = mkFrame(init.name);
|
|
93
|
+
if (init.mode) f.layoutMode = init.mode;
|
|
94
|
+
if (init.primaryFixed) f.primaryAxisSizingMode = 'FIXED';
|
|
95
|
+
if (init.counterFixed) f.counterAxisSizingMode = 'FIXED';
|
|
96
|
+
if (init.layoutAlign) f.layoutAlign = init.layoutAlign;
|
|
97
|
+
if (init.layoutGrow != null) f.layoutGrow = init.layoutGrow;
|
|
98
|
+
if (init.width != null) f.width = init.width;
|
|
99
|
+
if (init.height != null) f.height = init.height;
|
|
100
|
+
return f;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const asNode = (f: StubFrame) => f as unknown as SceneNode;
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// hasDefiniteWidth — propagation
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
// Authored root: any orphaned frame is treated as definite.
|
|
110
|
+
{
|
|
111
|
+
const root = newFrame({ name: 'root', mode: 'VERTICAL', counterFixed: true, width: 1024 });
|
|
112
|
+
assert.equal(hasDefiniteWidth(asNode(root)), true, 'orphan root = definite');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// STRETCH child of VERTICAL parent that's definite → propagated.
|
|
116
|
+
{
|
|
117
|
+
const root = newFrame({ name: 'root', mode: 'VERTICAL', counterFixed: true, width: 1024 });
|
|
118
|
+
const child = newFrame({ name: 'child', mode: 'VERTICAL', layoutAlign: 'STRETCH' });
|
|
119
|
+
root.appendChild(child);
|
|
120
|
+
assert.equal(
|
|
121
|
+
hasDefiniteWidth(asNode(child)), true,
|
|
122
|
+
'STRETCH child of definite VERTICAL parent = definite',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// INHERIT VERTICAL child of VERTICAL parent without authored FIXED width
|
|
127
|
+
// → HUG counter, NOT propagated.
|
|
128
|
+
{
|
|
129
|
+
const root = newFrame({ name: 'root', mode: 'VERTICAL', counterFixed: true, width: 1024 });
|
|
130
|
+
const child = newFrame({ name: 'child', mode: 'VERTICAL' });
|
|
131
|
+
root.appendChild(child);
|
|
132
|
+
assert.equal(
|
|
133
|
+
hasDefiniteWidth(asNode(child)), false,
|
|
134
|
+
'INHERIT HUG-counter VERTICAL child of VERTICAL parent = not propagated',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// GROW child of HORIZONTAL parent that's definite → propagated.
|
|
139
|
+
{
|
|
140
|
+
const root = newFrame({ name: 'root', mode: 'HORIZONTAL', primaryFixed: true, width: 1024 });
|
|
141
|
+
const child = newFrame({ name: 'child', mode: 'HORIZONTAL', layoutGrow: 1 });
|
|
142
|
+
root.appendChild(child);
|
|
143
|
+
assert.equal(
|
|
144
|
+
hasDefiniteWidth(asNode(child)), true,
|
|
145
|
+
'GROW child of definite HORIZONTAL parent = definite',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// hasDefiniteWidth — explicit-FIXED short-circuit
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
// HORIZONTAL frame with primary=FIXED width=400 and layoutAlign=INHERIT is
|
|
154
|
+
// authored — even if its parent's chain isn't definite, the frame's own
|
|
155
|
+
// width is authored. CardHeader-shape: nested under a HUG-primary ancestor
|
|
156
|
+
// but explicitly sized by applyFullWidthIfPossible / w-[Npx].
|
|
157
|
+
{
|
|
158
|
+
const unknownParent = newFrame({ name: 'parent', mode: 'VERTICAL' });
|
|
159
|
+
const fixed = newFrame({
|
|
160
|
+
name: 'fixed', mode: 'HORIZONTAL', primaryFixed: true, width: 400,
|
|
161
|
+
});
|
|
162
|
+
unknownParent.appendChild(fixed);
|
|
163
|
+
assert.equal(
|
|
164
|
+
hasDefiniteWidth(asNode(fixed)), true,
|
|
165
|
+
'HORIZONTAL primary=FIXED + align!=STRETCH = authored definite',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// HORIZONTAL frame with primary=FIXED but layoutAlign=STRETCH is NOT
|
|
170
|
+
// authored — the FIXED is a Figma side-effect of STRETCH. Must fall
|
|
171
|
+
// through to parent propagation.
|
|
172
|
+
{
|
|
173
|
+
const unknownParent = newFrame({ name: 'parent', mode: 'VERTICAL' });
|
|
174
|
+
const sideEffect = newFrame({
|
|
175
|
+
name: 'side', mode: 'HORIZONTAL', primaryFixed: true, width: 100,
|
|
176
|
+
layoutAlign: 'STRETCH',
|
|
177
|
+
});
|
|
178
|
+
unknownParent.appendChild(sideEffect);
|
|
179
|
+
// Parent is orphan = root = definite. STRETCH child of VERTICAL parent
|
|
180
|
+
// propagates from parent → definite.
|
|
181
|
+
assert.equal(
|
|
182
|
+
hasDefiniteWidth(asNode(sideEffect)), true,
|
|
183
|
+
'STRETCH of definite VERTICAL parent propagates regardless of own FIXED side-effect',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Same shape but inside a non-definite chain → the STRETCH side-effect FIXED
|
|
188
|
+
// must not trick the predicate into definite.
|
|
189
|
+
{
|
|
190
|
+
const horizParent = newFrame({ name: 'hp', mode: 'HORIZONTAL' });
|
|
191
|
+
const vertHug = newFrame({ name: 'v', mode: 'VERTICAL' });
|
|
192
|
+
horizParent.appendChild(vertHug);
|
|
193
|
+
const sideEffect = newFrame({
|
|
194
|
+
name: 'side', mode: 'HORIZONTAL', primaryFixed: true, width: 100,
|
|
195
|
+
layoutAlign: 'STRETCH',
|
|
196
|
+
});
|
|
197
|
+
vertHug.appendChild(sideEffect);
|
|
198
|
+
// horizParent is root-orphan = definite. But vertHug is layoutGrow=0 in
|
|
199
|
+
// HORIZONTAL parent → not propagated → vertHug is NOT definite.
|
|
200
|
+
// Therefore STRETCH child of vertHug is also NOT definite.
|
|
201
|
+
assert.equal(
|
|
202
|
+
hasDefiniteWidth(asNode(vertHug)), false,
|
|
203
|
+
'HUG-primary child of HORIZONTAL parent breaks chain (per the bug shape)',
|
|
204
|
+
);
|
|
205
|
+
assert.equal(
|
|
206
|
+
hasDefiniteWidth(asNode(sideEffect)), false,
|
|
207
|
+
'STRETCH-of-unanchored-parent + FIXED side-effect = still NOT definite',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// VERTICAL frame with counter=FIXED + align=INHERIT = authored definite.
|
|
212
|
+
{
|
|
213
|
+
const wrapper = newFrame({ name: 'w', mode: 'VERTICAL' });
|
|
214
|
+
const fixed = newFrame({
|
|
215
|
+
name: 'fixed', mode: 'VERTICAL', counterFixed: true, width: 400,
|
|
216
|
+
});
|
|
217
|
+
wrapper.appendChild(fixed);
|
|
218
|
+
assert.equal(hasDefiniteWidth(asNode(fixed)), true, 'VERTICAL counter=FIXED + align=INHERIT authored');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// isUnanchoredStretchChild — the canonical bug shape
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
// MarketPriceCard chain: chain breaks at the right-group (HUG primary
|
|
226
|
+
// inside HORIZONTAL parent). Everything below is unanchored STRETCH.
|
|
227
|
+
{
|
|
228
|
+
const story = newFrame({ name: 'story', mode: 'VERTICAL', counterFixed: true, width: 1024 });
|
|
229
|
+
const card = newFrame({ name: 'card', mode: 'VERTICAL', layoutAlign: 'STRETCH' });
|
|
230
|
+
const hdr = newFrame({
|
|
231
|
+
name: 'hdr', mode: 'HORIZONTAL', primaryFixed: true, width: 952, layoutAlign: 'STRETCH',
|
|
232
|
+
});
|
|
233
|
+
const rgrp = newFrame({ name: 'rgrp', mode: 'VERTICAL', layoutGrow: 0 });
|
|
234
|
+
const mid = newFrame({
|
|
235
|
+
name: 'mid', mode: 'VERTICAL', layoutAlign: 'STRETCH', primaryFixed: true, width: 100,
|
|
236
|
+
});
|
|
237
|
+
const wrap = newFrame({
|
|
238
|
+
name: 'wrap', mode: 'HORIZONTAL', layoutAlign: 'STRETCH', primaryFixed: true, width: 100,
|
|
239
|
+
});
|
|
240
|
+
story.appendChild(card);
|
|
241
|
+
card.appendChild(hdr);
|
|
242
|
+
hdr.appendChild(rgrp);
|
|
243
|
+
rgrp.appendChild(mid);
|
|
244
|
+
mid.appendChild(wrap);
|
|
245
|
+
|
|
246
|
+
assert.equal(hasDefiniteWidth(asNode(rgrp)), false, 'rgrp breaks chain (HUG primary in HORIZONTAL hdr)');
|
|
247
|
+
assert.equal(hasDefiniteWidth(asNode(mid)), false, 'mid: STRETCH of unanchored parent');
|
|
248
|
+
assert.equal(hasDefiniteWidth(asNode(wrap)), false, 'wrap: STRETCH of unanchored parent');
|
|
249
|
+
|
|
250
|
+
assert.equal(isUnanchoredStretchChild(asNode(card)), false, 'card: STRETCH of definite parent — anchored');
|
|
251
|
+
assert.equal(isUnanchoredStretchChild(asNode(hdr)), false, 'hdr: STRETCH of definite parent (card) — anchored');
|
|
252
|
+
assert.equal(isUnanchoredStretchChild(asNode(rgrp)), false, 'rgrp: layoutAlign=INHERIT, not a stretch child');
|
|
253
|
+
assert.equal(isUnanchoredStretchChild(asNode(mid)), true, 'mid: unanchored stretch — flag');
|
|
254
|
+
assert.equal(isUnanchoredStretchChild(asNode(wrap)), true, 'wrap: unanchored stretch — flag');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Restricted to VERTICAL parents only — STRETCH children of HORIZONTAL
|
|
258
|
+
// parents are a height-deadlock concern, not a width-deadlock concern.
|
|
259
|
+
{
|
|
260
|
+
const horiz = newFrame({ name: 'h', mode: 'HORIZONTAL' });
|
|
261
|
+
const child = newFrame({ name: 'c', layoutAlign: 'STRETCH' });
|
|
262
|
+
horiz.appendChild(child);
|
|
263
|
+
assert.equal(
|
|
264
|
+
isUnanchoredStretchChild(asNode(child)), false,
|
|
265
|
+
'STRETCH child of HORIZONTAL parent is OUT of scope (height-axis)',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// breakHugStretchDeadlocks — end-to-end flip on MarketPriceCard chain
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
const story = newFrame({ name: 'story', mode: 'VERTICAL', counterFixed: true, width: 1024 });
|
|
275
|
+
const card = newFrame({ name: 'card', mode: 'VERTICAL', layoutAlign: 'STRETCH' });
|
|
276
|
+
const hdr = newFrame({
|
|
277
|
+
name: 'hdr', mode: 'HORIZONTAL', primaryFixed: true, width: 952, layoutAlign: 'STRETCH',
|
|
278
|
+
});
|
|
279
|
+
const rgrp = newFrame({ name: 'rgrp', mode: 'VERTICAL', layoutGrow: 0 });
|
|
280
|
+
const mid = newFrame({
|
|
281
|
+
name: 'mid', mode: 'VERTICAL', layoutAlign: 'STRETCH',
|
|
282
|
+
primaryFixed: true, counterFixed: true, width: 100,
|
|
283
|
+
});
|
|
284
|
+
const wrap = newFrame({
|
|
285
|
+
name: 'wrap', mode: 'HORIZONTAL', layoutAlign: 'STRETCH',
|
|
286
|
+
primaryFixed: true, width: 100,
|
|
287
|
+
});
|
|
288
|
+
story.appendChild(card);
|
|
289
|
+
card.appendChild(hdr);
|
|
290
|
+
hdr.appendChild(rgrp);
|
|
291
|
+
rgrp.appendChild(mid);
|
|
292
|
+
mid.appendChild(wrap);
|
|
293
|
+
|
|
294
|
+
const stats = breakHugStretchDeadlocks(asNode(story));
|
|
295
|
+
|
|
296
|
+
// Anchored frames untouched.
|
|
297
|
+
assert.equal(card.layoutAlign, 'STRETCH', 'card untouched (anchored)');
|
|
298
|
+
assert.equal(hdr.layoutAlign, 'STRETCH', 'hdr untouched (anchored)');
|
|
299
|
+
// Unanchored stretch children flipped.
|
|
300
|
+
assert.equal(mid.layoutAlign, 'INHERIT', 'mid flipped to INHERIT');
|
|
301
|
+
assert.equal(wrap.layoutAlign, 'INHERIT', 'wrap flipped to INHERIT');
|
|
302
|
+
// Sizing axes reset to AUTO so children hug naturally.
|
|
303
|
+
assert.equal(mid.counterAxisSizingMode, 'AUTO', 'mid VERTICAL: counter reset to AUTO');
|
|
304
|
+
assert.equal(wrap.primaryAxisSizingMode, 'AUTO', 'wrap HORIZONTAL: primary reset to AUTO');
|
|
305
|
+
|
|
306
|
+
assert.equal(stats.alignmentFlips, 2, 'flip count = 2 (mid + wrap)');
|
|
307
|
+
assert.equal(stats.nodesWalked, 6, 'walked entire chain (story+card+hdr+rgrp+mid+wrap)');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Pure-anchored tree: no flips at all.
|
|
311
|
+
{
|
|
312
|
+
const story = newFrame({ name: 'story', mode: 'VERTICAL', counterFixed: true, width: 1024 });
|
|
313
|
+
const a = newFrame({ name: 'a', mode: 'VERTICAL', layoutAlign: 'STRETCH' });
|
|
314
|
+
const b = newFrame({ name: 'b', mode: 'VERTICAL', layoutAlign: 'STRETCH' });
|
|
315
|
+
story.appendChild(a);
|
|
316
|
+
a.appendChild(b);
|
|
317
|
+
|
|
318
|
+
const stats = breakHugStretchDeadlocks(asNode(story));
|
|
319
|
+
|
|
320
|
+
assert.equal(stats.alignmentFlips, 0, 'fully-anchored tree: no flips');
|
|
321
|
+
assert.equal(a.layoutAlign, 'STRETCH', 'a untouched');
|
|
322
|
+
assert.equal(b.layoutAlign, 'STRETCH', 'b untouched');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Empty tree: walker doesn't crash on leaf nodes.
|
|
326
|
+
{
|
|
327
|
+
const leaf = newFrame({ name: 'leaf', mode: 'NONE' });
|
|
328
|
+
const stats = breakHugStretchDeadlocks(asNode(leaf));
|
|
329
|
+
assert.equal(stats.alignmentFlips, 0);
|
|
330
|
+
assert.equal(stats.nodesWalked, 1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
console.log('intrinsic-sizing-regression: ok');
|
|
@@ -0,0 +1,270 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Regression: story `args` resolution must handle three shapes that
|
|
8
|
+
* commonly appear in real consumer stories but were silently dropped or
|
|
9
|
+
* mis-parsed before:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Spread of a shared base** (`...BASE_ARGS`) — the canonical pattern
|
|
12
|
+
* for stories that mostly share args and only override one field:
|
|
13
|
+
*
|
|
14
|
+
* const BASE_ARGS = { marketSymbol: "SOL", activeSide: "long", ... };
|
|
15
|
+
* export const WithLegend = { args: { ...BASE_ARGS, markers: [...] } };
|
|
16
|
+
*
|
|
17
|
+
* Previously the scanner only walked `Node.isPropertyAssignment`,
|
|
18
|
+
* silently skipping `Node.isSpreadAssignment`. Every BASE_ARGS field
|
|
19
|
+
* went missing from `argsContext`, so the component body saw
|
|
20
|
+
* `marketSymbol = undefined` and rendered blank.
|
|
21
|
+
*
|
|
22
|
+
* 2. **`null` / `undefined` keyword literals** as story arg values —
|
|
23
|
+
* e.g. `error: null` in BASE_ARGS. `parseLiteralValue` previously
|
|
24
|
+
* fell through to `val.getText()` and returned the STRING `"null"`,
|
|
25
|
+
* which is truthy. Conditionals like `{error && <Banner>{error}</Banner>}`
|
|
26
|
+
* then rendered the banner with literal "null" text.
|
|
27
|
+
*
|
|
28
|
+
* 3. **Array literal property values** (`markers: [{...}, {...}]`) —
|
|
29
|
+
* `resolveExpressionValue` doesn't handle ArrayLiteralExpression, so
|
|
30
|
+
* the story-args loop set the arg to `undefined`. Inside the component
|
|
31
|
+
* `{markers.length ? <Legend/> : null}` then evaluated `undefined.length`
|
|
32
|
+
* → undefined → both branches skipped → legend missing. Fix: fall back
|
|
33
|
+
* to `parseLiteralValue` (already used by `extractPropsFromNode` for
|
|
34
|
+
* the JSX-prop equivalent).
|
|
35
|
+
*
|
|
36
|
+
* These fixes are exercised against an on-disk fixture project so the
|
|
37
|
+
* test models the production scan path (`pnpm inkbridge:scan`), not a
|
|
38
|
+
* synthetic in-memory variant.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
interface JsxNodeLike {
|
|
42
|
+
type: 'element' | 'text';
|
|
43
|
+
tagName?: string;
|
|
44
|
+
content?: string;
|
|
45
|
+
props?: Record<string, unknown>;
|
|
46
|
+
children?: JsxNodeLike[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface StoryShape {
|
|
50
|
+
name: string;
|
|
51
|
+
jsxTree?: JsxNodeLike;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ScannedShape {
|
|
55
|
+
name: string;
|
|
56
|
+
stories?: StoryShape[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findElement(node: JsxNodeLike | undefined | null, tag: string): JsxNodeLike | null {
|
|
60
|
+
if (!node || node.type !== 'element') return null;
|
|
61
|
+
if (node.tagName === tag) return node;
|
|
62
|
+
for (const child of node.children || []) {
|
|
63
|
+
const found = findElement(child, tag);
|
|
64
|
+
if (found) return found;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findAllElements(node: JsxNodeLike | undefined | null, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
|
|
70
|
+
if (!node || node.type !== 'element') return out;
|
|
71
|
+
if (node.tagName === tag) out.push(node);
|
|
72
|
+
for (const child of node.children || []) findAllElements(child, tag, out);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectText(node: JsxNodeLike | undefined | null, out: string[] = []): string[] {
|
|
77
|
+
if (!node) return out;
|
|
78
|
+
if (node.type === 'text') {
|
|
79
|
+
if (typeof node.content === 'string') out.push(node.content);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
for (const child of node.children || []) collectText(child, out);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const FIXTURE_DIR = path.resolve(
|
|
87
|
+
process.cwd(),
|
|
88
|
+
'tools/figma-plugin/scanner/__fixtures__/story-args-resolution'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Write fixture files to disk. The scanner discovers components via glob,
|
|
92
|
+
// so in-memory ts-morph files aren't enough — we need real files. Cleaned
|
|
93
|
+
// up at end of run so re-runs are idempotent.
|
|
94
|
+
function writeFixtures(): void {
|
|
95
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
96
|
+
fs.writeFileSync(
|
|
97
|
+
path.join(FIXTURE_DIR, 'Card.tsx'),
|
|
98
|
+
`
|
|
99
|
+
export interface CardProps {
|
|
100
|
+
title: string;
|
|
101
|
+
subtitle: string;
|
|
102
|
+
error: string | null;
|
|
103
|
+
activeSide: "long" | "short";
|
|
104
|
+
items: Array<{ id: string; label: string }>;
|
|
105
|
+
}
|
|
106
|
+
export function Card({ title, subtitle, error, activeSide, items }: CardProps) {
|
|
107
|
+
return (
|
|
108
|
+
<div>
|
|
109
|
+
<h1>{title}</h1>
|
|
110
|
+
<p>{subtitle}</p>
|
|
111
|
+
{error && <div data-role="error-banner">{error}</div>}
|
|
112
|
+
<button data-active={activeSide === "long" ? "true" : "false"}>{activeSide}</button>
|
|
113
|
+
{items.length ? (
|
|
114
|
+
<ul>
|
|
115
|
+
{items.map((it) => <li data-id={it.id}>{it.label}</li>)}
|
|
116
|
+
</ul>
|
|
117
|
+
) : null}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
'utf-8'
|
|
123
|
+
);
|
|
124
|
+
fs.writeFileSync(
|
|
125
|
+
path.join(FIXTURE_DIR, 'Card.stories.tsx'),
|
|
126
|
+
`
|
|
127
|
+
import { Card } from "./Card";
|
|
128
|
+
const meta = { component: Card };
|
|
129
|
+
export default meta;
|
|
130
|
+
|
|
131
|
+
const BASE_ARGS = {
|
|
132
|
+
title: "Hello",
|
|
133
|
+
subtitle: "from base",
|
|
134
|
+
error: null,
|
|
135
|
+
activeSide: "long" as const,
|
|
136
|
+
items: [],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Case 1: spread of base + override with array literal. Exercises
|
|
140
|
+
// the SpreadAssignment branch AND the ArrayLiteralExpression fallback
|
|
141
|
+
// in the PropertyAssignment branch.
|
|
142
|
+
export const WithItems = {
|
|
143
|
+
args: {
|
|
144
|
+
...BASE_ARGS,
|
|
145
|
+
items: [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Case 2: spread that includes a \`null\` keyword literal. The base's
|
|
150
|
+
// \`error: null\` MUST NOT become the string "null" (which is truthy
|
|
151
|
+
// and would render the error banner).
|
|
152
|
+
export const NoError = {
|
|
153
|
+
args: { ...BASE_ARGS },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Case 3: explicit override that flips a value. Verifies args
|
|
157
|
+
// ordering — properties after the spread win.
|
|
158
|
+
export const ShortSide = {
|
|
159
|
+
args: { ...BASE_ARGS, activeSide: "short" as const },
|
|
160
|
+
};
|
|
161
|
+
`,
|
|
162
|
+
'utf-8'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function cleanupFixtures(): void {
|
|
167
|
+
try {
|
|
168
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function run(): Promise<void> {
|
|
175
|
+
writeFixtures();
|
|
176
|
+
|
|
177
|
+
const scanner = new ComponentScanner({
|
|
178
|
+
componentPaths: [FIXTURE_DIR],
|
|
179
|
+
filePattern: '*.tsx',
|
|
180
|
+
exclude: [],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const results = (await scanner.scanAll()) as unknown as ScannedShape[];
|
|
184
|
+
const card = results.find((r) => r.name === 'Card');
|
|
185
|
+
assert.ok(card, `must find the Card component; got names: ${results.map((r) => r.name).join(', ')}`);
|
|
186
|
+
const stories = card.stories || [];
|
|
187
|
+
const byName = (n: string) => stories.find((s) => s.name === n || s.name.replace(/\s+/g, '') === n);
|
|
188
|
+
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
// Case 1: WithItems — spread + array literal override
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
{
|
|
193
|
+
const story = byName('WithItems');
|
|
194
|
+
assert.ok(story?.jsxTree, 'WithItems story must produce a jsxTree');
|
|
195
|
+
const h1 = findElement(story.jsxTree, 'h1');
|
|
196
|
+
const h1Text = collectText(h1).join('');
|
|
197
|
+
assert.ok(
|
|
198
|
+
h1Text.includes('Hello'),
|
|
199
|
+
`WithItems should inherit title="Hello" from BASE_ARGS via spread; got "${h1Text}"`,
|
|
200
|
+
);
|
|
201
|
+
const items = findAllElements(story.jsxTree, 'li');
|
|
202
|
+
assert.equal(
|
|
203
|
+
items.length,
|
|
204
|
+
2,
|
|
205
|
+
`WithItems should render 2 <li> from the array-literal override; got ${items.length}`,
|
|
206
|
+
);
|
|
207
|
+
const ids = items.map((it) => it.props?.['data-id']).join(',');
|
|
208
|
+
assert.equal(
|
|
209
|
+
ids,
|
|
210
|
+
'a,b',
|
|
211
|
+
`array-literal story-arg must preserve object keys; got "${ids}"`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -------------------------------------------------------------------------
|
|
216
|
+
// Case 2: NoError — spread including `error: null`
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
{
|
|
219
|
+
const story = byName('NoError');
|
|
220
|
+
assert.ok(story?.jsxTree, 'NoError story must produce a jsxTree');
|
|
221
|
+
// The error banner is the only nested <div> with data-role="error-banner".
|
|
222
|
+
function findByRole(n: JsxNodeLike | undefined, role: string): JsxNodeLike | null {
|
|
223
|
+
if (!n || n.type !== 'element') return null;
|
|
224
|
+
if (n.props?.['data-role'] === role) return n;
|
|
225
|
+
for (const c of n.children || []) {
|
|
226
|
+
const f = findByRole(c, role);
|
|
227
|
+
if (f) return f;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const errBanner = findByRole(story.jsxTree, 'error-banner');
|
|
232
|
+
assert.equal(
|
|
233
|
+
errBanner,
|
|
234
|
+
null,
|
|
235
|
+
`null literal must not be coerced to truthy string "null" — error banner should be absent`,
|
|
236
|
+
);
|
|
237
|
+
// h1 still resolves the title from the base.
|
|
238
|
+
const h1 = findElement(story.jsxTree, 'h1');
|
|
239
|
+
const h1Text = collectText(h1).join('');
|
|
240
|
+
assert.ok(h1Text.includes('Hello'), `NoError should inherit title from BASE_ARGS; got "${h1Text}"`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
// Case 3: ShortSide — explicit override after spread wins
|
|
245
|
+
// -------------------------------------------------------------------------
|
|
246
|
+
{
|
|
247
|
+
const story = byName('ShortSide');
|
|
248
|
+
assert.ok(story?.jsxTree, 'ShortSide story must produce a jsxTree');
|
|
249
|
+
const btn = findElement(story.jsxTree, 'button');
|
|
250
|
+
const btnText = collectText(btn).join('');
|
|
251
|
+
assert.ok(
|
|
252
|
+
btnText.includes('short'),
|
|
253
|
+
`explicit override should win over base; got button text "${btnText}"`,
|
|
254
|
+
);
|
|
255
|
+
assert.equal(
|
|
256
|
+
btn?.props?.['data-active'],
|
|
257
|
+
'false',
|
|
258
|
+
`activeSide override should flip the ternary; expected data-active="false" got "${btn?.props?.['data-active']}"`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log('story-args-resolution-regression: PASS (3 cases)');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
run()
|
|
266
|
+
.catch((err) => {
|
|
267
|
+
cleanupFixtures();
|
|
268
|
+
throw err;
|
|
269
|
+
})
|
|
270
|
+
.then(cleanupFixtures);
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
applyFullWidthIfPossible,
|
|
42
42
|
applyRingIfPossible,
|
|
43
43
|
applyVerticalFlexGrowIfPossible,
|
|
44
|
+
breakHugStretchDeadlocks,
|
|
44
45
|
enforceGrowChildPrimaryFixed,
|
|
45
46
|
extractGridColumns,
|
|
46
47
|
extractMaxWidth,
|
|
@@ -552,6 +553,17 @@ function populateStoryLayout(
|
|
|
552
553
|
// positions settle against the final heights.
|
|
553
554
|
walkVerticalFlexGrow(layout);
|
|
554
555
|
|
|
556
|
+
// Break the STRETCH-in-HUG-chain deadlock that CSS resolves via
|
|
557
|
+
// max-content but Figma cannot. For every HUG container in an
|
|
558
|
+
// unanchored chain that broadcasts STRETCH to its children, flip
|
|
559
|
+
// counterAxisAlignItems to MIN so children hug naturally and the
|
|
560
|
+
// container hugs to the widest child. See
|
|
561
|
+
// `src/layout/intrinsic-sizing.ts` for the predicate contract and
|
|
562
|
+
// `src/layout/intrinsic-applier.ts` for the transform rationale.
|
|
563
|
+
// Runs before the absolute-reflow pass so width readings downstream
|
|
564
|
+
// see the post-flip values.
|
|
565
|
+
breakHugStretchDeadlocks(layout);
|
|
566
|
+
|
|
555
567
|
// Final pass: resolve deferred absolute positioning after all story-level
|
|
556
568
|
// width/height/layout operations have completed.
|
|
557
569
|
reflowDeferredAbsolutePositioningTree(layout);
|
package/src/layout/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ export {
|
|
|
18
18
|
export type { LayoutIR, SizingMode } from './parser';
|
|
19
19
|
export * from './width-solver';
|
|
20
20
|
export * from './deferred-layout';
|
|
21
|
+
export * from './intrinsic-sizing';
|
|
22
|
+
export * from './intrinsic-applier';
|
|
21
23
|
export * from './layout-utils';
|
|
22
24
|
export * from './size-utils';
|
|
23
25
|
export * from './ring-utils';
|