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
@@ -12,6 +12,7 @@ import {
12
12
  applyNodeTransforms,
13
13
  getCompoundClasses,
14
14
  getClassesForBreakpoint,
15
+ isEffectivelyHiddenOrSrOnly,
15
16
  isElementLikeNode,
16
17
  mergeClasses,
17
18
  resolveNodeIR,
@@ -70,12 +71,15 @@ import {
70
71
  } from '../components';
71
72
  import {
72
73
  TAG_TYPOGRAPHY,
74
+ applyTruncation,
73
75
  createInlineTextNode,
74
76
  createTextNode,
75
77
  getLineHeightFromClasses,
76
78
  getNodeTextStyle,
77
79
  getTextAlignFromClasses,
80
+ getTruncateFromClasses,
78
81
  isInlineTextNode,
82
+ markForTruncation,
79
83
  maybeExpandTextLineComponent,
80
84
  type CreateTextOptions,
81
85
  } from '../text';
@@ -112,6 +116,7 @@ import {
112
116
  isTabsContentTag,
113
117
  isSelectRootTag,
114
118
  isSelectContentTag,
119
+ isDialogContentTag,
115
120
  isSelectTriggerTag,
116
121
  isSelectValueTag,
117
122
  isSelectItemTag,
@@ -158,6 +163,7 @@ import {
158
163
  getFontStyleFromClasses,
159
164
  getResolvedTextSizeFromClasses,
160
165
  getResolvedBoldFromClasses,
166
+ getFontRoleFromClasses,
161
167
  } from './typography';
162
168
  import { expandActiveConditionalVariants } from './node-variants';
163
169
  import {
@@ -934,18 +940,17 @@ function buildFigmaNode(
934
940
  opts.textAlignHorizontal = context.textAlign;
935
941
  }
936
942
  const textNode = createTextNode(node.text, opts);
937
- if (context.textTruncate && context.maxWidth) {
938
- try {
939
- const maxLines = context.textMaxLines || 1;
940
- const fontSize = context.textFontSize || 14;
941
- const lineH = context.textLineHeight || Math.ceil(fontSize * 1.5);
942
- textNode.textTruncation = 'ENDING';
943
- textNode.maxLines = maxLines;
944
- textNode.resize(context.maxWidth, Math.max(lineH, lineH * maxLines));
945
- } catch (_err) {
946
- // ignore — truncation not supported in all plugin contexts
943
+ if (context.textTruncate) {
944
+ // Mark unconditionally — the deferred truncation pass picks up
945
+ // marked nodes once a definite parent width is known (e.g. table
946
+ // cells whose column-alignment runs in phase 3).
947
+ const maxLines = context.textMaxLines || 1;
948
+ markForTruncation(textNode, maxLines);
949
+ if (context.maxWidth) {
950
+ applyTruncation(textNode, context.maxWidth, maxLines, context.textLineHeight);
947
951
  }
948
- } else if (context.maxWidth && context.parentLayout !== 'HORIZONTAL') {
952
+ }
953
+ if (!context.textTruncate && context.maxWidth && context.parentLayout !== 'HORIZONTAL') {
949
954
  try {
950
955
  textNode.textAutoResize = 'HEIGHT';
951
956
  textNode.resize(context.maxWidth, textNode.height);
@@ -1283,17 +1288,12 @@ function buildFigmaNode(
1283
1288
  ? Math.min(layoutComputed.maxWidth, context.maxWidth)
1284
1289
  : layoutComputed.maxWidth;
1285
1290
  }
1286
- // Detect text truncation from classes on this element; reset per-element (don't inherit)
1287
- let _truncate: boolean | undefined;
1288
- let _maxLines: number | undefined;
1289
- if (classes.includes('truncate')) {
1290
- _truncate = true; _maxLines = 1;
1291
- } else {
1292
- for (const cls of classes) {
1293
- const m = cls.match(/^line-clamp-(\d+)$/);
1294
- if (m) { _truncate = true; _maxLines = parseInt(m[1], 10); break; }
1295
- }
1296
- }
1291
+ // Detect text truncation from classes on this element; reset per-
1292
+ // element (don't inherit). Parsing is centralised in
1293
+ // `getTruncateFromClasses` so all text-creation sites stay in sync.
1294
+ const _truncateInfo = getTruncateFromClasses(classes);
1295
+ const _truncate: boolean | undefined = _truncateInfo ? true : undefined;
1296
+ const _maxLines: number | undefined = _truncateInfo ? _truncateInfo.maxLines : undefined;
1297
1297
  const nextContext: RenderContext = {
1298
1298
  ...context,
1299
1299
  textAlign: alignFromClasses || context.textAlign,
@@ -1340,10 +1340,35 @@ function buildFigmaNode(
1340
1340
  nextContext.selectHighlightedValue = !nextContext.selectSelectedValue && nextContext.selectIsOpen
1341
1341
  ? findFirstSelectItemValue(node)
1342
1342
  : null;
1343
- } else if (isSelectContentTag(node.tagName)) {
1344
- // Select popovers should size to their content in Figma previews instead of inheriting
1345
- // the story wrapper max width (e.g. w-56), which causes label wrapping.
1346
- nextContext.maxWidth = undefined;
1343
+ } else if (isSelectTriggerTag(node.tagName) || isSelectContentTag(node.tagName)) {
1344
+ // The scanner flattens transparent `<Select>` / `<SelectPrimitive.Root>`
1345
+ // wrappers (`propagateSelectRootContext` in component-scanner.ts) and
1346
+ // copies the wrapper's resolved context onto the hoisted Trigger and
1347
+ // Content as `__selectRoot*` data props. Bridge them into the render
1348
+ // context here so SelectValue / SelectItem children read the same
1349
+ // selectSelectedValue / selectSelectedLabel / selectIsOpen that the
1350
+ // explicit `isSelectRootTag` branch above would have set when the
1351
+ // wrapper was still in the tree.
1352
+ const annotatedSelectedValue = normalizeSelectableValue(node.props?.__selectRootSelectedValue);
1353
+ if (annotatedSelectedValue && !nextContext.selectSelectedValue) {
1354
+ nextContext.selectSelectedValue = annotatedSelectedValue;
1355
+ }
1356
+ const annotatedSelectedLabel = node.props?.__selectRootSelectedLabel;
1357
+ if (annotatedSelectedLabel && !nextContext.selectSelectedLabel) {
1358
+ nextContext.selectSelectedLabel = String(annotatedSelectedLabel);
1359
+ }
1360
+ if (!nextContext.selectIsOpen && isTruthyStateProp(node.props?.__selectRootIsOpen)) {
1361
+ nextContext.selectIsOpen = true;
1362
+ }
1363
+ const annotatedHighlight = normalizeSelectableValue(node.props?.__selectRootHighlightedValue);
1364
+ if (annotatedHighlight && !nextContext.selectHighlightedValue) {
1365
+ nextContext.selectHighlightedValue = annotatedHighlight;
1366
+ }
1367
+ if (isSelectContentTag(node.tagName)) {
1368
+ // Select popovers should size to their content in Figma previews instead of inheriting
1369
+ // the story wrapper max width (e.g. w-56), which causes label wrapping.
1370
+ nextContext.maxWidth = undefined;
1371
+ }
1347
1372
  } else if (isSelectItemTag(node.tagName)) {
1348
1373
  const itemValue = normalizeSelectableValue(node.props?.value);
1349
1374
  const selected = resolveSelectItemSelected(node as NodeIRElement, context);
@@ -1366,6 +1391,41 @@ function buildFigmaNode(
1366
1391
  if (isAccordionContentTag(node.tagName) && !context.accordionItemIsOpen) {
1367
1392
  return null;
1368
1393
  }
1394
+ // SelectContent (the dropdown items popover) renders only when the
1395
+ // Select is open. Without this gate, the items panel renders as a
1396
+ // phantom frame below the trigger in every Figma preview, even though
1397
+ // the Select is closed in the captured state. Matches the
1398
+ // AccordionContent / TabsContent gating above.
1399
+ //
1400
+ // The inherited `context.selectIsOpen` is set by `isSelectRootTag` on the
1401
+ // wrapper. After the scanner flattens the Select wrapper away, the open
1402
+ // signal lives on the Content node's own `__selectRootIsOpen` data prop —
1403
+ // check both so closed previews don't leak the dropdown either way.
1404
+ if (
1405
+ isSelectContentTag(node.tagName)
1406
+ && !context.selectIsOpen
1407
+ && !isTruthyStateProp(node.props?.__selectRootIsOpen)
1408
+ ) {
1409
+ return null;
1410
+ }
1411
+ // DialogContent visibility is gated by the wrapping `<Dialog>` /
1412
+ // `<DialogPrimitive.Root>`'s `open` / `defaultOpen` prop. The scanner
1413
+ // annotates every Content with `__dialogRootIsOpen` derived from its
1414
+ // nearest Dialog Root (see `propagateDialogRootContext` in
1415
+ // component-scanner.ts). Closed previews — typically a help-info
1416
+ // Dialog nested inside another Dialog's header — would otherwise
1417
+ // render their content inline as a sliver next to the parent's
1418
+ // content (the "Open position / Overview" leak in
1419
+ // IncreasePositionModal). Annotation absent → falls back to render
1420
+ // (preserves behaviour for trees not yet covered by the scanner
1421
+ // transform).
1422
+ if (
1423
+ isDialogContentTag(node.tagName)
1424
+ && node.props?.__dialogRootIsOpen != null
1425
+ && !isTruthyStateProp(node.props.__dialogRootIsOpen)
1426
+ ) {
1427
+ return null;
1428
+ }
1369
1429
  if (isTabsContentTag(node.tagName)) {
1370
1430
  const forceMount = isTruthyStateProp(node.props?.forceMount);
1371
1431
  const active = resolveTabsNodeActive(node as NodeIRElement, context);
@@ -1374,16 +1434,14 @@ function buildFigmaNode(
1374
1434
  }
1375
1435
  }
1376
1436
 
1377
- // Check for hidden classes - skip rendering unless overridden by a display class
1378
- // In responsive contexts "hidden sm:flex" produces ["hidden","flex"]; the last display
1379
- // class wins (same semantics as CSS), so we must not skip when a display override follows.
1380
- if (classes.includes('hidden') || classes.includes('sr-only')) {
1381
- const DISPLAY_OVERRIDES = new Set(['flex','inline-flex','block','inline-block','inline','grid','inline-grid','table','table-cell','table-row','contents','flow-root','list-item']);
1382
- const hiddenIdx = Math.max(classes.lastIndexOf('hidden'), classes.lastIndexOf('sr-only'));
1383
- const overridden = classes.slice(hiddenIdx + 1).some(c => DISPLAY_OVERRIDES.has(c));
1384
- if (!overridden) {
1385
- return null;
1386
- }
1437
+ // Skip rendering when the resolved class list ends in `display: hidden`
1438
+ // or `sr-only` (with no later override). Sole source of truth is
1439
+ // `isEffectivelyHiddenOrSrOnly` every other site that needs this
1440
+ // gate (collectTextContent, the renderChildren filter, inline-tree
1441
+ // collapse) routes through the same helper, so adding a new display
1442
+ // utility (e.g. tailwind v4 additions) lights up everywhere.
1443
+ if (isEffectivelyHiddenOrSrOnly(classes)) {
1444
+ return null;
1387
1445
  }
1388
1446
 
1389
1447
  if ((node.kind === 'component' || node.kind === 'element') && isSelectValueTag(node.tagName)) {
@@ -2042,9 +2100,13 @@ function buildFigmaNode(
2042
2100
  const rest = cls.slice(3);
2043
2101
  return !rest.startsWith('gradient') && !rest.startsWith('linear') && !rest.startsWith('radial') && rest !== 'transparent';
2044
2102
  });
2045
-
2046
2103
  if (inlineOnlyChildren && !hasLayoutClass && !hasFlexChildClass && !hasBgClass) {
2047
- const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed);
2104
+ const visibleInlineChildren = (node.children || []).filter((child) => {
2105
+ if (!('classes' in child) || !Array.isArray(child.classes)) return true;
2106
+ const resolved = resolveClassesForBreakpoint(child.classes, breakpointIndex);
2107
+ return !isEffectivelyHiddenOrSrOnly(resolved);
2108
+ });
2109
+ const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed, visibleInlineChildren);
2048
2110
  if (inlineText) return maybeWrapMxAuto(inlineText, classes, context);
2049
2111
  }
2050
2112
 
@@ -2186,13 +2248,12 @@ function buildFigmaNode(
2186
2248
  const textContent = collectTextContent(node);
2187
2249
  if (textContent.trim()) {
2188
2250
  const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
2189
- const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
2190
2251
  const textOpts: CreateTextOptions & { theme: string } = {
2191
2252
  fontSize: typo.fontSize,
2192
2253
  bold: typo.bold,
2193
2254
  lineHeight: typo.lineHeight,
2194
2255
  theme,
2195
- fontRole: isHeadingTag ? 'heading' : 'sans',
2256
+ fontRole: getFontRoleFromClasses(classes, tagLower),
2196
2257
  };
2197
2258
  if (textStyle.text) {
2198
2259
  textOpts.fill = textStyle.text;
@@ -2226,7 +2287,19 @@ function buildFigmaNode(
2226
2287
  // HORIZONTAL+CENTER (numbered-circle case) and HORIZONTAL+HUG
2227
2288
  // (inline-flex pill case); resizes otherwise.
2228
2289
  const textContentWidth = resolveTextResizeWidth(frame, nextContext.maxWidth);
2229
- if (textContentWidth != null) {
2290
+ // Text truncation: this frame-container path handles
2291
+ // text-only elements with sizing classes — e.g.
2292
+ // `<span block max-w-[9.5rem] truncate>`. Mark the text
2293
+ // unconditionally so a later deferred pass can still act,
2294
+ // and apply immediately if the element's own max-width gave
2295
+ // us a concrete content width.
2296
+ const truncateInfo = getTruncateFromClasses(classes);
2297
+ if (truncateInfo) {
2298
+ markForTruncation(textNode, truncateInfo.maxLines);
2299
+ if (textContentWidth != null) {
2300
+ applyTruncation(textNode, textContentWidth, truncateInfo.maxLines, textOpts.lineHeight);
2301
+ }
2302
+ } else if (textContentWidth != null) {
2230
2303
  try {
2231
2304
  textNode.textAutoResize = 'HEIGHT';
2232
2305
  textNode.resize(textContentWidth, textNode.height);
@@ -2245,13 +2318,12 @@ function buildFigmaNode(
2245
2318
  if (!textContent.trim()) return null;
2246
2319
 
2247
2320
  const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
2248
- const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
2249
2321
  const textOpts: CreateTextOptions & { theme: string } = {
2250
2322
  fontSize: typo.fontSize,
2251
2323
  bold: typo.bold,
2252
2324
  lineHeight: typo.lineHeight,
2253
2325
  theme,
2254
- fontRole: isHeadingTag ? 'heading' : 'sans',
2326
+ fontRole: getFontRoleFromClasses(classes, tagLower),
2255
2327
  };
2256
2328
 
2257
2329
  // Extract text color from classes
@@ -2300,22 +2372,14 @@ function buildFigmaNode(
2300
2372
  const shouldConstrain = effectiveMaxWidth
2301
2373
  && nextContext.parentLayout !== 'HORIZONTAL'
2302
2374
  && (isBlockText || classes.includes('block') || classes.includes('w-full'));
2303
- const hasTruncateClass = classes.includes('truncate');
2304
- let lineClampN: number | null = null;
2305
- for (const cls of classes) {
2306
- const m = cls.match(/^line-clamp-(\d+)$/);
2307
- if (m) { lineClampN = parseInt(m[1], 10); break; }
2375
+ const truncateInfo = getTruncateFromClasses(classes);
2376
+ if (truncateInfo) {
2377
+ markForTruncation(textNode, truncateInfo.maxLines);
2378
+ if (effectiveMaxWidth) {
2379
+ applyTruncation(textNode, effectiveMaxWidth, truncateInfo.maxLines, textOpts.lineHeight);
2380
+ }
2308
2381
  }
2309
- if ((hasTruncateClass || lineClampN != null) && effectiveMaxWidth) {
2310
- try {
2311
- const maxLines = lineClampN ?? 1;
2312
- const fs = textOpts.fontSize || 14;
2313
- const lh = textOpts.lineHeight || Math.ceil(fs * 1.5);
2314
- textNode.textTruncation = 'ENDING';
2315
- textNode.maxLines = maxLines;
2316
- textNode.resize(effectiveMaxWidth, Math.max(lh, lh * maxLines));
2317
- } catch (_err) { /* ignore */ }
2318
- } else if (shouldConstrain) {
2382
+ if (!truncateInfo && shouldConstrain) {
2319
2383
  try {
2320
2384
  textNode.textAutoResize = 'HEIGHT';
2321
2385
  textNode.resize(effectiveMaxWidth, textNode.height);
@@ -2432,6 +2496,22 @@ function buildFigmaNode(
2432
2496
  ) {
2433
2497
  frame.resize(context.maxWidth, frame.height);
2434
2498
  frame.primaryAxisSizingMode = 'FIXED';
2499
+ } else if (
2500
+ frame.layoutMode === 'VERTICAL'
2501
+ && context.maxWidth
2502
+ && frame.layoutAlign === 'STRETCH'
2503
+ && frame.counterAxisSizingMode !== 'FIXED'
2504
+ ) {
2505
+ // Same as the text-element path: a VERTICAL frame that's
2506
+ // STRETCH-aligned into a known-width parent has a definite
2507
+ // counter-axis width. Lock counterAxisSizingMode='FIXED' so the
2508
+ // block-level children inside (e.g. a `<p>` description inside an
2509
+ // inner wrapper div) recognise their parent as definite-width and
2510
+ // get their own implicit STRETCH via `parentCounterFixed`. Without
2511
+ // this, the cascade stops one level deep and longer block siblings
2512
+ // get cropped to the wrapper's first non-stretch child's width.
2513
+ frame.resize(context.maxWidth, frame.height);
2514
+ frame.counterAxisSizingMode = 'FIXED';
2435
2515
  }
2436
2516
 
2437
2517
  const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
@@ -2471,7 +2551,22 @@ function buildFigmaNode(
2471
2551
  textColor: nextContext.textColor,
2472
2552
  textColorToken: nextContext.textColorToken,
2473
2553
  };
2474
- const renderChildren = getRenderableChildren(node, classes);
2554
+ // Drop children whose resolved class list at the current breakpoint
2555
+ // ends in `display: hidden`. The outer-element hidden gate at the top
2556
+ // of buildFigmaNode handles whole-element skip when buildFigmaNode is
2557
+ // recursively invoked, but the inline-text collapse path below
2558
+ // (`createInlineTextNode`) and the inline-segment collector
2559
+ // (`collectInlineSegments`) merge children's text content without
2560
+ // calling buildFigmaNode per child — so a `<span sm:hidden>A</span>
2561
+ // <span hidden sm:inline>B</span>` pair would both contribute text
2562
+ // and render as "A B" concatenated regardless of breakpoint. Pre-
2563
+ // filtering here ensures hidden children are dropped before either
2564
+ // the inline-collapse path or the per-child render loop runs.
2565
+ const renderChildren = getRenderableChildren(node, classes).filter((child) => {
2566
+ if (!('classes' in child) || !Array.isArray(child.classes)) return true;
2567
+ const resolved = resolveClassesForBreakpoint(child.classes, breakpointIndex);
2568
+ return !isEffectivelyHiddenOrSrOnly(resolved);
2569
+ });
2475
2570
  const accordionItemOrdinal = { value: 0 };
2476
2571
 
2477
2572
  // Convert child mt-* margins to auto-layout spacing in vertical stacks.
@@ -2485,7 +2580,7 @@ function buildFigmaNode(
2485
2580
  && renderChildren.length > 0
2486
2581
  && renderChildren.every(isInlineTextNode);
2487
2582
  if (hasOnlyInlineChildren) {
2488
- const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed);
2583
+ const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed, renderChildren);
2489
2584
  if (inlineText) {
2490
2585
  frame.appendChild(inlineText);
2491
2586
  return maybeWrapMxAuto(frame, classes, context);
@@ -2561,10 +2656,43 @@ function buildFigmaNode(
2561
2656
  // ignore if node cannot grow in this parent
2562
2657
  }
2563
2658
  }
2659
+ // Record the JSX `colSpan` on the rendered cell so the Phase-3
2660
+ // table-column-alignment pass can skip multi-cell rows when
2661
+ // computing per-column max width (an empty-state row with
2662
+ // `colSpan={7}` shouldn't dominate column 0). Default colspan
2663
+ // of 1 is recorded too, both for explicit-intent symmetry and
2664
+ // so the alignment pass doesn't need a "default = 1" branch.
2665
+ try {
2666
+ (childNode as SceneNode).setPluginData('inkbridge:col-span', String(colSpan));
2667
+ } catch (_err) {
2668
+ // ignore — plugin data write is best-effort
2669
+ }
2564
2670
  }
2565
2671
  }
2566
2672
  }
2567
2673
 
2674
+ // Demote SPACE_BETWEEN → MIN when the frame ended up with fewer than 2
2675
+ // in-flow children. Figma centers the lone child of a SPACE_BETWEEN
2676
+ // frame ("Gap: Auto" in the inspector), which is not what CSS does
2677
+ // (`justify-content: space-between` with one item anchors to start).
2678
+ // Commonly hits patterns like
2679
+ // <header className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
2680
+ // <div>{title + description}</div>
2681
+ // {actions ? <div>{actions}</div> : null}
2682
+ // </header>
2683
+ // — at md+ the header flips HORIZONTAL with SPACE_BETWEEN. When the
2684
+ // consumer doesn't pass `actions`, only the title-content div remains
2685
+ // and Figma centers it horizontally. Demoting here restores the
2686
+ // CSS-correct start-anchor.
2687
+ if (
2688
+ 'primaryAxisAlignItems' in frame
2689
+ && frame.primaryAxisAlignItems === 'SPACE_BETWEEN'
2690
+ && Array.isArray(frame.children)
2691
+ && frame.children.filter((c) => !('layoutPositioning' in c) || c.layoutPositioning !== 'ABSOLUTE').length < 2
2692
+ ) {
2693
+ try { frame.primaryAxisAlignItems = 'MIN'; } catch (_err) { /* ignore */ }
2694
+ }
2695
+
2568
2696
  // Resolve any deferred percent positions on this frame's direct children
2569
2697
  // now that all of them are appended and (most likely) sized.
2570
2698
  if ('layoutMode' in frame) {
@@ -747,6 +747,14 @@ export function flattenSvgNode(node: SceneNode): VectorNode | null {
747
747
  }
748
748
 
749
749
  function resizeNode(node: SceneNode, width: number, height: number): void {
750
+ // Figma VECTOR nodes reject 0-dim resize with
751
+ // "Cannot set width and height of vector node to zero". The outer
752
+ // `resizeSvgNodeTo` already guards function-level w/h, but the
753
+ // per-sub-vector loop computes `vector.width * sx` per child and can
754
+ // land on 0 when a sub-vector has zero base dimensions (e.g. an empty
755
+ // path, or a marker that didn't pick up bounds from `createNodeFromSvg`).
756
+ // Mirror the outer guard at the call site so any caller benefits.
757
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
750
758
  if ('resizeWithoutConstraints' in node) node.resizeWithoutConstraints(width, height);
751
759
  else if ('resize' in node) node.resize(width, height);
752
760
  }
@@ -432,11 +432,124 @@ function flattenScrollAreaSubtree(node: NodeIR): NodeIR[] | null {
432
432
  changed = true;
433
433
  continue;
434
434
  }
435
+ // ScrollAreaPrimitive.Root wrapping a `<table>`: replace the
436
+ // component wrapper with a plain `<div>` that inherits Root's
437
+ // classes (including the consumer's `px-* sm:pl-* sm:pr-*` padding
438
+ // passed via ScrollArea's `className` prop). Two wins:
439
+ // 1. The synthetic `div` is `kind: 'element'`, which gets the
440
+ // normal block-stretch-to-parent-width treatment in
441
+ // ui-builder; `Root` was `kind: 'component'` and stayed
442
+ // HUG-primary, leaving the table narrower than the card.
443
+ // 2. The padding the consumer wrote on `<ScrollArea>` is
444
+ // preserved verbatim — `px-4` / `sm:pl-3 sm:pr-0` keep their
445
+ // visual effect even though the scroll mechanism itself is
446
+ // meaningless in static Figma renders.
447
+ // ScrollArea around non-table content is left untouched (case
448
+ // (i1) above) — only the table case re-shapes the wrapper.
449
+ if (tag === 'ScrollAreaPrimitive.Root' && subtreeContainsTable(child)) {
450
+ const inner = flattenScrollAreaSubtree(child)
451
+ ?? (('children' in child && Array.isArray(child.children)) ? child.children : []);
452
+ const rawRootClasses = ('classes' in child && Array.isArray((child as { classes?: string[] }).classes))
453
+ ? ((child as { classes: string[] }).classes)
454
+ : [];
455
+ // Normalise the consumer's scrollbar-gutter trick: `pl-N pr-0` is
456
+ // common shadcn shorthand for "leave room for the browser
457
+ // scrollbar on the right". Since Figma has no scrollbar (the
458
+ // ScrollArea wrapper is dropped above), that asymmetry just
459
+ // leaves table rows extending all the way to the card's right
460
+ // edge with no visual padding. Mirror `pr-0` to match the same
461
+ // breakpoint's `pl-N` so the rendered cell looks balanced.
462
+ const rootClasses = mirrorScrollbarGutterPadding(rawRootClasses);
463
+ const rootProps = ('props' in child && (child as { props?: Record<string, unknown> }).props)
464
+ ? ((child as { props: Record<string, unknown> }).props)
465
+ : {};
466
+ next.push({
467
+ kind: 'element',
468
+ tagName: 'div',
469
+ tagLower: 'div',
470
+ props: rootProps,
471
+ classes: rootClasses,
472
+ children: inner,
473
+ } as NodeIR);
474
+ changed = true;
475
+ continue;
476
+ }
435
477
  next.push(child);
436
478
  }
437
479
  return changed ? next : null;
438
480
  }
439
481
 
482
+ /**
483
+ * Does this node's subtree contain a semantic `<table>` element? Gates
484
+ * the `ScrollAreaPrimitive.Root` → synthetic `<div>` swap above. A
485
+ * `ScrollArea` around non-table content keeps its Root wrapper so
486
+ * standalone `<ScrollArea>` stories don't lose their structural shape.
487
+ */
488
+ /**
489
+ * Mirror the scrollbar-gutter trick when stripping a ScrollArea
490
+ * around a table. CSS pattern `pl-N pr-0` (with optional `<variant>:`
491
+ * prefix) typically means "give the browser scrollbar the right
492
+ * gutter visually". In Figma there's no scrollbar to consume the
493
+ * gap, so the row separators run flush to the card edge. We swap
494
+ * `pr-0` for `pr-N` (same N as the matching pl) at the same
495
+ * variant; pr-0 with no matching pl in that variant is left alone
496
+ * (could be a genuinely-edge-aligned design).
497
+ *
498
+ * Pure function on a class string array. Variant detection uses the
499
+ * standard `:` separator and the last segment as the utility.
500
+ */
501
+ function mirrorScrollbarGutterPadding(classes: string[]): string[] {
502
+ if (!classes || classes.length === 0) return classes;
503
+
504
+ function splitVariant(cls: string): { variant: string; base: string } {
505
+ const idx = cls.lastIndexOf(':');
506
+ if (idx === -1) return { variant: '', base: cls };
507
+ return { variant: cls.slice(0, idx + 1), base: cls.slice(idx + 1) };
508
+ }
509
+
510
+ // Build a map: variant → pl-N value (string after `pl-`).
511
+ // Also handle `px-N` → contributes pl-N for the same variant.
512
+ const plByVariant: Record<string, string> = {};
513
+ for (let i = 0; i < classes.length; i++) {
514
+ const { variant, base } = splitVariant(classes[i]);
515
+ if (base.indexOf('pl-') === 0) {
516
+ plByVariant[variant] = base.slice(3);
517
+ } else if (base.indexOf('px-') === 0) {
518
+ // `px-N` only acts as a fallback pl source — if a more specific
519
+ // `pl-N` exists in the same variant, the later loop iteration
520
+ // wins. We don't overwrite px-derived entries with px-derived
521
+ // ones from a different ordering, but that's fine: px is
522
+ // symmetric, so order doesn't matter.
523
+ if (plByVariant[variant] == null) plByVariant[variant] = base.slice(3);
524
+ }
525
+ }
526
+
527
+ let mutated = false;
528
+ const out: string[] = [];
529
+ for (let i = 0; i < classes.length; i++) {
530
+ const cls = classes[i];
531
+ const { variant, base } = splitVariant(cls);
532
+ if (base === 'pr-0' && plByVariant[variant] != null) {
533
+ out.push(variant + 'pr-' + plByVariant[variant]);
534
+ mutated = true;
535
+ } else {
536
+ out.push(cls);
537
+ }
538
+ }
539
+ return mutated ? out : classes;
540
+ }
541
+
542
+ function subtreeContainsTable(node: NodeIR): boolean {
543
+ if (!isClassedElement(node)) return false;
544
+ const tag = (node as { tagName?: string }).tagName;
545
+ if (typeof tag === 'string' && tag.toLowerCase() === 'table') return true;
546
+ if (!('children' in node) || !Array.isArray(node.children)) return false;
547
+ for (const c of node.children) {
548
+ if (subtreeContainsTable(c)) return true;
549
+ }
550
+ return false;
551
+ }
552
+
440
553
  /**
441
554
  * Recurse the IR, injecting registry classes for any element whose
442
555
  * `data-slot` matches a known shadcn pattern AND applying shadcn-specific
@@ -178,7 +178,19 @@ function requireResponseString(value: unknown, context: string): string {
178
178
  throw new Error('GitHub API response missing ' + context);
179
179
  }
180
180
 
181
- function buildPatchEndpointCandidates(): string[] {
181
+ function buildPatchEndpointCandidates(devServerPort?: string): string[] {
182
+ // When `devServerPort` is configured, collapse the candidate list to
183
+ // that single port — same pattern as the pack-fetch candidates. The
184
+ // value is already validated to a numeric 1-65535 by
185
+ // `normalizeDevServerPort` on the config layer, but we re-validate
186
+ // here defensively so callers can pass through unvalidated strings.
187
+ const trimmed = (devServerPort || '').trim();
188
+ if (trimmed) {
189
+ const n = parseInt(trimmed, 10);
190
+ if (Number.isFinite(n) && n >= 1 && n <= 65535) {
191
+ return ['http://localhost:' + n + PATCH_ENDPOINT_PATH];
192
+ }
193
+ }
182
194
  const out: string[] = [];
183
195
  for (let i = 0; i < PATCH_ENDPOINT_PORTS.length; i++) {
184
196
  out.push('http://localhost:' + PATCH_ENDPOINT_PORTS[i] + PATCH_ENDPOINT_PATH);
@@ -201,7 +213,8 @@ function serializeCssUpdates(updatesByTheme: Map<string, Map<string, string>>):
201
213
  async function patchCssViaDevServer(
202
214
  cssPath: string,
203
215
  cssText: string,
204
- updatesByTheme: Map<string, Map<string, string>>
216
+ updatesByTheme: Map<string, Map<string, string>>,
217
+ devServerPort?: string
205
218
  ): Promise<string | null> {
206
219
  await waitForUIReady();
207
220
 
@@ -218,7 +231,7 @@ async function patchCssViaDevServer(
218
231
  figma.ui.postMessage({
219
232
  type: 'patch-css',
220
233
  requestId,
221
- candidates: buildPatchEndpointCandidates(),
234
+ candidates: buildPatchEndpointCandidates(devServerPort),
222
235
  payload: {
223
236
  filePath: cssPath,
224
237
  cssText,
@@ -1039,7 +1052,12 @@ async function buildTokenCommitPlan(token: string): Promise<TokenCommitPlan> {
1039
1052
  const updates = buildCssVariableUpdates(variableTokenPatch || ({} as Tokens), tokensBeforeVariablePatch);
1040
1053
  const fullThemeUpdates = buildCssVariableUpdates(workingTokens, tokensBeforeVariablePatch);
1041
1054
  const activeThemeNames = getActiveThemeNames(workingTokens);
1042
- let patchedCss = await patchCssViaDevServer(cssFile.path, cssFile.content, updates);
1055
+ let patchedCss = await patchCssViaDevServer(
1056
+ cssFile.path,
1057
+ cssFile.content,
1058
+ updates,
1059
+ GITHUB_CONFIG!.devServerPort,
1060
+ );
1043
1061
  if (typeof patchedCss !== 'string') {
1044
1062
  // Dev server route unavailable or invalid response: fallback to in-plugin patcher.
1045
1063
  patchedCss = patchCssVariables(cssFile.content, updates, activeThemeNames, fullThemeUpdates);
@@ -18,6 +18,10 @@ 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';
23
+ export * from './table-layout';
24
+ export * from './text-truncate-pass';
21
25
  export * from './layout-utils';
22
26
  export * from './size-utils';
23
27
  export * from './ring-utils';