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.
@@ -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);
@@ -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';