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,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,109 @@
1
+ import assert from 'node:assert/strict';
2
+ import { ComponentScanner } from './component-scanner';
3
+
4
+ /**
5
+ * Regression: `stripPortalPositioningClasses` must KEEP responsive
6
+ * `max-w-*` variants. shadcn DialogContent declares its intended
7
+ * dialog width via `sm:max-w-lg` on the portal-rooted content node.
8
+ * If the scanner strips it, the renderer falls back to the generic
9
+ * 900px Story Layout default and the dialog preview is twice as wide
10
+ * as it would render in the browser.
11
+ *
12
+ * Earlier the strip rule was `/^(?:sm|md|lg|xl|2xl):max-w-/` —
13
+ * justified at the time by "the story's explicit max-w is
14
+ * authoritative." That principle only works for layouts where the
15
+ * consumer overrides; for components that publish their intended max
16
+ * width via responsive variants (every shadcn modal does), it
17
+ * silently destroys the width signal.
18
+ *
19
+ * The strip still removes legitimately Figma-irrelevant classes:
20
+ * fixed/absolute positioning, transforms, z-index, state-variant
21
+ * animations, transition durations, and uncomputable arbitrary
22
+ * `max-w-[calc(...)]` values.
23
+ *
24
+ * Real-world trigger: greenhouse-app DecreasePositionModal Default
25
+ * story renders at 900px instead of the 512px that
26
+ * `sm:max-w-lg` would produce.
27
+ */
28
+
29
+ interface TestScannerView {
30
+ stripPortalPositioningClasses: (className: string) => string;
31
+ }
32
+
33
+ const scanner = new ComponentScanner({
34
+ componentPaths: [],
35
+ filePattern: '*.tsx',
36
+ exclude: [],
37
+ }) as unknown as TestScannerView;
38
+
39
+ function strip(className: string): string {
40
+ return scanner.stripPortalPositioningClasses(className);
41
+ }
42
+
43
+ function tokens(className: string): string[] {
44
+ return className.split(/\s+/).filter(Boolean);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // KEEP: responsive max-w-* (the bug fix)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ {
52
+ const out = tokens(strip('grid w-full sm:max-w-lg p-6'));
53
+ assert.ok(out.indexOf('sm:max-w-lg') !== -1, 'sm:max-w-lg must survive — it is the dialog width signal');
54
+ }
55
+
56
+ {
57
+ // All breakpoint variants of max-w must survive.
58
+ for (const bp of ['sm', 'md', 'lg', 'xl', '2xl']) {
59
+ const cls = `${bp}:max-w-md`;
60
+ const out = tokens(strip(cls));
61
+ assert.ok(out.indexOf(cls) !== -1, `${cls} must survive — responsive max-w is a width hint, not noise`);
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // STRIP: things that are still meaningless in Figma
67
+ // ---------------------------------------------------------------------------
68
+
69
+ {
70
+ const out = tokens(strip('fixed top-[50%] left-[50%] z-50 grid w-full sm:max-w-lg'));
71
+ assert.equal(out.indexOf('fixed'), -1, 'fixed positioning is stripped');
72
+ assert.equal(out.indexOf('top-[50%]'), -1, 'top offset is stripped');
73
+ assert.equal(out.indexOf('left-[50%]'), -1, 'left offset is stripped');
74
+ assert.equal(out.indexOf('z-50'), -1, 'z-index is stripped');
75
+ assert.ok(out.indexOf('grid') !== -1, 'layout classes survive');
76
+ assert.ok(out.indexOf('w-full') !== -1, 'w-full survives');
77
+ assert.ok(out.indexOf('sm:max-w-lg') !== -1, 'sm:max-w-lg survives alongside the strip');
78
+ }
79
+
80
+ {
81
+ const out = tokens(strip('translate-x-[-50%] -translate-y-[50%] duration-200 ease-out'));
82
+ assert.equal(out.indexOf('translate-x-[-50%]'), -1, 'translate is stripped');
83
+ assert.equal(out.indexOf('-translate-y-[50%]'), -1, 'negative translate is stripped');
84
+ assert.equal(out.indexOf('duration-200'), -1, 'transition duration is stripped');
85
+ assert.equal(out.indexOf('ease-out'), -1, 'transition easing is stripped');
86
+ }
87
+
88
+ {
89
+ const out = tokens(strip('data-[state=open]:animate-in data-[state=closed]:fade-out-0 grid'));
90
+ assert.equal(out.indexOf('data-[state=open]:animate-in'), -1, 'state-variant animations are stripped');
91
+ assert.equal(out.indexOf('data-[state=closed]:fade-out-0'), -1, 'state-variant fade is stripped');
92
+ assert.ok(out.indexOf('grid') !== -1, 'layout class survives');
93
+ }
94
+
95
+ {
96
+ // Uncomputable max-w values still go.
97
+ const out = tokens(strip('max-w-[calc(100%-2rem)] sm:max-w-lg grid'));
98
+ assert.equal(out.indexOf('max-w-[calc(100%-2rem)]'), -1, 'calc-based max-w stripped (no px value)');
99
+ assert.ok(out.indexOf('sm:max-w-lg') !== -1, 'responsive max-w survives the same pass');
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Edge cases
104
+ // ---------------------------------------------------------------------------
105
+
106
+ assert.equal(strip(''), '', 'empty string round-trips');
107
+ assert.equal(strip(' '), '', 'whitespace-only collapses to empty');
108
+
109
+ console.log('portal-class-strip-regression: PASS');
@@ -0,0 +1,226 @@
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 {
9
+ isEffectivelyHidden,
10
+ getClassesForBreakpoint,
11
+ } from '../src/tailwind/responsive-analyzer';
12
+ import { collectTextContent } from '../src/design-system/selectable-state';
13
+ import type { NodeIR } from '../src/tailwind';
14
+
15
+ /**
16
+ * Locks in the responsive-hidden semantics for inline-text children.
17
+ *
18
+ * Bug class: a pair like
19
+ * <span sm:hidden>Collat Δ</span>
20
+ * <span hidden sm:inline>Collateral Δ</span>
21
+ * inside a `<th>` previously rendered both labels concatenated. The
22
+ * inline-collapse path (`createInlineTextNode` →
23
+ * `collectInlineSegments`) walked `node.children` directly, bypassing
24
+ * the per-child renderer (which already drops children with
25
+ * `display: hidden`). Both spans contributed text and the result was
26
+ * "Collat Δ Collateral Δ".
27
+ *
28
+ * Two pieces protect against regression:
29
+ * - `isEffectivelyHidden` applies last-wins display-utility resolution
30
+ * on an already-cascaded class list.
31
+ * - `getClassesForBreakpoint` is the cascade used to feed
32
+ * `isEffectivelyHidden` from a raw class list.
33
+ *
34
+ * Together they let the ui-builder filter hidden children before the
35
+ * inline-collapse path sees them.
36
+ */
37
+
38
+ function run(): void {
39
+ // ---------------------------------------------------------------------
40
+ // isEffectivelyHidden — last-wins across display utilities
41
+ // ---------------------------------------------------------------------
42
+ assert.equal(isEffectivelyHidden([]), false, 'empty class list → not hidden');
43
+ assert.equal(isEffectivelyHidden(['flex']), false, 'flex → not hidden');
44
+ assert.equal(isEffectivelyHidden(['hidden']), true, 'hidden → hidden');
45
+ assert.equal(
46
+ isEffectivelyHidden(['hidden', 'inline']),
47
+ false,
48
+ 'hidden then inline → inline wins (not hidden)',
49
+ );
50
+ assert.equal(
51
+ isEffectivelyHidden(['inline', 'hidden']),
52
+ true,
53
+ 'inline then hidden → hidden wins',
54
+ );
55
+ assert.equal(
56
+ isEffectivelyHidden(['hidden', 'text-sm', 'font-medium']),
57
+ true,
58
+ 'non-display utilities do not flip the hidden state',
59
+ );
60
+ assert.equal(
61
+ isEffectivelyHidden(['!hidden']),
62
+ true,
63
+ 'important hidden still counted as the hidden display',
64
+ );
65
+
66
+ // ---------------------------------------------------------------------
67
+ // getClassesForBreakpoint — cascade producing the input above
68
+ // ---------------------------------------------------------------------
69
+ // Span 1: base `sm:hidden`. Hidden once we reach the sm breakpoint.
70
+ assert.deepEqual(
71
+ getClassesForBreakpoint(['sm:hidden'], 'base'),
72
+ [],
73
+ 'sm:hidden at base → empty (visible)',
74
+ );
75
+ assert.deepEqual(
76
+ getClassesForBreakpoint(['sm:hidden'], 'sm'),
77
+ ['hidden'],
78
+ 'sm:hidden at sm → [hidden]',
79
+ );
80
+ assert.deepEqual(
81
+ getClassesForBreakpoint(['sm:hidden'], 'md'),
82
+ ['hidden'],
83
+ 'sm:hidden at md → [hidden] (cascades up)',
84
+ );
85
+
86
+ // Span 2: base `hidden`, opt back in at sm with `sm:inline`.
87
+ assert.deepEqual(
88
+ getClassesForBreakpoint(['hidden', 'sm:inline'], 'base'),
89
+ ['hidden'],
90
+ 'hidden + sm:inline at base → [hidden] (visible-at-base = false)',
91
+ );
92
+ assert.deepEqual(
93
+ getClassesForBreakpoint(['hidden', 'sm:inline'], 'sm'),
94
+ ['hidden', 'inline'],
95
+ 'hidden + sm:inline at sm → [hidden, inline] (inline wins)',
96
+ );
97
+ assert.deepEqual(
98
+ getClassesForBreakpoint(['hidden', 'sm:inline'], 'md'),
99
+ ['hidden', 'inline'],
100
+ 'hidden + sm:inline at md → [hidden, inline] (cascades)',
101
+ );
102
+
103
+ // ---------------------------------------------------------------------
104
+ // End-to-end: feed cascade into isEffectivelyHidden — this is the
105
+ // exact check the ui-builder renderChildren filter performs.
106
+ // ---------------------------------------------------------------------
107
+ const span1Classes = ['sm:hidden'];
108
+ const span2Classes = ['hidden', 'sm:inline'];
109
+ const breakpoints = ['base', 'sm', 'md', 'lg', 'xl', '2xl'];
110
+
111
+ // Span 1: visible at base, hidden at sm+.
112
+ for (const bp of breakpoints) {
113
+ const resolved = getClassesForBreakpoint(span1Classes, bp);
114
+ const hidden = isEffectivelyHidden(resolved);
115
+ if (bp === 'base') {
116
+ assert.equal(hidden, false, `span1 at ${bp}: should be visible`);
117
+ } else {
118
+ assert.equal(hidden, true, `span1 at ${bp}: should be hidden`);
119
+ }
120
+ }
121
+
122
+ // Span 2: hidden at base, visible at sm+.
123
+ for (const bp of breakpoints) {
124
+ const resolved = getClassesForBreakpoint(span2Classes, bp);
125
+ const hidden = isEffectivelyHidden(resolved);
126
+ if (bp === 'base') {
127
+ assert.equal(hidden, true, `span2 at ${bp}: should be hidden`);
128
+ } else {
129
+ assert.equal(hidden, false, `span2 at ${bp}: should be visible`);
130
+ }
131
+ }
132
+
133
+ // Exactly one of the pair must be visible at every breakpoint —
134
+ // otherwise we'd either lose the label or render both concatenated.
135
+ for (const bp of breakpoints) {
136
+ const span1Hidden = isEffectivelyHidden(getClassesForBreakpoint(span1Classes, bp));
137
+ const span2Hidden = isEffectivelyHidden(getClassesForBreakpoint(span2Classes, bp));
138
+ assert.equal(
139
+ span1Hidden !== span2Hidden,
140
+ true,
141
+ `at ${bp}: exactly one of (span1, span2) must be visible`,
142
+ );
143
+ }
144
+
145
+ // ---------------------------------------------------------------------
146
+ // collectTextContent must honor last-display-wins too.
147
+ //
148
+ // PerpsHeader real-world repro:
149
+ // <p className="hidden text-muted-foreground sm:block">
150
+ // Trade SOL, ETH, and BTC perpetual futures…
151
+ // </p>
152
+ //
153
+ // Resolved at sm+: classes = ['hidden', 'text-muted-foreground', 'block'].
154
+ // `block` is the last display utility → element is visible. But an older
155
+ // `collectTextContent` checked `classes.includes('hidden')` directly and
156
+ // returned `''`, causing the text-element fallthrough in `buildFigmaNode`
157
+ // to bail on `if (!textContent.trim()) return null` — the entire `<p>`
158
+ // disappeared at every responsive tile even though the outer hidden
159
+ // gate correctly let it through.
160
+ // ---------------------------------------------------------------------
161
+ function pNode(classes: string[], text: string): NodeIR {
162
+ return {
163
+ kind: 'element',
164
+ tagName: 'p',
165
+ tagLower: 'p',
166
+ classes,
167
+ props: {},
168
+ children: [{ kind: 'text', text }],
169
+ } as NodeIR;
170
+ }
171
+
172
+ // Resolved at sm+: hidden + block → block wins → visible → text collected.
173
+ assert.equal(
174
+ collectTextContent(pNode(['hidden', 'text-muted-foreground', 'block'], 'Trade SOL')),
175
+ 'Trade SOL',
176
+ 'collectTextContent must collect text when display cascade ends in `block` (last-wins, not includes-hidden)',
177
+ );
178
+
179
+ // Resolved at base: hidden alone → hidden → empty.
180
+ assert.equal(
181
+ collectTextContent(pNode(['hidden', 'text-muted-foreground'], 'Trade SOL')),
182
+ '',
183
+ 'collectTextContent must skip text when display cascade ends in `hidden`',
184
+ );
185
+
186
+ // sr-only still skipped (separate code path).
187
+ assert.equal(
188
+ collectTextContent(pNode(['sr-only'], 'Screen-reader only')),
189
+ '',
190
+ 'collectTextContent skips sr-only nodes',
191
+ );
192
+
193
+ // Nested: parent visible, deep text node behind a `hidden` block child → that child skipped, sibling collected.
194
+ const nested: NodeIR = {
195
+ kind: 'element',
196
+ tagName: 'div',
197
+ tagLower: 'div',
198
+ classes: [],
199
+ props: {},
200
+ children: [
201
+ {
202
+ kind: 'element',
203
+ tagName: 'span',
204
+ tagLower: 'span',
205
+ classes: ['hidden'],
206
+ props: {},
207
+ children: [{ kind: 'text', text: 'invisible' }],
208
+ } as NodeIR,
209
+ { kind: 'text', text: 'visible' },
210
+ ],
211
+ } as NodeIR;
212
+ assert.equal(
213
+ collectTextContent(nested),
214
+ 'visible',
215
+ 'collectTextContent skips hidden children inside a visible parent',
216
+ );
217
+
218
+ console.log('responsive-hidden-inline-regression: PASS');
219
+ }
220
+
221
+ try {
222
+ run();
223
+ } catch (err) {
224
+ console.error(err);
225
+ process.exit(1);
226
+ }