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,432 @@
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: the expression evaluator in `ComponentScanner.resolveExpressionValue`
8
+ * must handle three families of JSX expressions that previously rendered
9
+ * as raw arg values (or empty) in Figma. Each family corresponds to a
10
+ * phase in the rollout:
11
+ *
12
+ * P1. Built-in method calls and global conversions —
13
+ * `trade.txHash.slice(0, 6)`, `Number(value)`, `"x".padStart(...)`,
14
+ * `Math.abs(...)`, `Number.isFinite(...)`.
15
+ *
16
+ * P2. Pure-instance constructors at module scope —
17
+ * `const fmt = new Intl.NumberFormat("en-US", { ... });`
18
+ * followed by `fmt.format(N)` in the JSX body. The evaluator
19
+ * resolves the identifier to the live JS instance and calls
20
+ * `.format()` at scan time. Also covers `new Date(x).toISOString()`.
21
+ *
22
+ * P3. User-defined function evaluation — `formatUsd(trade.price)`
23
+ * where `formatUsd` is a pure arrow function in the same file or
24
+ * in an imported module (mirrors the perps utils pattern).
25
+ *
26
+ * Background: before this lands, the scanner returned the raw first
27
+ * argument for any user-function call and dropped method-chain results
28
+ * entirely. Figma rendered `172800000000` instead of `$172,800,000,000.00`
29
+ * and tx-hash cells collapsed to just the em-dash separator. Fix: make
30
+ * the evaluator a small AST interpreter that handles pure JS constructs
31
+ * found in real consumer code. Adding a new evaluator path? Add a case
32
+ * here too — silent drift between scanner reality and consumer
33
+ * expectations is the recurring bug class this fixture exists to prevent.
34
+ */
35
+
36
+ interface JsxNodeLike {
37
+ type: 'element' | 'text';
38
+ tagName?: string;
39
+ content?: string;
40
+ props?: Record<string, unknown>;
41
+ children?: JsxNodeLike[];
42
+ }
43
+
44
+ interface StoryShape {
45
+ name: string;
46
+ jsxTree?: JsxNodeLike;
47
+ }
48
+
49
+ interface ScannedShape {
50
+ name: string;
51
+ stories?: StoryShape[];
52
+ }
53
+
54
+ function findElementByRole(node: JsxNodeLike | undefined | null, role: string): JsxNodeLike | null {
55
+ if (!node || node.type !== 'element') return null;
56
+ if (node.props?.['data-role'] === role) return node;
57
+ for (const child of node.children || []) {
58
+ const found = findElementByRole(child, role);
59
+ if (found) return found;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function collectText(node: JsxNodeLike | undefined | null, out: string[] = []): string[] {
65
+ if (!node) return out;
66
+ if (node.type === 'text') {
67
+ if (typeof node.content === 'string') out.push(node.content);
68
+ return out;
69
+ }
70
+ for (const child of node.children || []) collectText(child, out);
71
+ return out;
72
+ }
73
+
74
+ const FIXTURE_DIR = path.resolve(
75
+ process.cwd(),
76
+ 'tools/figma-plugin/scanner/__fixtures__/expression-evaluator'
77
+ );
78
+
79
+ function writeFixtures(): void {
80
+ fs.mkdirSync(FIXTURE_DIR, { recursive: true });
81
+
82
+ // Shared constants module — exercises the imported-object-literal /
83
+ // imported-array-literal resolution path (P5). Without this, the
84
+ // perps TOKEN_METADATA[mint]?.symbol lookup returned undefined and
85
+ // the symbol display fell back to a 4-char mint slice.
86
+ fs.writeFileSync(
87
+ path.join(FIXTURE_DIR, 'constants.ts'),
88
+ `
89
+ export const TOKEN_LABELS = {
90
+ "So11111111111111111111111111111111111111112": { symbol: "SOL", name: "Solana" },
91
+ "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs": { symbol: "ETH", name: "Ether" },
92
+ } as const;
93
+
94
+ export const SUPPORTED_MINTS: string[] = [
95
+ "So11111111111111111111111111111111111111112",
96
+ "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs",
97
+ ];
98
+ `,
99
+ 'utf-8'
100
+ );
101
+
102
+ // Shared utils module — the user-function evaluator must follow the
103
+ // import and walk this file's function bodies. Mirrors the perps
104
+ // `~/domains/perps/utils` layout.
105
+ fs.writeFileSync(
106
+ path.join(FIXTURE_DIR, 'utils.ts'),
107
+ `
108
+ export const usdFormatter = new Intl.NumberFormat("en-US", {
109
+ style: "currency",
110
+ currency: "USD",
111
+ minimumFractionDigits: 2,
112
+ maximumFractionDigits: 2,
113
+ });
114
+
115
+ export const baseUnitsToNumber = (value?: string | null, decimals = 6) => {
116
+ if (value === undefined || value === null) return null;
117
+ const numeric = Number(value);
118
+ if (!Number.isFinite(numeric)) return null;
119
+ return numeric / 10 ** decimals;
120
+ };
121
+
122
+ export const formatUsd = (value?: string | null, fallback = "-") => {
123
+ if (!value && value !== "0") return fallback;
124
+ const numeric = Number(value);
125
+ if (!Number.isFinite(numeric)) return fallback;
126
+ return usdFormatter.format(numeric);
127
+ };
128
+
129
+ export const formatUsdFromBase = (value?: string | null) => {
130
+ const num = baseUnitsToNumber(value, 6);
131
+ return num === null ? "-" : usdFormatter.format(num);
132
+ };
133
+ `,
134
+ 'utf-8'
135
+ );
136
+
137
+ fs.writeFileSync(
138
+ path.join(FIXTURE_DIR, 'Receipt.tsx'),
139
+ `
140
+ import { formatUsd, formatUsdFromBase } from "./utils";
141
+ import { TOKEN_LABELS, SUPPORTED_MINTS } from "./constants";
142
+
143
+ export interface Trade {
144
+ txHash: string;
145
+ priceRaw: string;
146
+ feeBaseUnits: string;
147
+ countBig: number;
148
+ mint: string;
149
+ }
150
+
151
+ export interface ReceiptProps {
152
+ trade: Trade;
153
+ // Optional list — when undefined, the component must still treat
154
+ // the visibility gate as falsy and pick the DEFAULT branch.
155
+ activeGroups?: Array<"price" | "market">;
156
+ }
157
+
158
+ const DEFAULT_ACTIVE: Array<"price" | "market"> = ["price", "market"];
159
+
160
+ // Module-level Intl instance — P2 must follow the identifier back to
161
+ // the constructor and dispatch \`.format()\` against a live instance.
162
+ const inlineUsdFmt = new Intl.NumberFormat("en-US", {
163
+ style: "currency",
164
+ currency: "USD",
165
+ });
166
+
167
+ export function Receipt({ trade, activeGroups }: ReceiptProps) {
168
+ // P4: optional-prop guard + Set + Set.has — the DataSourceDetailsDialog
169
+ // visibility-gate pattern. \`activeGroups\` is missing in args, so the
170
+ // \`&&\` must short-circuit to false and the ternary must pick
171
+ // DEFAULT_ACTIVE. \`new Set(...)\` constructs at scan time. \`.has(...)\`
172
+ // dispatches against the live Set prototype.
173
+ const active = new Set(activeGroups && activeGroups.length ? activeGroups : DEFAULT_ACTIVE);
174
+ return (
175
+ <div>
176
+ {/* P1: String.prototype.slice with positive + negative indices */}
177
+ <span data-role="hash-short">
178
+ {trade.txHash.slice(0, 6)}…{trade.txHash.slice(-4)}
179
+ </span>
180
+ {/* P1: Number global + Number method */}
181
+ <span data-role="count-padded">{Number(trade.countBig).toString().padStart(8, "0")}</span>
182
+ {/* P1: Math global */}
183
+ <span data-role="abs-of-neg">{Math.abs(-42)}</span>
184
+ {/* P2: Intl.NumberFormat instance + .format() chain */}
185
+ <span data-role="price-inline">{inlineUsdFmt.format(Number(trade.priceRaw))}</span>
186
+ {/* P3: user-function evaluation, single arg */}
187
+ <span data-role="price-user-fn">{formatUsd(trade.priceRaw)}</span>
188
+ {/* P3: chained user-function evaluation (formatUsdFromBase ->
189
+ baseUnitsToNumber -> usdFormatter.format) */}
190
+ <span data-role="fee-from-base">{formatUsdFromBase(trade.feeBaseUnits)}</span>
191
+ {/* P3: user-function with default-param fallback path */}
192
+ <span data-role="missing-fallback">{formatUsd(null)}</span>
193
+ {/* P3: IIFE — inline arrow invoked immediately to scope a few
194
+ statements inside a single JSX expression. Real-world case:
195
+ perps RecentTradeDetailsDialog Collateral Delta cell. Tests
196
+ if-return, local const binding, template literal, and a
197
+ chained user-fn + Intl call inside an anonymous arrow. */}
198
+ <span data-role="delta-iife">
199
+ {(() => {
200
+ const delta = formatUsdFromBase(trade.priceRaw);
201
+ if (delta === "-") return "-";
202
+ const label = trade.countBig > 0 ? "up" : "down";
203
+ return label + " " + delta;
204
+ })()}
205
+ </span>
206
+ {/* P4: Set membership gate. Both branches must render when the
207
+ Set was built from DEFAULT_ACTIVE (active.has returns true). */}
208
+ {active.has("price") ? <span data-role="set-price-section">price-on</span> : null}
209
+ {active.has("market") ? <span data-role="set-market-section">market-on</span> : null}
210
+ {/* P4: Set negative case — missing key must return false and
211
+ hide the section, not undefined (which would drop on
212
+ JSX-walker fall-through). */}
213
+ {active.has("nonexistent") ? <span data-role="set-missing-section">should-not-render</span> : <span data-role="set-missing-fallback">missing-off</span>}
214
+ {/* P5: simple imported-object lookup (no cast) */}
215
+ <span data-role="symbol-direct">{TOKEN_LABELS[trade.mint]?.symbol ?? "unknown"}</span>
216
+ {/* P5: AsExpression unwrap — \`(trade as any).mint\` must
217
+ evaluate through the cast so the imported-object lookup
218
+ below can read .symbol. Mirrors the perps pattern
219
+ \`TOKEN_METADATA[(position as any).marketMint]?.symbol\`. */}
220
+ <span data-role="symbol-via-cast">{TOKEN_LABELS[(trade as any).mint]?.symbol ?? "unknown"}</span>
221
+ {/* P5: Imported array literal — SUPPORTED_MINTS comes from
222
+ ./constants. \`.includes(trade.mint)\` must dispatch
223
+ against the resolved array (not undefined). */}
224
+ <span data-role="mint-supported">{SUPPORTED_MINTS.includes(trade.mint) ? "supported" : "unsupported"}</span>
225
+ </div>
226
+ );
227
+ }
228
+ `,
229
+ 'utf-8'
230
+ );
231
+
232
+ fs.writeFileSync(
233
+ path.join(FIXTURE_DIR, 'Receipt.stories.tsx'),
234
+ `
235
+ import { Receipt } from "./Receipt";
236
+ const meta = { component: Receipt };
237
+ export default meta;
238
+
239
+ export const Default = {
240
+ args: {
241
+ trade: {
242
+ txHash: "5xKx8mP3qN7vYbR2tF6gH4nDcWeZL9aJpKmU8oXyB1sErT3hQ",
243
+ priceRaw: "172800",
244
+ feeBaseUnits: "2500000",
245
+ countBig: 7,
246
+ mint: "So11111111111111111111111111111111111111112",
247
+ },
248
+ },
249
+ };
250
+ `,
251
+ 'utf-8'
252
+ );
253
+ }
254
+
255
+ function cleanupFixtures(): void {
256
+ try {
257
+ fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
258
+ } catch {
259
+ /* ignore */
260
+ }
261
+ }
262
+
263
+ async function run(): Promise<void> {
264
+ writeFixtures();
265
+
266
+ const scanner = new ComponentScanner({
267
+ componentPaths: [FIXTURE_DIR],
268
+ filePattern: '*.tsx',
269
+ exclude: [],
270
+ });
271
+
272
+ const results = (await scanner.scanAll()) as unknown as ScannedShape[];
273
+ const receipt = results.find((r) => r.name === 'Receipt');
274
+ assert.ok(receipt, `must find Receipt; got: ${results.map((r) => r.name).join(', ')}`);
275
+ const story = (receipt.stories || []).find((s) => s.name === 'Default');
276
+ assert.ok(story?.jsxTree, 'Default story must produce a jsxTree');
277
+
278
+ const textForRole = (role: string): string => {
279
+ const el = findElementByRole(story.jsxTree, role);
280
+ assert.ok(el, `expected element with data-role="${role}" in story tree`);
281
+ return collectText(el).join('');
282
+ };
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // P1: built-in string/number method calls + global conversions
286
+ // ---------------------------------------------------------------------------
287
+ {
288
+ const hashShort = textForRole('hash-short');
289
+ assert.equal(
290
+ hashShort,
291
+ '5xKx8m…T3hQ',
292
+ `P1 string.slice(start)/slice(-n) must evaluate to the truncated hash; got "${hashShort}"`,
293
+ );
294
+ }
295
+ {
296
+ const countPadded = textForRole('count-padded');
297
+ assert.equal(
298
+ countPadded,
299
+ '00000007',
300
+ `P1 Number(...).toString().padStart(...) chain must evaluate; got "${countPadded}"`,
301
+ );
302
+ }
303
+ {
304
+ const abs = textForRole('abs-of-neg');
305
+ assert.equal(abs, '42', `P1 Math.abs(-42) must evaluate to "42"; got "${abs}"`);
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // P2: Intl.NumberFormat module instance + .format()
310
+ // ---------------------------------------------------------------------------
311
+ {
312
+ const priceInline = textForRole('price-inline');
313
+ assert.equal(
314
+ priceInline,
315
+ '$172,800.00',
316
+ `P2 inline Intl.NumberFormat instance must format numeric arg; got "${priceInline}"`,
317
+ );
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // P3: user-function evaluation (single arg, chained, default-param)
322
+ // ---------------------------------------------------------------------------
323
+ {
324
+ const priceUserFn = textForRole('price-user-fn');
325
+ assert.equal(
326
+ priceUserFn,
327
+ '$172,800.00',
328
+ `P3 formatUsd(priceRaw) must evaluate via the imported arrow function and Intl chain; got "${priceUserFn}"`,
329
+ );
330
+ }
331
+ {
332
+ const feeFromBase = textForRole('fee-from-base');
333
+ assert.equal(
334
+ feeFromBase,
335
+ '$2.50',
336
+ `P3 formatUsdFromBase -> baseUnitsToNumber chain ($2500000 / 10^6 = 2.5) must format to "$2.50"; got "${feeFromBase}"`,
337
+ );
338
+ }
339
+ {
340
+ const missingFallback = textForRole('missing-fallback');
341
+ assert.equal(
342
+ missingFallback,
343
+ '-',
344
+ `P3 user-fn must honour the default-param fallback path when arg is null; got "${missingFallback}"`,
345
+ );
346
+ }
347
+ {
348
+ const deltaIife = textForRole('delta-iife');
349
+ // 172800 / 10**6 (USD_DECIMALS in baseUnitsToNumber) = 0.1728 → "$0.17"
350
+ // Prefixed with "up " by the IIFE's binary-op + ternary path.
351
+ assert.equal(
352
+ deltaIife,
353
+ 'up $0.17',
354
+ `P3 IIFE must interpret inline arrow body (if-return + ternary + string concat + chained user-fn / Intl); got "${deltaIife}"`,
355
+ );
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // P4: Set construction + .has() membership gate, plus optional-prop short
360
+ // circuit (activeGroups undefined → ternary picks DEFAULT_ACTIVE).
361
+ //
362
+ // Real-world trigger: perps DataSourceDetailsDialog renders nothing past
363
+ // the header when activeGroups is omitted because the scanner couldn't
364
+ // resolve `new Set(...)` / `.has(...)` and every section's
365
+ // `isXActive ? <Section/> : null` evaluated to undefined.
366
+ // ---------------------------------------------------------------------------
367
+ {
368
+ const priceSection = textForRole('set-price-section');
369
+ assert.equal(
370
+ priceSection,
371
+ 'price-on',
372
+ `P4 Set.has("price") (from DEFAULT_ACTIVE) must return true → section renders; got "${priceSection}"`,
373
+ );
374
+ }
375
+ {
376
+ const marketSection = textForRole('set-market-section');
377
+ assert.equal(
378
+ marketSection,
379
+ 'market-on',
380
+ `P4 Set.has("market") (from DEFAULT_ACTIVE) must return true → section renders; got "${marketSection}"`,
381
+ );
382
+ }
383
+ {
384
+ const missingFallbackSection = textForRole('set-missing-fallback');
385
+ assert.equal(
386
+ missingFallbackSection,
387
+ 'missing-off',
388
+ `P4 Set.has("nonexistent") must return false → ternary picks the fallback span; got "${missingFallbackSection}"`,
389
+ );
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // P5: AsExpression unwrap + imported object/array literal resolution.
394
+ // Real-world trigger: perps StopLossModal renders `LONG So11` (4-char
395
+ // mint slice fallback) instead of `LONG SOL` because `TOKEN_METADATA[
396
+ // (position as any).marketMint]?.symbol` couldn't resolve through the
397
+ // type assertion and the imported lookup table.
398
+ // ---------------------------------------------------------------------------
399
+ {
400
+ const symbolDirect = textForRole('symbol-direct');
401
+ assert.equal(
402
+ symbolDirect,
403
+ 'SOL',
404
+ `P5 imported-object lookup (no cast) must resolve TOKEN_LABELS[trade.mint].symbol; got "${symbolDirect}"`,
405
+ );
406
+ }
407
+ {
408
+ const symbolViaCast = textForRole('symbol-via-cast');
409
+ assert.equal(
410
+ symbolViaCast,
411
+ 'SOL',
412
+ `P5 (trade as any).mint must unwrap through AsExpression AND TOKEN_LABELS imported object lookup must resolve; got "${symbolViaCast}"`,
413
+ );
414
+ }
415
+ {
416
+ const mintSupported = textForRole('mint-supported');
417
+ assert.equal(
418
+ mintSupported,
419
+ 'supported',
420
+ `P5 imported array (SUPPORTED_MINTS).includes(trade.mint) must dispatch against the resolved array; got "${mintSupported}"`,
421
+ );
422
+ }
423
+
424
+ console.log('expression-evaluator-regression: PASS (13 cases — P1 + P2 + P3 + IIFE + P4 Set + P5 cast/imports)');
425
+ }
426
+
427
+ run()
428
+ .catch((err) => {
429
+ cleanupFixtures();
430
+ throw err;
431
+ })
432
+ .then(cleanupFixtures);
@@ -477,4 +477,160 @@ function scrollAreaTree(extraScrollbarClasses: string[] = []): NodeIR {
477
477
  );
478
478
  }
479
479
 
480
- console.log('framework-adapter-shadcn-regression: PASS (3 registry + 4 leaf + 4 avatar + 5 progress + 5 slider + 2 scroll-area cases)');
480
+ // (i3) When the ScrollArea wraps a `<table>`, replace
481
+ // ScrollAreaPrimitive.Root with a plain `<div>` that inherits Root's
482
+ // classes (the consumer's padding from the `className` prop). The
483
+ // synthetic div is `kind: 'element'` so it gets the normal block-
484
+ // stretch-to-parent-width treatment instead of staying HUG-primary
485
+ // like the original `kind: 'component'` Root.
486
+ {
487
+ const tableInsideScrollArea = el({}, [], [
488
+ {
489
+ kind: 'element',
490
+ tagName: 'ScrollAreaPrimitive.Root',
491
+ tagLower: 'div',
492
+ props: {},
493
+ classes: ['relative', 'overflow-hidden', 'px-4', 'sm:pl-3', 'sm:pr-0'],
494
+ children: [
495
+ {
496
+ kind: 'element',
497
+ tagName: 'ScrollAreaPrimitive.Viewport',
498
+ tagLower: 'div',
499
+ props: {},
500
+ classes: ['h-full', 'w-full'],
501
+ children: [{
502
+ kind: 'element',
503
+ tagName: 'table',
504
+ tagLower: 'table',
505
+ props: {},
506
+ classes: ['min-w-full'],
507
+ children: [],
508
+ }],
509
+ },
510
+ {
511
+ kind: 'element',
512
+ tagName: 'ScrollAreaPrimitive.Scrollbar',
513
+ tagLower: 'div',
514
+ props: {},
515
+ classes: [],
516
+ children: [],
517
+ },
518
+ ],
519
+ } as unknown as NodeIR,
520
+ ]);
521
+ const next = applyShadcnAdapter(tableInsideScrollArea);
522
+ const top = (next as { children: NodeIR[] }).children;
523
+ assert.equal(top.length, 1, 'after rewrite the wrapper still has a single direct child');
524
+ const wrapper = top[0] as { tagName?: string; classes?: string[]; children?: NodeIR[] };
525
+ assert.equal(wrapper.tagName, 'div', 'Root is replaced by a plain <div>');
526
+ // The scrollbar-gutter mirror: `sm:pr-0` was paired with `sm:pl-3`,
527
+ // so the strip swaps it for `sm:pr-3`. Other classes are preserved.
528
+ // (Without this, row separators inside the table run flush to the
529
+ // card's right edge because Figma has no scrollbar to absorb the
530
+ // gap that the `pr-0` was reserving in CSS.)
531
+ assert.deepEqual(
532
+ wrapper.classes,
533
+ ['relative', 'overflow-hidden', 'px-4', 'sm:pl-3', 'sm:pr-3'],
534
+ "scrollbar-gutter sm:pr-0 mirrored to sm:pr-3 (matching sm:pl-3)",
535
+ );
536
+ assert.equal(wrapper.children?.length, 1, 'Scrollbar dropped, Viewport flattened — only the table survives inside');
537
+ assert.equal(
538
+ (wrapper.children?.[0] as { tagName?: string }).tagName,
539
+ 'table',
540
+ 'the table sits directly inside the synthetic div',
541
+ );
542
+ }
543
+
544
+ // (i4) ScrollArea wrapping non-table content keeps the Root wrapper.
545
+ // Standalone `<ScrollArea>` stories rely on the wrapper for their
546
+ // visual shape; only the table case gets the structural rewrite.
547
+ {
548
+ const nonTableScrollArea = el({}, [], [
549
+ {
550
+ kind: 'element',
551
+ tagName: 'ScrollAreaPrimitive.Root',
552
+ tagLower: 'div',
553
+ props: {},
554
+ classes: ['relative'],
555
+ children: [
556
+ {
557
+ kind: 'element',
558
+ tagName: 'ScrollAreaPrimitive.Viewport',
559
+ tagLower: 'div',
560
+ props: {},
561
+ classes: [],
562
+ children: [el({}, ['p-3'], [])],
563
+ },
564
+ ],
565
+ } as unknown as NodeIR,
566
+ ]);
567
+ const next = applyShadcnAdapter(nonTableScrollArea);
568
+ const top = (next as { children: NodeIR[] }).children;
569
+ assert.equal(top.length, 1, 'non-table ScrollArea keeps its Root');
570
+ assert.equal(
571
+ (top[0] as { tagName?: string }).tagName,
572
+ 'ScrollAreaPrimitive.Root',
573
+ 'Root must survive when its subtree does NOT contain a table',
574
+ );
575
+ }
576
+
577
+ // (i5) Scrollbar-gutter mirror edge cases.
578
+ // - `pr-0` with no matching `pl-N` in the same variant is left alone
579
+ // (could be a genuine edge-aligned design).
580
+ // - `px-N` shorthand counts as a same-breakpoint `pl-N` source, so
581
+ // `px-4 sm:pr-0` mirrors to `px-4 sm:pr-4`.
582
+ {
583
+ // Edge A: only sm:pr-0, no sm:pl. Leave alone.
584
+ const onlyPrZero = el({}, [], [
585
+ {
586
+ kind: 'element',
587
+ tagName: 'ScrollAreaPrimitive.Root',
588
+ tagLower: 'div',
589
+ props: {},
590
+ classes: ['sm:pr-0'],
591
+ children: [{
592
+ kind: 'element',
593
+ tagName: 'table',
594
+ tagLower: 'table',
595
+ props: {},
596
+ classes: [],
597
+ children: [],
598
+ }],
599
+ } as unknown as NodeIR,
600
+ ]);
601
+ const out = applyShadcnAdapter(onlyPrZero);
602
+ const wrapper = (out as { children: NodeIR[] }).children[0] as { classes?: string[] };
603
+ assert.deepEqual(wrapper.classes, ['sm:pr-0'], 'lone sm:pr-0 (no matching pl) is preserved');
604
+
605
+ // Edge B: px-4 counts as pl source — sm:pr-0 mirrors to sm:pr-0… or 4?
606
+ // Note: px-4 has no variant, so it provides the base ('') pl source,
607
+ // not the sm: source. sm:pr-0 doesn't match because no sm:pl.
608
+ // Result: sm:pr-0 left alone, but a *base* `pr-0` (if present) would
609
+ // mirror against px-4.
610
+ const pxWithPrZero = el({}, [], [
611
+ {
612
+ kind: 'element',
613
+ tagName: 'ScrollAreaPrimitive.Root',
614
+ tagLower: 'div',
615
+ props: {},
616
+ classes: ['px-4', 'pr-0'],
617
+ children: [{
618
+ kind: 'element',
619
+ tagName: 'table',
620
+ tagLower: 'table',
621
+ props: {},
622
+ classes: [],
623
+ children: [],
624
+ }],
625
+ } as unknown as NodeIR,
626
+ ]);
627
+ const out2 = applyShadcnAdapter(pxWithPrZero);
628
+ const wrapper2 = (out2 as { children: NodeIR[] }).children[0] as { classes?: string[] };
629
+ assert.deepEqual(
630
+ wrapper2.classes,
631
+ ['px-4', 'pr-4'],
632
+ 'base pr-0 mirrors against base px-4 (px contributes pl in same variant)',
633
+ );
634
+ }
635
+
636
+ console.log('framework-adapter-shadcn-regression: PASS (3 registry + 4 leaf + 4 avatar + 5 progress + 5 slider + 5 scroll-area cases)');