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.
- package/README.md +2 -1
- package/bin/inkbridge.mjs +64 -9
- package/code.js +11 -11
- package/package.json +8 -2
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/component-scanner.ts +276 -19
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/framework-adapter-shadcn-regression.ts +96 -1
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/ring-utility-regression.ts +25 -4
- package/scanner/state-classification-regression.ts +38 -0
- package/scanner/stretch-to-parent-width-regression.ts +35 -1
- package/scanner/tailwind-parser.ts +38 -2
- package/src/components/component-gen.ts +11 -151
- package/src/design-system/cva-master.ts +7 -3
- package/src/design-system/design-system.ts +8 -0
- package/src/design-system/node-helpers.ts +15 -1
- package/src/design-system/preview-builder.ts +14 -45
- package/src/design-system/state-master.ts +23 -1
- package/src/design-system/story-builder.ts +55 -5
- package/src/design-system/ui-builder.ts +116 -6
- package/src/framework-adapters/index.ts +15 -2
- package/src/framework-adapters/shadcn.ts +83 -67
- package/src/layout/deferred-layout.ts +187 -1
- package/src/layout/layout-utils.ts +2 -1
- package/src/layout/ring-utils.ts +31 -82
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/tailwind/jsx-utils.ts +9 -0
- package/src/tailwind/node-ir.ts +172 -0
- package/src/tailwind/tailwind.ts +23 -16
- package/src/tokens/tokens.ts +11 -3
- 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
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|