inkbridge 0.1.0-beta.20 → 0.1.0-beta.21

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 (36) hide show
  1. package/README.md +2 -1
  2. package/bin/inkbridge.mjs +64 -9
  3. package/code.js +11 -11
  4. package/package.json +8 -2
  5. package/scanner/adapter-utils-regression.ts +159 -0
  6. package/scanner/component-scanner.ts +276 -19
  7. package/scanner/font-family-extract-regression.ts +113 -0
  8. package/scanner/framework-adapter-shadcn-regression.ts +96 -1
  9. package/scanner/grid-cols-extraction-regression.ts +110 -0
  10. package/scanner/input-range-regression.ts +217 -0
  11. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  12. package/scanner/local-const-className-regression.ts +331 -0
  13. package/scanner/ring-utility-regression.ts +25 -4
  14. package/scanner/state-classification-regression.ts +38 -0
  15. package/scanner/stretch-to-parent-width-regression.ts +35 -1
  16. package/scanner/tailwind-parser.ts +38 -2
  17. package/src/components/component-gen.ts +11 -151
  18. package/src/design-system/cva-master.ts +7 -3
  19. package/src/design-system/design-system.ts +8 -0
  20. package/src/design-system/node-helpers.ts +15 -1
  21. package/src/design-system/preview-builder.ts +14 -45
  22. package/src/design-system/state-master.ts +23 -1
  23. package/src/design-system/story-builder.ts +55 -5
  24. package/src/design-system/ui-builder.ts +116 -6
  25. package/src/framework-adapters/index.ts +15 -2
  26. package/src/framework-adapters/shadcn.ts +83 -67
  27. package/src/layout/deferred-layout.ts +187 -1
  28. package/src/layout/layout-utils.ts +2 -1
  29. package/src/layout/ring-utils.ts +31 -82
  30. package/src/render-engine-version.ts +1 -1
  31. package/src/tailwind/adapter-utils.ts +137 -0
  32. package/src/tailwind/jsx-utils.ts +9 -0
  33. package/src/tailwind/node-ir.ts +172 -0
  34. package/src/tailwind/tailwind.ts +23 -16
  35. package/src/tokens/tokens.ts +11 -3
  36. package/templates/scan-components-route.ts +11 -1
@@ -34,6 +34,7 @@ import {
34
34
  applyDeferredBottomPositioning,
35
35
  applyDeferredCenterYPositioning,
36
36
  applyFullWidthIfPossible,
37
+ applyRingIfPossible,
37
38
  applyGridColumnsWithReflow,
38
39
  enforceFixedBoxSizingAfterLayout,
39
40
  enforceGrowChildPrimaryFixed,
@@ -560,6 +561,32 @@ function nodeIRToJsxNode(node: NodeIR): JsxNode | null {
560
561
  // JSX Tree Rendering
561
562
  // ---------------------------------------------------------------------------
562
563
 
564
+ /**
565
+ * Returns true when any direct IR child carries `ml-auto` (or `mx-auto`)
566
+ * on its className. `ml-auto` on a flex child means "push me along the
567
+ * primary axis"; the Figma equivalent is `primaryAxisAlignItems =
568
+ * 'SPACE_BETWEEN'` on the parent. The parent-side detection is needed
569
+ * because inline-text children (e.g. a `<span className="ml-auto">`
570
+ * containing only text) render via `createInlineTextNode` and never get
571
+ * a frame to carry the per-child `SELF_ALIGNMENT_NODES` mark that
572
+ * `deferred-layout.applyFullWidthIfPossible` reads. DropdownMenuShortcut
573
+ * was the canonical case — the inline-text path swallowed `ml-auto`
574
+ * entirely, leaving the shortcut anchored to the start of the item.
575
+ */
576
+ function hasAutoMarginChild(children: readonly NodeIR[] | undefined): boolean {
577
+ if (!Array.isArray(children)) return false;
578
+ for (const child of children) {
579
+ if (!child || typeof child !== 'object') continue;
580
+ if (child.kind !== 'element' && child.kind !== 'component') continue;
581
+ const cl = (child as { classes?: unknown }).classes;
582
+ if (!Array.isArray(cl)) continue;
583
+ for (const cls of cl) {
584
+ if (cls === 'ml-auto' || cls === 'mx-auto') return true;
585
+ }
586
+ }
587
+ return false;
588
+ }
589
+
563
590
  /**
564
591
  * Create a separator node for divide-y/divide-x
565
592
  */
@@ -960,6 +987,7 @@ function buildFigmaNode(
960
987
  stabilizeHorizontalStretchChild(childNode, wrapper);
961
988
  constrainSingleHorizontalTextChild(childNode);
962
989
  applyFullWidthIfPossible(childNode, wrapper, context.maxWidth != null ? { widthOverride: context.maxWidth } : undefined);
990
+ applyRingIfPossible(childNode, wrapper);
963
991
  applyAspectRatioIfPossible(childNode);
964
992
  // The just-resolved aspect ratio / full-width may have given this
965
993
  // child its final dimensions, so resolve any deferred percent
@@ -1031,18 +1059,33 @@ function buildFigmaNode(
1031
1059
  ring.cornerRadius = baseRadius + node.offsetWidth;
1032
1060
 
1033
1061
  let innerParent: FrameNode = ring;
1034
- if (node.offsetWidth > 0 && node.offsetColor) {
1062
+ if (node.offsetWidth > 0) {
1063
+ // ring-offset-N — even without an explicit ring-offset-COLOR class
1064
+ // we still need the gap. CSS draws the offset as a transparent
1065
+ // band (inheriting the page background) that separates the inner
1066
+ // element's border from the outer ring stroke. Without this
1067
+ // wrapper, the ring stroke sits flush against the border and the
1068
+ // two visually merge into one thick line — the symptom: shadcn
1069
+ // Select trigger / Input focus state showing a single fat green
1070
+ // ring instead of "gray border + gap + green ring" like in the
1071
+ // browser.
1035
1072
  const offsetFrame = figma.createFrame();
1036
1073
  offsetFrame.name = 'ring-offset';
1037
1074
  offsetFrame.layoutMode = 'HORIZONTAL';
1038
1075
  offsetFrame.primaryAxisSizingMode = 'AUTO';
1039
1076
  offsetFrame.counterAxisSizingMode = 'AUTO';
1040
1077
  applyClipBehavior(offsetFrame, []);
1041
- offsetFrame.fills = [{
1042
- type: 'SOLID',
1043
- color: { r: node.offsetColor.r, g: node.offsetColor.g, b: node.offsetColor.b },
1044
- opacity: 1,
1045
- }];
1078
+ if (node.offsetColor) {
1079
+ offsetFrame.fills = [{
1080
+ type: 'SOLID',
1081
+ color: { r: node.offsetColor.r, g: node.offsetColor.g, b: node.offsetColor.b },
1082
+ opacity: 1,
1083
+ }];
1084
+ } else {
1085
+ // Transparent gap — matches CSS's default offset behaviour when
1086
+ // no `ring-offset-COLOR` was set.
1087
+ offsetFrame.fills = [];
1088
+ }
1046
1089
  offsetFrame.strokes = [];
1047
1090
  offsetFrame.paddingLeft = offsetFrame.paddingRight = offsetFrame.paddingTop = offsetFrame.paddingBottom = node.offsetWidth;
1048
1091
  offsetFrame.cornerRadius = baseRadius + node.offsetWidth;
@@ -1087,6 +1130,7 @@ function buildFigmaNode(
1087
1130
  if (!ringIsAbsolute) {
1088
1131
  applyFullWidthIfPossible(childNode, innerParent);
1089
1132
  }
1133
+ applyRingIfPossible(childNode, innerParent);
1090
1134
  return maybeWrapMxAuto(ring, node.classes, context);
1091
1135
  }
1092
1136
 
@@ -1786,6 +1830,57 @@ function buildFigmaNode(
1786
1830
  applyTailwindStylesToFrame(wrapperFrame, activeMergedClasses, colorGroup, radiusGroup, theme);
1787
1831
  }
1788
1832
  applyClipBehavior(wrapperFrame, activeMergedClasses);
1833
+ // Component wrappers that the scanner emits as Radix primitives
1834
+ // (`<DropdownMenuPrimitive.Item>`, `<TabsPrimitive.Trigger>`, …) reach
1835
+ // this branch with `kind: 'component'`. The `kind: 'element'` branch
1836
+ // applies `shouldStretchToParentWidth` to stretch block-level children
1837
+ // to a vertical parent's content width; without the same check here a
1838
+ // DropdownMenuItem inside a `w-56` Content stays HUG-width. Treat the
1839
+ // wrapper as a div — Radix/shadcn primitives render <div> by default
1840
+ // unless `asChild` is used (handled separately) — so block-flow stretch
1841
+ // applies. The `__fromPortal` guard below intentionally runs after so
1842
+ // portal panels still tag themselves.
1843
+ if (
1844
+ context.parentLayout === 'VERTICAL'
1845
+ && shouldStretchToParentWidth('div', activeMergedClasses)
1846
+ ) {
1847
+ try { wrapperFrame.layoutAlign = 'STRETCH'; } catch (_err) { /* ignore */ }
1848
+ // Mirror the `kind: 'element'` branch's resize: layoutAlign alone may
1849
+ // not grow the frame when the parent isn't sized yet, so when we know
1850
+ // context.maxWidth, set the axis we'd otherwise hug to FIXED at that
1851
+ // width. Without this the Item stays at its content width even after
1852
+ // the STRETCH mark.
1853
+ if (context.maxWidth && context.maxWidth > 0) {
1854
+ try {
1855
+ if (
1856
+ wrapperFrame.layoutMode === 'HORIZONTAL'
1857
+ && wrapperFrame.primaryAxisSizingMode !== 'FIXED'
1858
+ ) {
1859
+ wrapperFrame.resize(context.maxWidth, wrapperFrame.height);
1860
+ wrapperFrame.primaryAxisSizingMode = 'FIXED';
1861
+ } else if (
1862
+ wrapperFrame.layoutMode === 'VERTICAL'
1863
+ && wrapperFrame.counterAxisSizingMode !== 'FIXED'
1864
+ ) {
1865
+ wrapperFrame.resize(context.maxWidth, wrapperFrame.height);
1866
+ wrapperFrame.counterAxisSizingMode = 'FIXED';
1867
+ }
1868
+ } catch (_err) { /* ignore resize errors */ }
1869
+ }
1870
+ }
1871
+ // `ml-auto` on any direct child → parent uses SPACE_BETWEEN along the
1872
+ // primary axis. See `hasAutoMarginChild` above for why the parent has
1873
+ // to detect this itself (inline-text children never carry a frame
1874
+ // mark). Only flip when the consumer hasn't already set a `justify-*`
1875
+ // (the default Figma value is `MIN`, which is also what we get for a
1876
+ // flex with no `justify-*`).
1877
+ if (
1878
+ wrapperFrame.layoutMode === 'HORIZONTAL'
1879
+ && wrapperFrame.primaryAxisAlignItems === 'MIN'
1880
+ && hasAutoMarginChild(node.children)
1881
+ ) {
1882
+ try { wrapperFrame.primaryAxisAlignItems = 'SPACE_BETWEEN'; } catch (_err) { /* ignore */ }
1883
+ }
1789
1884
  // Tag portal panels so story-builder can grow Story Layout past narrow wrapper
1790
1885
  // classes (e.g. `w-56`) that would otherwise clip the panel. See portal-panel.ts.
1791
1886
  if (node.props && node.props.__fromPortal) {
@@ -1842,6 +1937,7 @@ function buildFigmaNode(
1842
1937
  ? { skipFullWidth: skipWrapperFullWidth, widthOverride: childCtx.maxWidth }
1843
1938
  : undefined;
1844
1939
  applyFullWidthIfPossible(childNode, wrapperFrame, fullWidthOptions);
1940
+ applyRingIfPossible(childNode, wrapperFrame);
1845
1941
  applyAspectRatioIfPossible(childNode);
1846
1942
  enforceTabsChildSizing(node, child, childNode, childCtx.maxWidth);
1847
1943
  if (isElementLikeNode(child)) {
@@ -1914,6 +2010,7 @@ function buildFigmaNode(
1914
2010
  ? { skipFullWidth: skipCompFullWidth, widthOverride: compChildCtx.maxWidth }
1915
2011
  : undefined;
1916
2012
  applyFullWidthIfPossible(childNode, compFrame, fullWidthOptions);
2013
+ applyRingIfPossible(childNode, compFrame);
1917
2014
  applyAspectRatioIfPossible(childNode);
1918
2015
  if (isElementLikeNode(child)) {
1919
2016
  enforceFixedBoxSizingAfterLayout(childNode, child.classes);
@@ -1965,6 +2062,17 @@ function buildFigmaNode(
1965
2062
  applyTailwindStylesToFrame(frame, classes, colorGroup, radiusGroup, theme);
1966
2063
  }
1967
2064
  applyClipBehavior(frame, classes);
2065
+ // `ml-auto` on any direct child → flip parent to SPACE_BETWEEN. Mirror
2066
+ // of the wrapper-branch detection above; needed here because inline
2067
+ // text children (e.g. `<span className="ml-auto">`) render as text
2068
+ // nodes and never carry a frame mark for deferred-layout to read.
2069
+ if (
2070
+ frame.layoutMode === 'HORIZONTAL'
2071
+ && frame.primaryAxisAlignItems === 'MIN'
2072
+ && hasAutoMarginChild(node.children)
2073
+ ) {
2074
+ try { frame.primaryAxisAlignItems = 'SPACE_BETWEEN'; } catch (_err) { /* ignore */ }
2075
+ }
1968
2076
  if (
1969
2077
  context.parentLayout === 'VERTICAL'
1970
2078
  && shouldStretchToParentWidth(tagLower, classes)
@@ -2041,6 +2149,7 @@ function buildFigmaNode(
2041
2149
  ? { skipFullWidth: skipElementFullWidth, widthOverride: childContext.maxWidth }
2042
2150
  : undefined;
2043
2151
  applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
2152
+ applyRingIfPossible(childNode, frame);
2044
2153
  applyAspectRatioIfPossible(childNode);
2045
2154
  if (isElementLikeNode(child)) {
2046
2155
  enforceFixedBoxSizingAfterLayout(childNode, child.classes);
@@ -2406,6 +2515,7 @@ function buildFigmaNode(
2406
2515
  ? { skipFullWidth: skipChildFullWidth, widthOverride: contentWidth }
2407
2516
  : undefined;
2408
2517
  applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
2518
+ applyRingIfPossible(childNode, frame);
2409
2519
  applyAspectRatioIfPossible(childNode);
2410
2520
  enforceGrowChildPrimaryFixed(childNode, frame);
2411
2521
  enforceTabsChildSizing(node, child, childNode, childContext.maxWidth);
@@ -15,9 +15,22 @@
15
15
  */
16
16
 
17
17
  import type { NodeIR } from '../tailwind/node-ir';
18
- import { applyShadcnAdapter } from './shadcn';
18
+ import { applyShadcnAdapter, isShadcnAdapterDroppedTag } from './shadcn';
19
19
 
20
- export { applyShadcnAdapter, getShadcnSlotInjections } from './shadcn';
20
+ export { applyShadcnAdapter, getShadcnSlotInjections, isShadcnAdapterDroppedTag } from './shadcn';
21
+
22
+ /**
23
+ * Returns true when the given JSX tagName is unconditionally dropped by
24
+ * some registered framework adapter at render time. Walks that adapters
25
+ * declare via their own `is…DroppedTag` predicate so callers don't have to
26
+ * know which adapters exist. Pre-render scans (responsive-signal
27
+ * detection, complexity heuristics, …) consult this to avoid counting
28
+ * classes on elements that never reach Figma.
29
+ */
30
+ export function isFrameworkAdapterDroppedTag(tagName: string): boolean {
31
+ return isShadcnAdapterDroppedTag(tagName);
32
+ // Future adapters add `|| isMuiAdapterDroppedTag(tagName)` etc.
33
+ }
21
34
 
22
35
  /**
23
36
  * Run every registered framework adapter against the IR in turn. Each
@@ -38,14 +38,7 @@
38
38
  // Type-only import: avoid a runtime cycle with `tailwind/node-ir.ts`,
39
39
  // which imports this file's `applyShadcnAdapter`.
40
40
  import type { NodeIR } from '../tailwind/node-ir';
41
-
42
- // Inlined to avoid the runtime import cycle above. We only care about
43
- // `element` / `component` nodes (the ones with `props` + `classes`).
44
- // `ring` nodes wrap a child without props/classes of their own — they
45
- // have a separate recursion branch.
46
- function isClassedElement(node: NodeIR): node is Extract<NodeIR, { kind: 'element' | 'component' }> {
47
- return node.kind === 'element' || node.kind === 'component';
48
- }
41
+ import { isClassedElement, mergeMissing, resolveValuePercents } from '../tailwind/adapter-utils';
49
42
 
50
43
  /**
51
44
  * `data-slot` → extra Tailwind classes injected before layout solving.
@@ -90,19 +83,6 @@ function toElementWithClasses(node: NodeIR, classes: string[]): NodeIR {
90
83
  return Object.assign({}, node, patch) as NodeIR;
91
84
  }
92
85
 
93
- function mergeMissing(existing: string[], extras: readonly string[]): string[] {
94
- if (extras.length === 0) return existing;
95
- const set = new Set(existing);
96
- let out: string[] | null = null;
97
- for (const cls of extras) {
98
- if (set.has(cls)) continue;
99
- if (!out) out = existing.slice();
100
- out.push(cls);
101
- set.add(cls);
102
- }
103
- return out ?? existing;
104
- }
105
-
106
86
  export function getShadcnSlotInjections(slot: string): readonly string[] | null {
107
87
  return SLOT_CLASS_INJECTIONS[slot] ?? null;
108
88
  }
@@ -256,51 +236,6 @@ function applyProgressIndicatorWidth(
256
236
  return changed ? out : null;
257
237
  }
258
238
 
259
- /**
260
- * Resolve a shadcn Slider's `defaultValue` (or `value`) into an array of
261
- * percent positions. shadcn / base-ui accepts a number for single-thumb
262
- * and a tuple for range. The scanner preserves both shapes verbatim,
263
- * but for safety we also handle stringified forms ("[25, 75]" or "50").
264
- */
265
- function resolveSliderValues(rawValue: unknown, rawMax: unknown): number[] {
266
- const maxRaw =
267
- typeof rawMax === 'number' ? rawMax
268
- : typeof rawMax === 'string' && rawMax.trim().length > 0 ? parseFloat(rawMax)
269
- : 100;
270
- const max = Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : 100;
271
- const toPct = (v: number): number => Math.max(0, Math.min(100, (v / max) * 100));
272
-
273
- if (Array.isArray(rawValue)) {
274
- const nums: number[] = [];
275
- for (const v of rawValue) {
276
- const n = typeof v === 'number' ? v : typeof v === 'string' ? parseFloat(v) : NaN;
277
- if (Number.isFinite(n)) nums.push(toPct(n));
278
- }
279
- if (nums.length > 0) return nums;
280
- }
281
- if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
282
- return [toPct(rawValue)];
283
- }
284
- if (typeof rawValue === 'string') {
285
- const trimmed = rawValue.trim();
286
- if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
287
- try {
288
- const parsed = JSON.parse(trimmed);
289
- if (Array.isArray(parsed)) {
290
- const nums = parsed
291
- .map((v) => (typeof v === 'number' ? v : parseFloat(String(v))))
292
- .filter((v: number) => Number.isFinite(v))
293
- .map(toPct);
294
- if (nums.length > 0) return nums;
295
- }
296
- } catch (_e) { /* not valid JSON — fall through */ }
297
- }
298
- const num = parseFloat(trimmed);
299
- if (Number.isFinite(num)) return [toPct(num)];
300
- }
301
- return [0];
302
- }
303
-
304
239
  /**
305
240
  * Walk the Slider subtree and rebuild it so the indicator + thumbs
306
241
  * reflect the value prop:
@@ -322,7 +257,8 @@ function applySliderPositioning(
322
257
  if (getDataSlot(root) !== 'slider') return null;
323
258
  const rawValue = root.props && (root.props.defaultValue ?? root.props.value);
324
259
  const rawMax = root.props ? root.props.max : undefined;
325
- const pcts = resolveSliderValues(rawValue, rawMax);
260
+ // base-ui's Slider is min=0..max only; pass min=0 explicitly.
261
+ const pcts = resolveValuePercents(rawValue, 0, rawMax);
326
262
  if (pcts.length === 0) return null;
327
263
 
328
264
  // Figma plugin sandbox doesn't support spread (`...arr`); compute min/max
@@ -433,6 +369,74 @@ function applySliderPositioning(
433
369
  return touched ? single : null;
434
370
  }
435
371
 
372
+ /**
373
+ * Drop Radix ScrollArea control children AND flatten the Viewport
374
+ * wrapper. Two operations rolled into one pass because they target
375
+ * sibling positions inside the same ScrollAreaPrimitive.Root subtree.
376
+ *
377
+ * 1. `ScrollAreaPrimitive.Scrollbar` / `.Thumb` / `.Corner` are
378
+ * runtime-only behavioural overlays (scrollbar fades in while the
379
+ * user scrolls, Corner only appears when both axes overflow). In a
380
+ * static design-system render they manifest as visible content
381
+ * blobs inside the scroll-area. Strip them.
382
+ *
383
+ * 2. `ScrollAreaPrimitive.Viewport` is a structural wrapper Radix
384
+ * uses for `overflow: hidden` + scroll-position tracking — it
385
+ * carries no visual styling beyond `h-full w-full
386
+ * rounded-[inherit]`. In Figma the default frame fill (white)
387
+ * paints it as a second nested rounded rectangle, producing the
388
+ * "double card / ghost-border" effect the user reported. Flatten
389
+ * by pulling Viewport's children up to be Root's direct children.
390
+ *
391
+ * Returns the rewritten children array when something changed, `null`
392
+ * when the subtree was untouched (so callers can keep object identity
393
+ * stable for caching).
394
+ */
395
+ const RADIX_SCROLL_AREA_RUNTIME_TAGS = new Set([
396
+ 'ScrollAreaPrimitive.Scrollbar',
397
+ 'ScrollAreaPrimitive.Thumb',
398
+ 'ScrollAreaPrimitive.Corner',
399
+ ]);
400
+
401
+ /**
402
+ * JSX-tree tags the shadcn adapter unconditionally drops at render time.
403
+ * Exposed so pre-render scans that walk classes (e.g. responsive-signal
404
+ * detection) can ignore classes that won't actually reach Figma — without
405
+ * this, a `sm:bg-black/60` on `ScrollAreaPrimitive.Scrollbar` falsely
406
+ * trips `treeHasResponsiveClasses` and emits a duplicate-content
407
+ * Responsive preview block even though every breakpoint render is
408
+ * identical post-adapter.
409
+ */
410
+ export function isShadcnAdapterDroppedTag(tagName: string): boolean {
411
+ return RADIX_SCROLL_AREA_RUNTIME_TAGS.has(tagName);
412
+ }
413
+ function flattenScrollAreaSubtree(node: NodeIR): NodeIR[] | null {
414
+ if (!('children' in node) || !Array.isArray(node.children)) return null;
415
+ let changed = false;
416
+ const next: NodeIR[] = [];
417
+ for (const child of node.children) {
418
+ if (!isClassedElement(child)) {
419
+ next.push(child);
420
+ continue;
421
+ }
422
+ const tag = (child as { tagName?: string }).tagName;
423
+ if (typeof tag === 'string' && RADIX_SCROLL_AREA_RUNTIME_TAGS.has(tag)) {
424
+ changed = true;
425
+ continue;
426
+ }
427
+ if (tag === 'ScrollAreaPrimitive.Viewport') {
428
+ const viewportChildren = ('children' in child && Array.isArray(child.children))
429
+ ? child.children
430
+ : [];
431
+ for (const vc of viewportChildren) next.push(vc);
432
+ changed = true;
433
+ continue;
434
+ }
435
+ next.push(child);
436
+ }
437
+ return changed ? next : null;
438
+ }
439
+
436
440
  /**
437
441
  * Recurse the IR, injecting registry classes for any element whose
438
442
  * `data-slot` matches a known shadcn pattern AND applying shadcn-specific
@@ -487,6 +491,18 @@ export function applyShadcnAdapter(node: NodeIR): NodeIR {
487
491
  }
488
492
  }
489
493
 
494
+ // Tree transform: Radix ScrollArea drops its Scrollbar / Thumb /
495
+ // Corner overlays (runtime-only behaviour, not visual content) AND
496
+ // flattens its Viewport wrapper (no visual styling of its own —
497
+ // Figma's default frame fill would render it as a ghost-bordered
498
+ // duplicate of Root).
499
+ if (isClassedElement(nextNode)) {
500
+ const filtered = flattenScrollAreaSubtree(nextNode);
501
+ if (filtered) {
502
+ nextNode = Object.assign({}, nextNode, { children: filtered });
503
+ }
504
+ }
505
+
490
506
  // Tree transform: shadcn Progress reads `value` on Root and (at
491
507
  // runtime) inline-styles the Indicator's transform/width. Inject a
492
508
  // `w-[N%]` class on the descendant indicator so the universal
@@ -18,6 +18,7 @@ import {
18
18
  hasResponsiveVariant as hasResponsiveVariantSemantic,
19
19
  variantState as variantStateSemantic,
20
20
  } from '../tailwind';
21
+ import type { RingInfo } from './ring-utils';
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // State stores
@@ -67,6 +68,13 @@ const MAX_WIDTH_NODES = new WeakMap<SceneNode, number>(); // child → max-width
67
68
  const DEFERRED_CENTER_Y_NODES = new WeakSet<SceneNode>(); // absolute children needing cross-axis centering
68
69
  const ASPECT_RATIO_NODES = new WeakMap<SceneNode, number>(); // node → width/height ratio (e.g. 5/3 for aspect-5/3)
69
70
  const BORDER_WIDTH_CLASSES = new WeakMap<SceneNode, string[]>();
71
+ // Ring overlays (Tailwind `ring-*`). Eagerly creating the overlay during
72
+ // frame construction inflates Hug-sized parents during the brief flex-child
73
+ // moment between `appendChild` and `layoutPositioning = 'ABSOLUTE'`. Marking
74
+ // at parse time and applying at post-pass time (when the host has all its
75
+ // content children and we can lock its sizing modes) makes the inflation
76
+ // class impossible. See `applyRingIfPossible` below.
77
+ const RING_NODES = new WeakMap<SceneNode, RingInfo>();
70
78
  // SVG icon wraps whose inner SVG vectors must be rescaled when the wrap
71
79
  // itself resizes (e.g. an inline `<svg className="absolute inset-0 h-full
72
80
  // w-full">` overlay that originally got a 24×24 default size from
@@ -104,6 +112,35 @@ export function markAbsoluteNode(node: SceneNode): void {
104
112
  ABSOLUTE_NODES.add(node);
105
113
  }
106
114
 
115
+ /**
116
+ * Mark a node as needing a Tailwind `ring-*` overlay. The actual overlay
117
+ * frame is created later by `applyRingIfPossible` once the host's content
118
+ * children are all in place — that's the only moment when we can lock the
119
+ * host's sizing modes briefly without truncating its natural size.
120
+ *
121
+ * The mark is per-node and idempotent — repeated calls overwrite the prior
122
+ * `RingInfo`, which matches how Tailwind's class cascade works (the last
123
+ * `ring-*` utility in the class list wins).
124
+ */
125
+ export function markRingNode(node: SceneNode, ring: RingInfo): void {
126
+ RING_NODES.set(node, ring);
127
+ }
128
+
129
+ export function hasRingNode(node: SceneNode): boolean {
130
+ return RING_NODES.has(node);
131
+ }
132
+
133
+ /**
134
+ * Read the ring mark off a node. Used to bridge node-identity transitions
135
+ * — e.g. `figma.createComponentFromNode(sourceNode)` creates a new node
136
+ * identity, so the WeakMap entry keyed on `sourceNode` becomes unreachable
137
+ * from the resulting component. Callers read the mark with `getRingNode`
138
+ * before the transition and re-set it on the new node with `markRingNode`.
139
+ */
140
+ export function getRingNode(node: SceneNode): RingInfo | undefined {
141
+ return RING_NODES.get(node);
142
+ }
143
+
107
144
  /**
108
145
  * Lock a frame's aspect ratio (`aspect-5/3`, `aspect-square`, `aspect-[3/4]`).
109
146
  * The ratio is `width / height`. Resolved by `applyAspectRatioIfPossible`
@@ -296,6 +333,132 @@ function reapplyDirectionalBordersIfNeeded(node: SceneNode): void {
296
333
  // Apply functions — resolve intent after a node is appended to its parent
297
334
  // ---------------------------------------------------------------------------
298
335
 
336
+ /**
337
+ * Resolve a marked ring overlay on this host. Called from the post-append
338
+ * pipeline (after `applyAbsoluteIfPossible` / `applyFullWidthIfPossible`)
339
+ * when the host's content children are all in place and its natural W × H
340
+ * is settled.
341
+ *
342
+ * The architecturally important step is **briefly switching the host's
343
+ * auto-layout sizing modes to FIXED** at the host's current dimensions
344
+ * before appending the overlay. The Figma sandbox requires
345
+ * `appendChild` before `layoutPositioning = 'ABSOLUTE'`, so the overlay
346
+ * unavoidably participates in the host's auto-layout for a moment. With
347
+ * the host locked to FIXED, that moment cannot inflate the host — the
348
+ * sizing is no longer derived from children. After the overlay is marked
349
+ * ABSOLUTE, the host's sizing modes are restored to AUTO; the host then
350
+ * re-measures from its non-absolute children only, returning to its
351
+ * natural size with no drift.
352
+ *
353
+ * Ring geometry: overlay matches the host's W × H exactly, with
354
+ * `strokeAlign: 'OUTSIDE'` so the ring paints past the geometric edge
355
+ * (matching CSS `box-shadow: 0 0 0 N <color>` — see the "DO NOT use
356
+ * Figma DROP_SHADOW spread parameter" entry in `.ai/troubleshooting.md`
357
+ * for why `spread`-based effects fail on transparent frames).
358
+ *
359
+ * `parent` is accepted for API symmetry with the other `apply*IfPossible`
360
+ * functions; it isn't used directly. The host's `clipsContent` IS toggled
361
+ * off so the OUTSIDE stroke isn't clipped.
362
+ */
363
+ export function applyRingIfPossible(host: SceneNode, _parent: FrameNode): void {
364
+ const ring = RING_NODES.get(host);
365
+ if (!ring) return;
366
+ if (!('layoutMode' in host) || !('resize' in host) || !('appendChild' in host)) {
367
+ RING_NODES.delete(host);
368
+ return;
369
+ }
370
+ const hostFrame = host as FrameNode;
371
+ const width = typeof hostFrame.width === 'number' ? hostFrame.width : 0;
372
+ const height = typeof hostFrame.height === 'number' ? hostFrame.height : 0;
373
+ if (!(width > 0) || !(height > 0)) {
374
+ // Host has no usable dimensions yet — leave the mark in place so a later
375
+ // reflow pass (after width-solver, aspect-ratio resolution, etc.) can
376
+ // retry. Idempotent: re-running applyRingIfPossible after dimensions
377
+ // settle picks up where we left off.
378
+ return;
379
+ }
380
+
381
+ // Remove any stale overlay from a previous pass (e.g. when the State Matrix
382
+ // re-renders a component master).
383
+ const children = hostFrame.children;
384
+ if (Array.isArray(children) && children.length > 0) {
385
+ for (let i = children.length - 1; i >= 0; i--) {
386
+ const child = children[i];
387
+ if (!child || child.name !== '__inkbridge-ring__') continue;
388
+ try { child.remove(); } catch (_e) { /* ignore */ }
389
+ }
390
+ }
391
+
392
+ const originalPrimary = hostFrame.primaryAxisSizingMode;
393
+ const originalCounter = hostFrame.counterAxisSizingMode;
394
+ const primaryWasAuto = originalPrimary === 'AUTO';
395
+ const counterWasAuto = originalCounter === 'AUTO';
396
+
397
+ // Lock host at current dimensions so the brief flex-child moment of the
398
+ // overlay's appendChild can't grow the host. This is the invariant that
399
+ // makes the inflation class impossible.
400
+ try {
401
+ if (primaryWasAuto) hostFrame.primaryAxisSizingMode = 'FIXED';
402
+ if (counterWasAuto) hostFrame.counterAxisSizingMode = 'FIXED';
403
+ hostFrame.resize(width, height);
404
+ } catch (_e) { /* ignore */ }
405
+
406
+ const overlay = figma.createFrame();
407
+ overlay.name = '__inkbridge-ring__';
408
+ overlay.resize(width, height);
409
+ overlay.fills = [];
410
+ overlay.strokes = [{
411
+ type: 'SOLID',
412
+ color: { r: ring.color.r, g: ring.color.g, b: ring.color.b },
413
+ opacity: ring.color.a == null ? 1 : ring.color.a,
414
+ }];
415
+ overlay.strokeWeight = ring.width;
416
+ try { overlay.strokeAlign = 'OUTSIDE'; } catch (_e) { /* ignore */ }
417
+ try {
418
+ (overlay as { strokesIncludedInLayout?: boolean }).strokesIncludedInLayout = false;
419
+ } catch (_e) { /* ignore */ }
420
+
421
+ // Mirror host corner radii — OUTSIDE stroke extends the visible perimeter
422
+ // outward automatically, no `+ ringWidth` arithmetic needed.
423
+ const nodeRadius = hostFrame.cornerRadius;
424
+ if (typeof nodeRadius === 'number') {
425
+ overlay.cornerRadius = nodeRadius;
426
+ } else {
427
+ const tl = typeof hostFrame.topLeftRadius === 'number' ? hostFrame.topLeftRadius : null;
428
+ const tr = typeof hostFrame.topRightRadius === 'number' ? hostFrame.topRightRadius : null;
429
+ const br = typeof hostFrame.bottomRightRadius === 'number' ? hostFrame.bottomRightRadius : null;
430
+ const bl = typeof hostFrame.bottomLeftRadius === 'number' ? hostFrame.bottomLeftRadius : null;
431
+ if (tl != null && tr != null && br != null && bl != null) {
432
+ overlay.topLeftRadius = tl;
433
+ overlay.topRightRadius = tr;
434
+ overlay.bottomRightRadius = br;
435
+ overlay.bottomLeftRadius = bl;
436
+ }
437
+ }
438
+
439
+ try { hostFrame.appendChild(overlay); } catch (_e) { /* ignore */ }
440
+ try { overlay.layoutPositioning = 'ABSOLUTE'; } catch (_e) { /* ignore */ }
441
+ overlay.x = 0;
442
+ overlay.y = 0;
443
+ try { hostFrame.clipsContent = false; } catch (_e) { /* ignore */ }
444
+ // Z-order: overlay sits in front of host's content children (last in
445
+ // `children` array) — exactly what CSS box-shadow does (rendered on top
446
+ // of the host's content along the stroke band).
447
+
448
+ // Restore the host's original sizing modes. The overlay is now ABSOLUTE
449
+ // and excluded from the host's flow, so AUTO sizing recomputes from the
450
+ // host's content children only (returning to the natural size).
451
+ try {
452
+ if (primaryWasAuto) hostFrame.primaryAxisSizingMode = 'AUTO';
453
+ if (counterWasAuto) hostFrame.counterAxisSizingMode = 'AUTO';
454
+ } catch (_e) { /* ignore */ }
455
+
456
+ // Keep the mark in `RING_NODES` so reflow passes (e.g. after
457
+ // `applyFullWidthIfPossible` resizes the host) can re-run this function
458
+ // idempotently — the stale-overlay-removal step at the top of this
459
+ // function makes re-application safe.
460
+ }
461
+
299
462
  export function applyAbsoluteIfPossible(child: SceneNode, parent: FrameNode): void {
300
463
  if (!ABSOLUTE_NODES.has(child)) return;
301
464
  const parentIsAutoLayout = 'layoutMode' in parent && parent.layoutMode && parent.layoutMode !== 'NONE';
@@ -645,6 +808,8 @@ export function reflowDeferredAbsolutePositioningTree(root: SceneNode): void {
645
808
  for (const child of rootFrame.children) {
646
809
  if (!FULL_WIDTH_NODES.has(child) && !FULL_HEIGHT_NODES.has(child)) continue;
647
810
  applyFullWidthIfPossible(child, rootFrame);
811
+ // Re-sync any ring overlay to the child's new width/height.
812
+ applyRingIfPossible(child, rootFrame);
648
813
  // Standard `applyFullWidthIfPossible` for a non-absolute child in
649
814
  // a VERTICAL parent uses `layoutAlign='STRETCH'` + `layoutGrow=1`,
650
815
  // which Figma resolves asynchronously and doesn't trigger our
@@ -706,6 +871,20 @@ export function applyFullWidthIfPossible(
706
871
  try { child.layoutAlign = align; } catch (_err) { /* ignore */ }
707
872
  }
708
873
  } else {
874
+ // `ml-auto` on a horizontal-flex child means "push me along the primary
875
+ // axis" in CSS. The child-level MAX mark alone can't express that — the
876
+ // Figma equivalent is the parent flipping to SPACE_BETWEEN so the child
877
+ // anchors to the end while siblings stay at MIN. Only promote when the
878
+ // parent's primary alignment is still the default MIN (consumer didn't
879
+ // set justify-*) and the parent has at least two children to space out.
880
+ if (
881
+ align === 'MAX'
882
+ && parent.layoutMode === 'HORIZONTAL'
883
+ && parent.primaryAxisAlignItems === 'MIN'
884
+ && parent.children.length >= 2
885
+ ) {
886
+ try { parent.primaryAxisAlignItems = 'SPACE_BETWEEN'; } catch (_err) { /* ignore */ }
887
+ }
709
888
  // MIN / CENTER / MAX: avoid forcing HUG when an explicit width utility exists.
710
889
  // Otherwise classes like w-9 can be collapsed back to content width.
711
890
  if (hasMarkedFixedWidth) {
@@ -1113,7 +1292,14 @@ export function applyGridColumnsIfPossible(
1113
1292
  colsOverride?: number
1114
1293
  ): void {
1115
1294
  const cols = colsOverride != null ? colsOverride : GRID_COLUMNS_NODES.get(frame);
1116
- if (!cols || cols <= 0) return;
1295
+ // A single-column grid (`grid` with no `grid-cols-N`, or an explicit
1296
+ // `grid-cols-1`) is CSS-semantically a vertical stack. Flipping to
1297
+ // HORIZONTAL + WRAP for it produces a one-row layout that
1298
+ // Hug-grows along its counter axis to the sum of children — exactly
1299
+ // the bench-renders-horizontal bug that recurred at multiple call
1300
+ // sites until the guard moved here. Single source of truth: callers
1301
+ // can forward whatever `cols` they extracted and rely on this gate.
1302
+ if (!cols || cols <= 1) return;
1117
1303
  if (colsOverride != null) {
1118
1304
  GRID_COLUMNS_NODES.set(frame, cols);
1119
1305
  }
@@ -1,4 +1,4 @@
1
- import { applyAspectRatioIfPossible, applyFullWidthIfPossible, applyGridColumnsIfPossible, getGridColumnsNode, hasGridColumnsNode, getColSpanNode, markFullWidthNode } from './deferred-layout';
1
+ import { applyAspectRatioIfPossible, applyFullWidthIfPossible, applyRingIfPossible, applyGridColumnsIfPossible, getGridColumnsNode, hasGridColumnsNode, getColSpanNode, markFullWidthNode } from './deferred-layout';
2
2
  import { getBaseClass } from '../tailwind';
3
3
 
4
4
  export type LayoutWrapContext = {
@@ -43,6 +43,7 @@ function reflowFullWidthInSubtree(parent: FrameNode): void {
43
43
 
44
44
  for (const child of parent.children) {
45
45
  applyFullWidthIfPossible(child, parent, options);
46
+ applyRingIfPossible(child, parent);
46
47
  applyAspectRatioIfPossible(child);
47
48
  if ('layoutMode' in child) {
48
49
  const childFrame = child as FrameNode;