inkbridge 0.1.0-beta.2 → 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 (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -0,0 +1,1556 @@
1
+ /**
2
+ * deferred-layout.ts — Post-render deferred layout state machine
3
+ *
4
+ * Figma nodes are created and styled before they are appended to their parent,
5
+ * so many sizing/alignment decisions cannot be made at creation time. This module
6
+ * owns the full lifecycle of those deferred decisions:
7
+ *
8
+ * 1. mark* — called during node creation to record intent
9
+ * 2. apply* — called after a node is appended to its parent to resolve intent
10
+ * 3. reflow* — called after the full tree is built to fix up any remaining drift
11
+ *
12
+ * All state lives in module-level WeakSets/WeakMaps keyed on SceneNode, so nodes
13
+ * are automatically garbage-collected and there is no cleanup burden on callers.
14
+ */
15
+
16
+ import {
17
+ parseUtilityClass,
18
+ hasResponsiveVariant as hasResponsiveVariantSemantic,
19
+ variantState as variantStateSemantic,
20
+ } from '../tailwind';
21
+ import type { RingInfo } from './ring-utils';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // State stores
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const ABSOLUTE_NODES = new WeakSet<SceneNode>();
28
+ const CSS_GRID_VERTICAL_FRAMES = new WeakSet<SceneNode>(); // grid without grid-cols-N → single-column, children stretch
29
+ const FULL_WIDTH_NODES = new WeakSet<SceneNode>();
30
+ const FULL_HEIGHT_NODES = new WeakSet<SceneNode>();
31
+ const FIXED_HEIGHT_NODES = new WeakSet<SceneNode>();
32
+ const FRACTION_WIDTH_NODES = new WeakMap<SceneNode, number>();
33
+ const FIXED_WIDTH_NODES = new WeakSet<SceneNode>();
34
+ const SELF_ALIGNMENT_NODES = new WeakMap<SceneNode, 'MIN' | 'CENTER' | 'MAX' | 'STRETCH'>();
35
+ const FLEX_BASIS_NODES = new WeakMap<SceneNode, number>();
36
+ const MIN_WIDTH_NODES = new WeakMap<SceneNode, number>();
37
+ const GRID_COLUMNS_NODES = new WeakMap<SceneNode, number>();
38
+ const COL_SPAN_NODES = new WeakMap<SceneNode, number>(); // child → number of columns spanned
39
+ type PositionInfo = {
40
+ top?: number;
41
+ bottom?: number;
42
+ left?: number;
43
+ right?: number;
44
+ // Percentage-based positions (fractions in [0, 1]). Resolved against the
45
+ // parent's final dimensions in `applyDeferredPercentPositioning`. Stored
46
+ // separately because they can't be evaluated until the parent's width and
47
+ // height are settled (after `applyAspectRatioIfPossible` and friends).
48
+ topPercent?: number;
49
+ bottomPercent?: number;
50
+ leftPercent?: number;
51
+ rightPercent?: number;
52
+ // Self-translate fractions of the child's own width/height — `translate-x-1/2`
53
+ // = 0.5, `-translate-x-1/2` = -0.5, `-translate-x-full` = -1. Applied
54
+ // alongside percent positions to mirror CSS `transform: translate(N%, N%)`
55
+ // on absolute elements. Without this, an icon meant to be centered on
56
+ // `(left-50%, top-50%)` via `-translate-x-1/2 -translate-y-1/2` ends up
57
+ // anchored top-left at the percent point instead.
58
+ translateXFraction?: number;
59
+ translateYFraction?: number;
60
+ hintParentHeight?: number;
61
+ inset?: number;
62
+ };
63
+ const POSITION_INFO_NODES = new WeakMap<SceneNode, PositionInfo>();
64
+ const DEFERRED_BOTTOM_NODES = new WeakMap<SceneNode, number>(); // child → bottom pixel offset
65
+ const DEFERRED_RIGHT_NODES = new WeakMap<SceneNode, number>(); // child → right pixel offset
66
+ const DEFERRED_TOP_RELATIVE_NODES = new WeakMap<SceneNode, number>(); // child → (parentHeight - N) top offset
67
+ const MAX_WIDTH_NODES = new WeakMap<SceneNode, number>(); // child → max-width constraint from max-w-* classes
68
+ const DEFERRED_CENTER_Y_NODES = new WeakSet<SceneNode>(); // absolute children needing cross-axis centering
69
+ const ASPECT_RATIO_NODES = new WeakMap<SceneNode, number>(); // node → width/height ratio (e.g. 5/3 for aspect-5/3)
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>();
78
+ // SVG icon wraps whose inner SVG vectors must be rescaled when the wrap
79
+ // itself resizes (e.g. an inline `<svg className="absolute inset-0 h-full
80
+ // w-full">` overlay that originally got a 24×24 default size from
81
+ // `resolveIconSizeFromClasses`). The callback is registered by the
82
+ // icon-rendering layer with a closure over `resizeSvgNodeTo`, so this
83
+ // module doesn't depend on `effects/icon-builder`.
84
+ const SVG_CONTENT_RESIZE_HOOKS = new WeakMap<SceneNode, (width: number, height: number) => void>();
85
+
86
+ export function markSvgContentWrap(
87
+ wrap: SceneNode,
88
+ resize: (width: number, height: number) => void
89
+ ): void {
90
+ SVG_CONTENT_RESIZE_HOOKS.set(wrap, resize);
91
+ }
92
+
93
+ function resizeSvgContentIfMarked(wrap: SceneNode, width: number, height: number): void {
94
+ const hook = SVG_CONTENT_RESIZE_HOOKS.get(wrap);
95
+ if (!hook) return;
96
+ try {
97
+ hook(width, height);
98
+ } catch (_e) {
99
+ // ignore
100
+ }
101
+ }
102
+
103
+ function isSvgContentWrap(node: SceneNode): boolean {
104
+ return SVG_CONTENT_RESIZE_HOOKS.has(node);
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Mark functions — record intent at node-creation time
109
+ // ---------------------------------------------------------------------------
110
+
111
+ export function markAbsoluteNode(node: SceneNode): void {
112
+ ABSOLUTE_NODES.add(node);
113
+ }
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
+
144
+ /**
145
+ * Lock a frame's aspect ratio (`aspect-5/3`, `aspect-square`, `aspect-[3/4]`).
146
+ * The ratio is `width / height`. Resolved by `applyAspectRatioIfPossible`
147
+ * once the frame's width is settled (after `w-full` / `max-w-*` resolve).
148
+ */
149
+ export function markAspectRatio(node: SceneNode, ratio: number): void {
150
+ if (!Number.isFinite(ratio) || ratio <= 0) return;
151
+ ASPECT_RATIO_NODES.set(node, ratio);
152
+ }
153
+
154
+ export function hasAspectRatio(node: SceneNode): boolean {
155
+ return ASPECT_RATIO_NODES.has(node);
156
+ }
157
+
158
+ export function markPositionInfo(node: SceneNode, info: PositionInfo): void {
159
+ const existing = POSITION_INFO_NODES.get(node) || {};
160
+ POSITION_INFO_NODES.set(node, { ...existing, ...info });
161
+ }
162
+
163
+ export function markFullWidthNode(node: SceneNode): void {
164
+ FULL_WIDTH_NODES.add(node);
165
+ }
166
+
167
+ function markFullHeightNode(node: SceneNode): void {
168
+ FULL_HEIGHT_NODES.add(node);
169
+ }
170
+
171
+ function markFixedHeightNode(node: SceneNode): void {
172
+ FIXED_HEIGHT_NODES.add(node);
173
+ }
174
+
175
+ function markFixedWidthNode(node: SceneNode): void {
176
+ FIXED_WIDTH_NODES.add(node);
177
+ }
178
+
179
+ function markSelfAlignmentNode(node: SceneNode, align: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH'): void {
180
+ SELF_ALIGNMENT_NODES.set(node, align);
181
+ }
182
+
183
+ function markFlexBasisNode(node: SceneNode, basis: number): void {
184
+ if (!Number.isFinite(basis)) return;
185
+ FLEX_BASIS_NODES.set(node, basis);
186
+ }
187
+
188
+ function markMinWidthNode(node: SceneNode, minWidth: number): void {
189
+ if (!Number.isFinite(minWidth)) return;
190
+ MIN_WIDTH_NODES.set(node, minWidth);
191
+ }
192
+
193
+ function markMaxWidthNode(node: SceneNode, maxWidth: number): void {
194
+ if (!Number.isFinite(maxWidth)) return;
195
+ MAX_WIDTH_NODES.set(node, maxWidth);
196
+ }
197
+
198
+ function markGridColumnsNode(node: SceneNode, cols: number): void {
199
+ if (!Number.isFinite(cols) || cols <= 0) return;
200
+ GRID_COLUMNS_NODES.set(node, cols);
201
+ }
202
+
203
+ function markColSpanNode(node: SceneNode, span: number): void {
204
+ if (!Number.isFinite(span) || span <= 0) return;
205
+ COL_SPAN_NODES.set(node, span);
206
+ }
207
+
208
+ export function getColSpanNode(node: SceneNode): number {
209
+ return COL_SPAN_NODES.get(node) ?? 1;
210
+ }
211
+
212
+ function markFractionWidthNode(node: SceneNode, fraction: number): void {
213
+ if (!Number.isFinite(fraction) || fraction <= 0) return;
214
+ FRACTION_WIDTH_NODES.set(node, fraction);
215
+ }
216
+
217
+ // Re-exported so tailwind.ts can mark CSS_GRID_VERTICAL_FRAMES (which stays private here)
218
+ export function markCssGridVerticalFrame(node: SceneNode): void {
219
+ CSS_GRID_VERTICAL_FRAMES.add(node);
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // State variant helpers (used by applyBorderWidthUtilities)
224
+ // ---------------------------------------------------------------------------
225
+
226
+ const STATE_VARIANTS = new Set([
227
+ 'hover',
228
+ 'focus',
229
+ 'focus-visible',
230
+ 'disabled',
231
+ 'active',
232
+ 'aria-invalid',
233
+ ]);
234
+
235
+ function isStateVariant(variant: string): boolean {
236
+ if (STATE_VARIANTS.has(variant)) return true;
237
+ return variant.startsWith('data-[state=');
238
+ }
239
+
240
+ export function shouldApplyAtom(atom: ReturnType<typeof parseUtilityClass>, state: string): boolean {
241
+ if (hasResponsiveVariantSemantic(atom.variants)) return false;
242
+ if (state === 'default') return atom.variants.length === 0;
243
+ if (!atom.variants.length) return false;
244
+ if (!atom.variants.every(isStateVariant)) return false;
245
+ return variantStateSemantic(atom.variants) === state;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Fraction helpers
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function parseFractionToken(token: string): number | null {
253
+ if (!token.includes('/')) return null;
254
+ const [rawNum, rawDen] = token.split('/');
255
+ const num = Number(rawNum);
256
+ const den = Number(rawDen);
257
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null;
258
+ return num / den;
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Border width utilities
263
+ // ---------------------------------------------------------------------------
264
+
265
+
266
+ function setBorderSideWeights(frame: FrameNode, sides: { top: number; right: number; bottom: number; left: number }): void {
267
+ frame.strokeTopWeight = sides.top;
268
+ frame.strokeRightWeight = sides.right;
269
+ frame.strokeBottomWeight = sides.bottom;
270
+ frame.strokeLeftWeight = sides.left;
271
+ // Do not write strokeWeight: in Figma this can normalize all sides and undo
272
+ // directional borders (e.g. border-t becoming full box).
273
+ }
274
+
275
+
276
+ export function applyBorderWidthUtilities(frame: FrameNode, classes: string[]): void {
277
+ let touched = false;
278
+ let next = { top: 0, right: 0, bottom: 0, left: 0 };
279
+ for (const cls of classes) {
280
+ const atom = parseUtilityClass(cls);
281
+ if (!atom.utility) continue;
282
+ if (!shouldApplyAtom(atom, 'default')) continue;
283
+
284
+ const utility = atom.utility;
285
+ if (utility === 'border') {
286
+ next = { top: 1, right: 1, bottom: 1, left: 1 };
287
+ touched = true;
288
+ continue;
289
+ }
290
+ const borderWidthMatch = utility.match(/^border-(\d+)$/);
291
+ if (borderWidthMatch) {
292
+ const weight = parseFloat(borderWidthMatch[1]);
293
+ if (Number.isFinite(weight) && weight >= 0) {
294
+ next = { top: weight, right: weight, bottom: weight, left: weight };
295
+ touched = true;
296
+ }
297
+ continue;
298
+ }
299
+ if (utility === 'border-t') { next.top = 1; touched = true; continue; }
300
+ if (utility === 'border-r') { next.right = 1; touched = true; continue; }
301
+ if (utility === 'border-b') { next.bottom = 1; touched = true; continue; }
302
+ if (utility === 'border-l') { next.left = 1; touched = true; continue; }
303
+ if (utility === 'border-x') { next.left = 1; next.right = 1; touched = true; continue; }
304
+ if (utility === 'border-y') { next.top = 1; next.bottom = 1; touched = true; continue; }
305
+ const directionalBorderWidthMatch = utility.match(/^(border-(?:t|r|b|l|x|y))-(\d+)$/);
306
+ if (directionalBorderWidthMatch) {
307
+ const borderWeight = parseFloat(directionalBorderWidthMatch[2]);
308
+ if (!Number.isFinite(borderWeight) || borderWeight < 0) continue;
309
+ const directionalUtility = directionalBorderWidthMatch[1];
310
+ if (directionalUtility === 'border-t') next.top = borderWeight;
311
+ else if (directionalUtility === 'border-r') next.right = borderWeight;
312
+ else if (directionalUtility === 'border-b') next.bottom = borderWeight;
313
+ else if (directionalUtility === 'border-l') next.left = borderWeight;
314
+ else if (directionalUtility === 'border-x') { next.left = borderWeight; next.right = borderWeight; }
315
+ else if (directionalUtility === 'border-y') { next.top = borderWeight; next.bottom = borderWeight; }
316
+ touched = true;
317
+ continue;
318
+ }
319
+ }
320
+ if (touched) {
321
+ setBorderSideWeights(frame, next);
322
+ }
323
+ }
324
+
325
+ function reapplyDirectionalBordersIfNeeded(node: SceneNode): void {
326
+ const classes = BORDER_WIDTH_CLASSES.get(node);
327
+ if (!classes || classes.length === 0) return;
328
+ if (!('strokes' in node)) return;
329
+ applyBorderWidthUtilities(node as FrameNode, classes);
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Apply functions — resolve intent after a node is appended to its parent
334
+ // ---------------------------------------------------------------------------
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
+
462
+ export function applyAbsoluteIfPossible(child: SceneNode, parent: FrameNode): void {
463
+ if (!ABSOLUTE_NODES.has(child)) return;
464
+ const parentIsAutoLayout = 'layoutMode' in parent && parent.layoutMode && parent.layoutMode !== 'NONE';
465
+ try {
466
+ if (parentIsAutoLayout && 'layoutPositioning' in child) {
467
+ child.layoutPositioning = 'ABSOLUTE';
468
+ }
469
+ // Match CSS stacking: a `position: absolute` child renders ABOVE its
470
+ // non-positioned siblings in the same stacking context. Figma's
471
+ // z-order is the parent.children array index (back-to-front), so we
472
+ // re-append the child to push it to the top. This is what makes a
473
+ // shadcn Avatar.Image overlay its Fallback instead of being hidden
474
+ // behind it.
475
+ try { parent.appendChild(child); } catch (_zerr) { /* ignore */ }
476
+
477
+ // Apply positioning from stored position info
478
+ const posInfo = POSITION_INFO_NODES.get(child);
479
+ if (posInfo) {
480
+ // If the child hints a minimum parent height (e.g. gradient-blob inside collapsed container)
481
+ if (posInfo.hintParentHeight != null && 'resize' in parent) {
482
+ try {
483
+ const ph = posInfo.hintParentHeight;
484
+ const pw = Math.max(1, parent.width);
485
+ // Respect explicit fixed-height parents. Only auto-expand when the
486
+ // parent is effectively unconstrained/collapsed.
487
+ let heightIsFixed = false;
488
+ if ('layoutMode' in parent && 'primaryAxisSizingMode' in parent && 'counterAxisSizingMode' in parent) {
489
+ if (parent.layoutMode === 'VERTICAL') {
490
+ heightIsFixed = parent.primaryAxisSizingMode === 'FIXED';
491
+ } else if (parent.layoutMode === 'HORIZONTAL') {
492
+ heightIsFixed = parent.counterAxisSizingMode === 'FIXED';
493
+ }
494
+ }
495
+ const parentCollapsed = parent.height <= 1;
496
+ if (!heightIsFixed && parentCollapsed && parent.height < ph) {
497
+ parent.resize(pw, ph);
498
+ if ('primaryAxisSizingMode' in parent) parent.primaryAxisSizingMode = 'FIXED';
499
+ }
500
+ } catch (_e) { /* ignore */ }
501
+ }
502
+ if ('x' in child && 'y' in child) {
503
+ const childFrame = child as FrameNode;
504
+ // inset-{n}: position at (n, n) and resize to fill parent minus inset on all sides
505
+ if (posInfo.inset != null) {
506
+ const inset = posInfo.inset;
507
+ childFrame.x = inset;
508
+ childFrame.y = inset;
509
+ const targetW = Math.max(1, parent.width - inset * 2);
510
+ const targetH = Math.max(1, parent.height - inset * 2);
511
+ try {
512
+ childFrame.resize(targetW, targetH);
513
+ if ('primaryAxisSizingMode' in childFrame) childFrame.primaryAxisSizingMode = 'FIXED';
514
+ if ('counterAxisSizingMode' in childFrame) childFrame.counterAxisSizingMode = 'FIXED';
515
+ } catch (_e) { /* ignore */ }
516
+ } else {
517
+ if (posInfo.left != null) {
518
+ childFrame.x = posInfo.left;
519
+ } else if (posInfo.right != null) {
520
+ // Defer right anchoring until parent width is final.
521
+ DEFERRED_RIGHT_NODES.set(child, posInfo.right);
522
+ childFrame.x = parent.width - childFrame.width - posInfo.right;
523
+ }
524
+ if (posInfo.top != null) {
525
+ childFrame.y = posInfo.top;
526
+ } else if (posInfo.bottom != null) {
527
+ // Defer: parent height may not be final yet (flow children added after this call)
528
+ DEFERRED_BOTTOM_NODES.set(child, posInfo.bottom);
529
+ } else if (posInfo.topPercent == null && posInfo.bottomPercent == null) {
530
+ // No explicit top/bottom and no percent — CSS places absolute children
531
+ // at the cross-axis static position. For items-center parents this means
532
+ // vertically centered.
533
+ if (parent.counterAxisAlignItems === 'CENTER') {
534
+ DEFERRED_CENTER_Y_NODES.add(child);
535
+ }
536
+ }
537
+ }
538
+ }
539
+ }
540
+ } catch (_err) {
541
+ // ignore
542
+ }
543
+ ABSOLUTE_NODES.delete(child);
544
+ // Retain percent fields AND translate fractions for the deferred percent
545
+ // resolver — they need the parent's FINAL width/height (which won't exist
546
+ // until aspect-ratio / full-width passes settle) and the child's own final
547
+ // width/height. Pixel fields are consumed; percent + translate stay.
548
+ const remaining = POSITION_INFO_NODES.get(child);
549
+ if (remaining && hasPercentPosition(remaining)) {
550
+ POSITION_INFO_NODES.set(child, {
551
+ topPercent: remaining.topPercent,
552
+ bottomPercent: remaining.bottomPercent,
553
+ leftPercent: remaining.leftPercent,
554
+ rightPercent: remaining.rightPercent,
555
+ translateXFraction: remaining.translateXFraction,
556
+ translateYFraction: remaining.translateYFraction,
557
+ });
558
+ } else {
559
+ POSITION_INFO_NODES.delete(child);
560
+ }
561
+ }
562
+
563
+ function hasPercentPosition(info: PositionInfo): boolean {
564
+ return info.topPercent != null
565
+ || info.bottomPercent != null
566
+ || info.leftPercent != null
567
+ || info.rightPercent != null;
568
+ }
569
+
570
+ /**
571
+ * Resolve any percent-based positions (`top-[X%]`, `left-[X%]`, etc.) on
572
+ * `parent`'s children against the parent's now-final dimensions. Should be
573
+ * called after `applyAspectRatioIfPossible(parent)` and the full-width pass
574
+ * — by that point `parent.width` and `parent.height` reflect the final size
575
+ * those percentages should resolve against.
576
+ *
577
+ * Mirrors CSS: `top: 30%` is `0.3 * containing-block-height`,
578
+ * `left: 50%` is `0.5 * containing-block-width`. `bottom: 70%` anchors the
579
+ * child's bottom edge at 70% from the parent's bottom (= 30% from top), so
580
+ * `child.y = parent.height * (1 - 0.7) - child.height`.
581
+ */
582
+ export function applyDeferredPercentPositioning(parent: FrameNode): void {
583
+ if (!('children' in parent)) return;
584
+ const pw = parent.width;
585
+ const ph = parent.height;
586
+ if (!Number.isFinite(pw) || !Number.isFinite(ph) || pw <= 0 || ph <= 0) return;
587
+
588
+ for (const child of parent.children as SceneNode[]) {
589
+ const info = POSITION_INFO_NODES.get(child);
590
+ if (!info || !hasPercentPosition(info)) continue;
591
+ if (!('x' in child) || !('y' in child)) continue;
592
+ const childFrame = child as FrameNode;
593
+ const cw = Number.isFinite(childFrame.width) ? childFrame.width : 0;
594
+ const chh = Number.isFinite(childFrame.height) ? childFrame.height : 0;
595
+ try {
596
+ if (info.leftPercent != null) {
597
+ childFrame.x = pw * info.leftPercent;
598
+ } else if (info.rightPercent != null) {
599
+ childFrame.x = pw - cw - pw * info.rightPercent;
600
+ }
601
+ if (info.topPercent != null) {
602
+ childFrame.y = ph * info.topPercent;
603
+ } else if (info.bottomPercent != null) {
604
+ childFrame.y = ph - chh - ph * info.bottomPercent;
605
+ }
606
+ // Apply self-translate (CSS `transform: translate(N%, N%)`) — offset
607
+ // by the child's OWN width/height times the fraction. Most common
608
+ // case is `-translate-x-1/2 -translate-y-1/2` (center on point).
609
+ if (info.translateXFraction != null) {
610
+ childFrame.x += cw * info.translateXFraction;
611
+ }
612
+ if (info.translateYFraction != null) {
613
+ childFrame.y += chh * info.translateYFraction;
614
+ }
615
+ } catch (_e) {
616
+ // ignore
617
+ }
618
+ // Keep the mark — application is idempotent and the parent's dimensions
619
+ // may still change (aspect-ratio retry, w-full resolution, etc.). The
620
+ // final `reflowDeferredAbsolutePositioningTree` pass uses these marks to
621
+ // re-resolve positions against the parent's final size. Earlier this
622
+ // was deleted after use, which caused percent-positioned children to
623
+ // freeze at coordinates computed from intermediate parent dimensions
624
+ // (e.g. parent hugging to one child before aspect-ratio resolved).
625
+ }
626
+ }
627
+
628
+ /** Re-apply right-anchored x positions for children of a frame after its width changes. */
629
+ export function reapplyRightPositionedChildren(frame: FrameNode, newWidth: number): void {
630
+ if (!('children' in frame)) return;
631
+ for (const child of (frame.children as SceneNode[])) {
632
+ const right = DEFERRED_RIGHT_NODES.get(child);
633
+ if (right != null) {
634
+ try {
635
+ child.x = newWidth - child.width - right;
636
+ } catch (_e) { /* ignore */ }
637
+ }
638
+ }
639
+ }
640
+
641
+ function reflowNestedFullWidthChildren(parent: FrameNode): void {
642
+ if (!parent || !('children' in parent)) return;
643
+ const padding = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
644
+ const widthOverride = Number.isFinite(parent.width) ? Math.max(0, parent.width - padding) : null;
645
+ const options = widthOverride != null ? { widthOverride: widthOverride } : undefined;
646
+ for (const child of (parent.children as SceneNode[])) {
647
+ if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') continue;
648
+ applyFullWidthIfPossible(child, parent, options);
649
+ if ('layoutMode' in child) {
650
+ reflowNestedFullWidthChildren(child as FrameNode);
651
+ }
652
+ }
653
+ }
654
+
655
+ /** Call after all children have been appended to a frame to apply bottom-anchored positions. */
656
+ export function applyDeferredBottomPositioning(parent: FrameNode): void {
657
+ for (const child of (parent.children as SceneNode[])) {
658
+ const right = DEFERRED_RIGHT_NODES.get(child);
659
+ if (right != null) {
660
+ try {
661
+ child.x = parent.width - child.width - right;
662
+ } catch (_e) { /* ignore */ }
663
+ // Do not delete — applyFullWidthIfPossible may recalculate after parent is resized.
664
+ }
665
+
666
+ const bottom = DEFERRED_BOTTOM_NODES.get(child);
667
+ if (bottom != null) {
668
+ try {
669
+ child.y = parent.height - child.height - bottom;
670
+ } catch (_e) { /* ignore */ }
671
+ DEFERRED_BOTTOM_NODES.delete(child);
672
+ }
673
+ // Handle top-[calc(100%-Nrem)]: top = parentHeight - N
674
+ const topRelative = DEFERRED_TOP_RELATIVE_NODES.get(child);
675
+ if (topRelative != null) {
676
+ try {
677
+ child.y = parent.height - topRelative;
678
+ } catch (_e) { /* ignore */ }
679
+ DEFERRED_TOP_RELATIVE_NODES.delete(child);
680
+ }
681
+ }
682
+ }
683
+
684
+ /** Call after all children have been appended to center absolute children with no explicit top/bottom. */
685
+ export function applyDeferredCenterYPositioning(parent: FrameNode): void {
686
+ const parentHeight = parent.height;
687
+ if (parentHeight <= 0) return;
688
+ for (const child of (parent.children as SceneNode[])) {
689
+ if (DEFERRED_CENTER_Y_NODES.has(child)) {
690
+ try {
691
+ const childHeight = child.height || 0;
692
+ child.y = Math.round((parentHeight - childHeight) / 2);
693
+ } catch (_e) { /* ignore */ }
694
+ DEFERRED_CENTER_Y_NODES.delete(child);
695
+ }
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Resolve a marked aspect ratio against the node's now-settled width.
701
+ * Caller is responsible for calling this AFTER any width-deciding pass
702
+ * (e.g. `applyFullWidthIfPossible`) so `child.width` reflects the final
703
+ * value. Computes `height = width / ratio`, resizes, and locks both
704
+ * sizing axes to FIXED so subsequent auto-layout passes don't collapse it.
705
+ *
706
+ * Mirrors CSS `aspect-ratio: <ratio>` semantics — if a `width` is set,
707
+ * the height is computed from the ratio; explicit `h-*` utilities will
708
+ * be overridden, which matches Tailwind's behaviour.
709
+ */
710
+ export function applyAspectRatioIfPossible(child: SceneNode): void {
711
+ const ratio = ASPECT_RATIO_NODES.get(child);
712
+ if (ratio == null) return;
713
+ if (!('resize' in child) || !('width' in child)) return;
714
+ const w = (child as FrameNode).width;
715
+ if (!Number.isFinite(w) || w <= 0) return;
716
+ const h = w / ratio;
717
+ if (!Number.isFinite(h) || h <= 0) return;
718
+ try {
719
+ (child as FrameNode).resize(w, h);
720
+ if ('primaryAxisSizingMode' in child) {
721
+ (child as FrameNode).primaryAxisSizingMode = 'FIXED';
722
+ }
723
+ if ('counterAxisSizingMode' in child) {
724
+ (child as FrameNode).counterAxisSizingMode = 'FIXED';
725
+ }
726
+ } catch (_e) {
727
+ // ignore
728
+ }
729
+ ASPECT_RATIO_NODES.delete(child);
730
+ }
731
+
732
+ /**
733
+ * Re-apply deferred absolute positioning after the layout tree has settled.
734
+ * This is a post-pass used to avoid timing/order drift when parent widths/heights
735
+ * change after initial child construction.
736
+ *
737
+ * Aspect-ratio retry is part of this reflow: a frame like
738
+ * `<div className="aspect-5/3 w-full">` whose children are all absolute
739
+ * collapses to 0 height under hug-content layout. The earlier
740
+ * `applyAspectRatioIfPossible` pass may have run while `width` was still 0
741
+ * (mark retained for retry). Without re-attempting it here, the parent
742
+ * stays at 0 height and `applyDeferredPercentPositioning` bails — leaving
743
+ * every percent-positioned child stacked at y=0.
744
+ */
745
+ export function reflowDeferredAbsolutePositioningTree(root: SceneNode): void {
746
+ if (!root) return;
747
+ if (!('children' in root)) return;
748
+ for (const child of root.children) {
749
+ reflowDeferredAbsolutePositioningTree(child);
750
+ }
751
+ if ('layoutMode' in root) {
752
+ try {
753
+ applyAspectRatioIfPossible(root as FrameNode);
754
+ const rootFrame = root as FrameNode;
755
+ // Detect "pure positioning container" — every child is absolute. In CSS
756
+ // such a parent's display mode is irrelevant (block / flex / grid all
757
+ // behave the same when children are out-of-flow). In Figma, leaving an
758
+ // auto-layout VERTICAL/HORIZONTAL mode on such a parent causes Figma to
759
+ // try to flow children and re-hug the parent height back to 0 even
760
+ // after `applyAspectRatioIfPossible` set FIXED sizing. Switch to NONE
761
+ // (freeform) so the aspect-ratio size sticks and percent positioning
762
+ // resolves against the real dimensions. Round-trip-section's triangle
763
+ // is the canonical case: `aspect-5/3 w-full` with one absolute SVG
764
+ // overlay + three absolute icon nodes.
765
+ if (
766
+ 'children' in rootFrame
767
+ && rootFrame.children.length > 0
768
+ && rootFrame.layoutMode !== 'NONE'
769
+ ) {
770
+ let allAbs = true;
771
+ for (const child of rootFrame.children) {
772
+ const isAbs = ABSOLUTE_NODES.has(child)
773
+ || ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE');
774
+ if (!isAbs) {
775
+ allAbs = false;
776
+ break;
777
+ }
778
+ }
779
+ if (allAbs) {
780
+ try {
781
+ rootFrame.layoutMode = 'NONE';
782
+ } catch (_err) {
783
+ // ignore
784
+ }
785
+ }
786
+ }
787
+ // Re-apply fill-parent marks now that the parent dimensions are
788
+ // settled. Critical for two patterns:
789
+ // 1. SVG overlay `absolute inset-0 h-full w-full` inside an
790
+ // `aspect-5/3` container — initial size was computed against
791
+ // parent.height === 0.
792
+ // 2. Bridge SVG `width="100%" height="100%"` inside `aspect-6/1
793
+ // w-full` — same timing issue; the child is a flow child but
794
+ // the parent's aspect-ratio resolved AFTER the initial pass.
795
+ //
796
+ // SKIP for HORIZONTAL+WRAP parents — those are grid containers
797
+ // sized by `applyGridColumnsIfPossible`, where each child's width is
798
+ // the column width (not the full parent width). Re-running
799
+ // `applyFullWidthIfPossible` here would resize each `w-full` grid
800
+ // child to the FULL grid width, forcing each onto its own row
801
+ // (vertical stack instead of N-column grid). Canonical break:
802
+ // MediaCard Grid story with three `w-full` cards inside
803
+ // `grid-cols-3`.
804
+ const isGridWrap = rootFrame.layoutMode === 'HORIZONTAL'
805
+ && 'layoutWrap' in rootFrame
806
+ && rootFrame.layoutWrap === 'WRAP';
807
+ if ('children' in rootFrame && !isGridWrap) {
808
+ for (const child of rootFrame.children) {
809
+ if (!FULL_WIDTH_NODES.has(child) && !FULL_HEIGHT_NODES.has(child)) continue;
810
+ applyFullWidthIfPossible(child, rootFrame);
811
+ // Re-sync any ring overlay to the child's new width/height.
812
+ applyRingIfPossible(child, rootFrame);
813
+ // Standard `applyFullWidthIfPossible` for a non-absolute child in
814
+ // a VERTICAL parent uses `layoutAlign='STRETCH'` + `layoutGrow=1`,
815
+ // which Figma resolves asynchronously and doesn't trigger our
816
+ // `markSvgContentWrap` resize hook. Bridge SVG (`width="100%"
817
+ // height="100%"` inside an `aspect-6/1` wrapper) ends up with the
818
+ // wrap notionally sized but the inner SVG vectors still 24×24.
819
+ // Force a direct resize for SVG content wraps so the hook fires
820
+ // and the vectors scale with the wrap.
821
+ if (
822
+ isSvgContentWrap(child)
823
+ && 'resize' in child
824
+ && Number.isFinite(rootFrame.width)
825
+ && Number.isFinite(rootFrame.height)
826
+ && rootFrame.width > 0
827
+ && rootFrame.height > 0
828
+ ) {
829
+ try {
830
+ const padX = (rootFrame.paddingLeft || 0) + (rootFrame.paddingRight || 0);
831
+ const padY = (rootFrame.paddingTop || 0) + (rootFrame.paddingBottom || 0);
832
+ const targetW = FULL_WIDTH_NODES.has(child)
833
+ ? Math.max(1, rootFrame.width - padX)
834
+ : (child as FrameNode).width;
835
+ const targetH = FULL_HEIGHT_NODES.has(child)
836
+ ? Math.max(1, rootFrame.height - padY)
837
+ : (child as FrameNode).height;
838
+ child.resize(targetW, targetH);
839
+ resizeSvgContentIfMarked(child, targetW, targetH);
840
+ } catch (_err) {
841
+ // ignore
842
+ }
843
+ }
844
+ }
845
+ }
846
+ applyDeferredBottomPositioning(rootFrame);
847
+ applyDeferredCenterYPositioning(rootFrame);
848
+ applyDeferredPercentPositioning(rootFrame);
849
+ } catch (_e) {
850
+ // ignore
851
+ }
852
+ }
853
+ }
854
+
855
+ export function applyFullWidthIfPossible(
856
+ child: SceneNode,
857
+ parent: FrameNode,
858
+ options?: { skipFullWidth?: boolean; widthOverride?: number }
859
+ ): void {
860
+ const hasMarkedFixedWidth = FIXED_WIDTH_NODES.has(child);
861
+ const hasMarkedFractionWidth = FRACTION_WIDTH_NODES.get(child) != null;
862
+ const hasMarkedFullWidth = FULL_WIDTH_NODES.has(child);
863
+ const align = SELF_ALIGNMENT_NODES.get(child);
864
+ const hasSelfAlign = align != null;
865
+ // mx-auto + max-w: CENTER self-alignment combined with full-width mark means
866
+ // the element should be center-aligned and resized to its max-width constraint.
867
+ const isMxAutoFill = align === 'CENTER' && hasMarkedFullWidth;
868
+ if (align) {
869
+ if (align === 'STRETCH') {
870
+ if ('layoutAlign' in child) {
871
+ try { child.layoutAlign = align; } catch (_err) { /* ignore */ }
872
+ }
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
+ }
888
+ // MIN / CENTER / MAX: avoid forcing HUG when an explicit width utility exists.
889
+ // Otherwise classes like w-9 can be collapsed back to content width.
890
+ if (hasMarkedFixedWidth) {
891
+ if ('layoutSizingHorizontal' in child) {
892
+ try { child.layoutSizingHorizontal = 'FIXED'; } catch (_err) { /* ignore if unsupported */ }
893
+ }
894
+ } else if (!hasMarkedFractionWidth && !hasMarkedFullWidth) {
895
+ // Best proxy for non-stretch self alignment when width is content-driven.
896
+ if ('layoutSizingHorizontal' in child) {
897
+ try { child.layoutSizingHorizontal = 'HUG'; } catch (_err) { /* ignore if unsupported */ }
898
+ }
899
+ }
900
+ }
901
+ SELF_ALIGNMENT_NODES.delete(child);
902
+ }
903
+
904
+ const basis = FLEX_BASIS_NODES.get(child);
905
+ if (basis != null && basis > 0 && 'resize' in child) {
906
+ try {
907
+ if (parent.layoutMode === 'HORIZONTAL') {
908
+ child.resize(basis, child.height);
909
+ if ('primaryAxisSizingMode' in child) child.primaryAxisSizingMode = 'FIXED';
910
+ } else if (parent.layoutMode === 'VERTICAL') {
911
+ child.resize(child.width, basis);
912
+ if ('primaryAxisSizingMode' in child) child.primaryAxisSizingMode = 'FIXED';
913
+ }
914
+ } catch (_err) {
915
+ // ignore
916
+ }
917
+ }
918
+ if (basis != null) {
919
+ FLEX_BASIS_NODES.delete(child);
920
+ }
921
+
922
+ const minWidth = MIN_WIDTH_NODES.get(child);
923
+ if (minWidth != null && 'resize' in child) {
924
+ const currentWidth = child.width as number;
925
+ if (!Number.isFinite(currentWidth) || currentWidth < minWidth) {
926
+ try {
927
+ child.resize(minWidth, child.height);
928
+ if (parent.layoutMode === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
929
+ child.primaryAxisSizingMode = 'FIXED';
930
+ } else if (parent.layoutMode === 'VERTICAL' && 'counterAxisSizingMode' in child) {
931
+ child.counterAxisSizingMode = 'FIXED';
932
+ }
933
+ } catch (_err) {
934
+ // ignore
935
+ }
936
+ }
937
+ MIN_WIDTH_NODES.delete(child);
938
+ }
939
+
940
+ const skipFullWidth = !!(options && options.skipFullWidth);
941
+ const rawWidthOverride = options && typeof options.widthOverride === 'number' && Number.isFinite(options.widthOverride)
942
+ ? options.widthOverride
943
+ : null;
944
+ // Bound inherited width overrides to the parent content box — but only when the parent
945
+ // has been explicitly sized (FIXED mode). Auto-sized frames (compFrame, wrapperFrame)
946
+ // may still be at their Figma default width before children are rendered and layout
947
+ // settles; bounding against that default would incorrectly clamp a correct narrow
948
+ // override (e.g. 320px viewport) down to the frame's unset default (~100px).
949
+ const parentIsFixedWidth =
950
+ parent.layoutMode === 'NONE'
951
+ || (parent.layoutMode === 'HORIZONTAL' && parent.primaryAxisSizingMode === 'FIXED')
952
+ || (parent.layoutMode === 'VERTICAL' && parent.counterAxisSizingMode === 'FIXED');
953
+ const parentPaddingX = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
954
+ const parentContentWidth = Math.max(0, parent.width - parentPaddingX);
955
+ const widthOverride = rawWidthOverride != null && parentIsFixedWidth && parentContentWidth > 0
956
+ ? Math.min(rawWidthOverride, parentContentWidth)
957
+ : rawWidthOverride;
958
+ const widthBase = widthOverride != null ? widthOverride : parent.width;
959
+ // CSS grid (single-column) children stretch to fill the column by default (align-items: stretch).
960
+ // Don't stretch absolute-positioned children — they're handled separately.
961
+ const childIsAbsolutePositioned = ABSOLUTE_NODES.has(child)
962
+ || ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE');
963
+ const isGridStretchChild = !skipFullWidth
964
+ && CSS_GRID_VERTICAL_FRAMES.has(parent)
965
+ && !childIsAbsolutePositioned;
966
+ const hasFullWidth = skipFullWidth ? false : (hasMarkedFullWidth || isGridStretchChild);
967
+ const fractionWidth = skipFullWidth ? null : FRACTION_WIDTH_NODES.get(child);
968
+ const hasFixedWidth = hasMarkedFixedWidth;
969
+ const hasFullHeight = FULL_HEIGHT_NODES.has(child);
970
+ if (!hasFullWidth && fractionWidth == null && !hasFullHeight && !hasFixedWidth) return;
971
+ if (hasFixedWidth && parent.layoutMode === 'VERTICAL' && !hasSelfAlign && 'layoutAlign' in child) {
972
+ try {
973
+ child.layoutAlign = 'INHERIT';
974
+ } catch (_err) {
975
+ // ignore
976
+ }
977
+ }
978
+ try {
979
+ if (!hasFullWidth && fractionWidth != null && 'resize' in child && widthBase > 0) {
980
+ // widthOverride is already content-width; only subtract padding when using parent.width
981
+ const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
982
+ const targetWidth = Math.max(0, widthBase - padding) * fractionWidth;
983
+ try {
984
+ child.resize(targetWidth, child.height);
985
+ // Prevent fractional width children from expanding
986
+ if ('layoutGrow' in child) {
987
+ child.layoutGrow = 0;
988
+ }
989
+ if (parent.layoutMode === 'VERTICAL' && !hasSelfAlign && 'layoutAlign' in child) {
990
+ child.layoutAlign = 'INHERIT';
991
+ }
992
+ // Set sizing mode to FIXED so the width is respected
993
+ if ('layoutMode' in child) {
994
+ const childLayout = child.layoutMode;
995
+ if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
996
+ child.primaryAxisSizingMode = 'FIXED';
997
+ } else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
998
+ child.counterAxisSizingMode = 'FIXED';
999
+ }
1000
+ reflowNestedFullWidthChildren(child as FrameNode);
1001
+ } else if (parent.layoutMode === 'VERTICAL' && 'counterAxisSizingMode' in child) {
1002
+ // For non-layout children in vertical parent, fix the width
1003
+ child.counterAxisSizingMode = 'FIXED';
1004
+ }
1005
+ } catch (_err) {
1006
+ // ignore resize errors
1007
+ }
1008
+ }
1009
+
1010
+ if (hasFullWidth) {
1011
+ if (parent.layoutMode === 'HORIZONTAL') {
1012
+ // The synthetic mx-auto wrapper is HORIZONTAL with primaryAxisAlignItems='CENTER'.
1013
+ // When its sole child has a max-w-* constraint, the child is pre-sized to its
1014
+ // max-w and meant to sit centered inside the wrapper. Applying full-width here
1015
+ // would stretch the child back to the wrapper width, defeating the centering.
1016
+ // Without a max-w (e.g. `mx-auto w-full`), the child should still fill — fall
1017
+ // through. The check is structural (parent.name and child's max-w mark) rather
1018
+ // than relying on `isMxAutoFill`, because `SELF_ALIGNMENT_NODES.delete(child)`
1019
+ // above nukes the CENTER mark after the first pass and subsequent calls from
1020
+ // `reflowNestedFullWidthChildren` would otherwise bypass the guard.
1021
+ if (parent.name === 'mx-auto' && MAX_WIDTH_NODES.has(child)) {
1022
+ return;
1023
+ }
1024
+ // When parent uses SPACE_BETWEEN, don't apply layoutGrow=1 because that would
1025
+ // make this child consume all space, leaving nothing for siblings.
1026
+ // In CSS flexbox, width:100% with justify-content:space-between still spaces items at edges.
1027
+ // In Figma, we need to explicitly set layoutGrow=0 and let SPACE_BETWEEN distribute items.
1028
+ const isSpaceBetween = parent.primaryAxisAlignItems === 'SPACE_BETWEEN';
1029
+ if (isSpaceBetween) {
1030
+ // Explicitly set to 0 to prevent any expansion
1031
+ if ('layoutGrow' in child) child.layoutGrow = 0;
1032
+ // Also ensure the child sizes to its content, not to fill
1033
+ if ('primaryAxisSizingMode' in child) {
1034
+ child.primaryAxisSizingMode = 'AUTO';
1035
+ }
1036
+ } else {
1037
+ if ('layoutGrow' in child) child.layoutGrow = 1;
1038
+ }
1039
+ if (
1040
+ (('primaryAxisSizingMode' in parent && parent.primaryAxisSizingMode === 'FIXED') || widthOverride != null)
1041
+ && 'resize' in child
1042
+ && !isSpaceBetween
1043
+ && widthBase > 0
1044
+ ) {
1045
+ try {
1046
+ child.resize(widthBase, child.height);
1047
+ if ('primaryAxisSizingMode' in child) {
1048
+ child.primaryAxisSizingMode = 'FIXED';
1049
+ }
1050
+ reapplyRightPositionedChildren(child as FrameNode, widthBase);
1051
+ if ('layoutMode' in child) {
1052
+ reflowNestedFullWidthChildren(child as FrameNode);
1053
+ }
1054
+ } catch (_err) {
1055
+ // ignore resize errors
1056
+ }
1057
+ }
1058
+ } else if (
1059
+ (childIsAbsolutePositioned || !('layoutMode' in parent) || parent.layoutMode === 'NONE')
1060
+ && 'resize' in child
1061
+ && Number.isFinite(parent.width)
1062
+ && parent.width > 0
1063
+ ) {
1064
+ // Direct-resize path for either:
1065
+ // (a) parents with no auto-layout (positioning containers, e.g.
1066
+ // an `aspect-*` div or a `relative` overlay), or
1067
+ // (b) absolute-positioned children of an auto-layout parent
1068
+ // (e.g. `<svg className="absolute inset-0 h-full w-full">`
1069
+ // inside an `aspect-5/3` triangle frame). For absolute
1070
+ // children, `layoutAlign='STRETCH'` and `layoutGrow=1` are
1071
+ // no-ops — only direct resize works. Mirrors CSS
1072
+ // `width: 100%` semantics.
1073
+ try {
1074
+ const padding = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
1075
+ const rawTarget = Math.max(0, parent.width - padding);
1076
+ const maxWConst = MAX_WIDTH_NODES.get(child);
1077
+ const targetWidth = maxWConst != null ? Math.min(rawTarget, maxWConst) : rawTarget;
1078
+ if (targetWidth > 0) {
1079
+ child.resize(targetWidth, child.height);
1080
+ resizeSvgContentIfMarked(child, targetWidth, child.height);
1081
+ if ('primaryAxisSizingMode' in child) {
1082
+ child.primaryAxisSizingMode = 'FIXED';
1083
+ }
1084
+ if ('counterAxisSizingMode' in child) {
1085
+ child.counterAxisSizingMode = 'FIXED';
1086
+ }
1087
+ }
1088
+ } catch (_err) {
1089
+ // ignore
1090
+ }
1091
+ } else if (parent.layoutMode === 'VERTICAL') {
1092
+ // If the child is HORIZONTAL with SPACE_BETWEEN and already has FIXED sizing,
1093
+ // don't use STRETCH as it conflicts. Instead, keep the explicit FIXED width.
1094
+ const childIsHorizontal = 'layoutMode' in child && child.layoutMode === 'HORIZONTAL';
1095
+ const childHasSpaceBetween = childIsHorizontal && child.primaryAxisAlignItems === 'SPACE_BETWEEN';
1096
+ const childHasFixedWidth = 'primaryAxisSizingMode' in child && child.primaryAxisSizingMode === 'FIXED';
1097
+ if (childHasSpaceBetween && childHasFixedWidth) {
1098
+ // Keep the FIXED sizing, but set layoutAlign to FILL to take parent width
1099
+ // Actually, for SPACE_BETWEEN to work, we need the child to have a specific width
1100
+ // So we resize it to parent width and keep FIXED
1101
+ if ('resize' in child && widthBase > 0) {
1102
+ try {
1103
+ const padding = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
1104
+ const resizedWidth = widthBase - padding;
1105
+ child.resize(resizedWidth, child.height);
1106
+ reapplyRightPositionedChildren(child as FrameNode, resizedWidth);
1107
+ } catch (_err) {
1108
+ // ignore
1109
+ }
1110
+ }
1111
+ } else if (isMxAutoFill) {
1112
+ // mx-auto + max-w: center-align child and resize to max-w constraint.
1113
+ // `layoutAlign` only accepts 'INHERIT' | 'STRETCH' in current Figma —
1114
+ // legacy 'CENTER' triggers a console warning per call. Use
1115
+ // `counterAxisAlignSelf` for non-STRETCH alignment.
1116
+ if ('counterAxisAlignSelf' in child) {
1117
+ try { child.counterAxisAlignSelf = 'CENTER'; } catch (_e) { /* ignore */ }
1118
+ }
1119
+ if ('resize' in child && widthBase > 0) {
1120
+ try {
1121
+ const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
1122
+ const rawTarget = Math.max(0, widthBase - padding);
1123
+ const maxWConst = MAX_WIDTH_NODES.get(child);
1124
+ const targetWidth = maxWConst != null ? Math.min(rawTarget, maxWConst) : rawTarget;
1125
+ if (targetWidth > 0) {
1126
+ child.resize(targetWidth, child.height);
1127
+ reapplyRightPositionedChildren(child as FrameNode, targetWidth);
1128
+ if ('layoutMode' in child) {
1129
+ const childLayout = child.layoutMode;
1130
+ if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
1131
+ child.primaryAxisSizingMode = 'FIXED';
1132
+ } else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
1133
+ child.counterAxisSizingMode = 'FIXED';
1134
+ }
1135
+ reflowNestedFullWidthChildren(child as FrameNode);
1136
+ } else if ('counterAxisSizingMode' in child) {
1137
+ child.counterAxisSizingMode = 'FIXED';
1138
+ }
1139
+ }
1140
+ } catch (_err) {
1141
+ // ignore
1142
+ }
1143
+ }
1144
+ } else {
1145
+ if ('layoutAlign' in child) child.layoutAlign = 'STRETCH';
1146
+ const forceResizeForMxAutoWrapper = child.name === 'mx-auto';
1147
+ if (
1148
+ (
1149
+ ('counterAxisSizingMode' in parent && parent.counterAxisSizingMode === 'FIXED')
1150
+ || widthOverride != null
1151
+ || forceResizeForMxAutoWrapper
1152
+ )
1153
+ && 'resize' in child
1154
+ ) {
1155
+ try {
1156
+ // widthOverride is already content-width (padding already subtracted by caller).
1157
+ // When falling back to parent.width we must subtract padding ourselves.
1158
+ const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
1159
+ const rawTarget = Math.max(0, widthBase - padding);
1160
+ // Respect max-w-* constraints for normal nodes, but never for the synthetic
1161
+ // mx-auto wrapper itself: wrapper must stay full-width and center its child.
1162
+ const maxWConst = forceResizeForMxAutoWrapper ? null : MAX_WIDTH_NODES.get(child);
1163
+ const targetWidth = maxWConst != null ? Math.min(rawTarget, maxWConst) : rawTarget;
1164
+ if (targetWidth > 0) {
1165
+ child.resize(targetWidth, child.height);
1166
+ reapplyRightPositionedChildren(child as FrameNode, targetWidth);
1167
+ if ('layoutMode' in child) {
1168
+ const childLayout = child.layoutMode;
1169
+ if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
1170
+ child.primaryAxisSizingMode = 'FIXED';
1171
+ } else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
1172
+ child.counterAxisSizingMode = 'FIXED';
1173
+ }
1174
+ reflowNestedFullWidthChildren(child as FrameNode);
1175
+ } else if ('counterAxisSizingMode' in child) {
1176
+ child.counterAxisSizingMode = 'FIXED';
1177
+ }
1178
+ }
1179
+ } catch (_err) {
1180
+ // ignore
1181
+ }
1182
+ }
1183
+ }
1184
+ }
1185
+ }
1186
+ if (hasFullHeight) {
1187
+ // Absolute-positioned children don't honor layoutGrow / layoutAlign —
1188
+ // for `absolute inset-0 h-full w-full` (e.g. the round-trip arrow SVG
1189
+ // overlay) only a direct resize works. Mirrors the width branch above.
1190
+ if (childIsAbsolutePositioned && 'resize' in child && Number.isFinite(parent.height) && parent.height > 0) {
1191
+ try {
1192
+ const padding = (parent.paddingTop || 0) + (parent.paddingBottom || 0);
1193
+ const targetHeight = Math.max(0, parent.height - padding);
1194
+ if (targetHeight > 0) {
1195
+ child.resize(child.width, targetHeight);
1196
+ resizeSvgContentIfMarked(child, child.width, targetHeight);
1197
+ if ('counterAxisSizingMode' in child) {
1198
+ child.counterAxisSizingMode = 'FIXED';
1199
+ }
1200
+ if ('primaryAxisSizingMode' in child) {
1201
+ child.primaryAxisSizingMode = 'FIXED';
1202
+ }
1203
+ }
1204
+ } catch (_err) {
1205
+ // ignore
1206
+ }
1207
+ } else if (parent.layoutMode === 'VERTICAL') {
1208
+ if ('layoutGrow' in child && parent.primaryAxisSizingMode === 'FIXED') {
1209
+ child.layoutGrow = 1;
1210
+ }
1211
+ } else if (parent.layoutMode === 'HORIZONTAL') {
1212
+ if ('layoutAlign' in child) {
1213
+ child.layoutAlign = 'STRETCH';
1214
+ }
1215
+ if (
1216
+ 'counterAxisSizingMode' in parent
1217
+ && parent.counterAxisSizingMode === 'FIXED'
1218
+ && 'resize' in child
1219
+ ) {
1220
+ try {
1221
+ const padding = (parent.paddingTop || 0) + (parent.paddingBottom || 0);
1222
+ const targetHeight = Math.max(0, parent.height - padding);
1223
+ if (targetHeight > 0) {
1224
+ child.resize(child.width, targetHeight);
1225
+ if ('counterAxisSizingMode' in child) {
1226
+ child.counterAxisSizingMode = 'FIXED';
1227
+ }
1228
+ }
1229
+ } catch (_err) {
1230
+ // ignore
1231
+ }
1232
+ }
1233
+ } else if ('resize' in child && Number.isFinite(parent.height) && parent.height > 0) {
1234
+ // Parent has no auto-layout (layoutMode='NONE') — typical for relative
1235
+ // positioning containers and absolutely-positioned overlays. Direct
1236
+ // resize to parent's content box, mirroring CSS `height: 100%`.
1237
+ try {
1238
+ const padding = (parent.paddingTop || 0) + (parent.paddingBottom || 0);
1239
+ const targetHeight = Math.max(0, parent.height - padding);
1240
+ if (targetHeight > 0) {
1241
+ child.resize(child.width, targetHeight);
1242
+ if ('counterAxisSizingMode' in child) {
1243
+ child.counterAxisSizingMode = 'FIXED';
1244
+ }
1245
+ if ('primaryAxisSizingMode' in child) {
1246
+ child.primaryAxisSizingMode = 'FIXED';
1247
+ }
1248
+ }
1249
+ } catch (_err) {
1250
+ // ignore
1251
+ }
1252
+ }
1253
+ }
1254
+ } catch (_err) {
1255
+ // ignore
1256
+ }
1257
+ reapplyDirectionalBordersIfNeeded(child);
1258
+ // Keep mark so we can re-apply after parent resize.
1259
+ }
1260
+
1261
+ /**
1262
+ * Vertical mirror of the hasFullHeight post-pass that already runs inside
1263
+ * `applyFullWidthIfPossible` for HORIZONTAL parents: once a VERTICAL parent
1264
+ * has a FIXED primary axis (set by `resolveStoryLayoutHeight`), pin each grow
1265
+ * child's own primary-axis sizing to FIXED so the cascade continues. Figma
1266
+ * auto-layout resizes the grow child to its proportional share; this just
1267
+ * locks that allocation in place so the child's own descendants (`h-full`,
1268
+ * nested `flex-1`) keep resolving correctly.
1269
+ */
1270
+ export function enforceGrowChildPrimaryFixed(child: SceneNode, parent: FrameNode): void {
1271
+ if (!parent || parent.layoutMode !== 'VERTICAL') return;
1272
+ if (parent.primaryAxisSizingMode !== 'FIXED') return;
1273
+ if (!('layoutGrow' in child)) return;
1274
+ const grow = child.layoutGrow;
1275
+ if (!Number.isFinite(grow) || grow <= 0) return;
1276
+ if (!('layoutMode' in child)) return;
1277
+ const childLayout = child.layoutMode;
1278
+ try {
1279
+ if (childLayout === 'VERTICAL' && 'primaryAxisSizingMode' in child) {
1280
+ child.primaryAxisSizingMode = 'FIXED';
1281
+ } else if (childLayout === 'HORIZONTAL' && 'counterAxisSizingMode' in child) {
1282
+ child.counterAxisSizingMode = 'FIXED';
1283
+ }
1284
+ } catch (_err) {
1285
+ // ignore
1286
+ }
1287
+ }
1288
+
1289
+ export function applyGridColumnsIfPossible(
1290
+ frame: FrameNode,
1291
+ widthOverride?: number,
1292
+ colsOverride?: number
1293
+ ): void {
1294
+ const cols = colsOverride != null ? colsOverride : GRID_COLUMNS_NODES.get(frame);
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;
1303
+ if (colsOverride != null) {
1304
+ GRID_COLUMNS_NODES.set(frame, cols);
1305
+ }
1306
+
1307
+ const wasVerticalBeforeFlip = frame.layoutMode === 'VERTICAL';
1308
+ try {
1309
+ if (frame.layoutMode !== 'HORIZONTAL') {
1310
+ frame.layoutMode = 'HORIZONTAL';
1311
+ }
1312
+ if (frame.layoutWrap !== undefined) {
1313
+ frame.layoutWrap = 'WRAP';
1314
+ }
1315
+ // When flipping from VERTICAL, the old height (computed from stacked children)
1316
+ // lingers on the counter axis and causes STRETCH-aligned children to inflate to
1317
+ // that stale value. Reset counter-axis sizing to AUTO so the frame re-hugs the
1318
+ // true row height of the new horizontal-wrap layout.
1319
+ if (wasVerticalBeforeFlip && 'counterAxisSizingMode' in frame) {
1320
+ frame.counterAxisSizingMode = 'AUTO';
1321
+ }
1322
+ } catch (_err) {
1323
+ // ignore
1324
+ }
1325
+
1326
+ const totalWidth = widthOverride && Number.isFinite(widthOverride) ? widthOverride : frame.width;
1327
+ if (!totalWidth || totalWidth <= 0) return;
1328
+
1329
+ const gap = frame.itemSpacing || 0;
1330
+ const padding = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
1331
+ const available = totalWidth - padding - gap * (cols - 1);
1332
+ if (available <= 0) return;
1333
+
1334
+ const childWidth = Math.max(0, available / cols);
1335
+ if (frame.counterAxisSpacing !== undefined) {
1336
+ frame.counterAxisSpacing = gap;
1337
+ }
1338
+
1339
+ for (const child of frame.children) {
1340
+ if (!('resize' in child)) continue;
1341
+ try {
1342
+ if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') continue;
1343
+ const span = Math.min(COL_SPAN_NODES.get(child) ?? 1, cols);
1344
+ const spanWidth = childWidth * span + gap * (span - 1);
1345
+ // For text nodes, fix the width so textAlignHorizontal (e.g. text-right) takes effect.
1346
+ // Without this, WIDTH_AND_HEIGHT auto-resize snaps the node back to text-fit width.
1347
+ if (child.type === 'TEXT' && child.textAutoResize !== undefined) {
1348
+ child.textAutoResize = 'HEIGHT';
1349
+ }
1350
+ // Track whether the child has an explicit fixed height (h-*, size-*, etc.).
1351
+ // Can't rely on counterAxisSizingMode alone because Figma promotes it to FIXED
1352
+ // as a side effect of resize() and of STRETCH-inheritance from row height.
1353
+ const hadFixedHeight = FIXED_HEIGHT_NODES.has(child);
1354
+ child.resize(spanWidth, child.height);
1355
+ // Prevent grid children from expanding beyond calculated width
1356
+ if ('layoutGrow' in child) {
1357
+ child.layoutGrow = 0;
1358
+ }
1359
+ if ('layoutMode' in child) {
1360
+ const childLayout = child.layoutMode;
1361
+ if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
1362
+ child.counterAxisSizingMode = 'FIXED';
1363
+ } else if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
1364
+ child.primaryAxisSizingMode = 'FIXED';
1365
+ // Let height hug content unless the child had an explicit h-* class.
1366
+ // Two things conspire to freeze the wrong height:
1367
+ // 1. resize() above promotes counterAxisSizingMode to FIXED.
1368
+ // 2. Figma can STRETCH an un-fixed child to the row height (input's 36px),
1369
+ // so its snapshot already reads FIXED before we look.
1370
+ // Resetting layoutAlign to INHERIT prevents re-stretch after we go AUTO.
1371
+ if (!hadFixedHeight) {
1372
+ if ('layoutAlign' in child && child.layoutAlign === 'STRETCH') {
1373
+ child.layoutAlign = 'INHERIT';
1374
+ }
1375
+ if ('counterAxisSizingMode' in child) {
1376
+ child.counterAxisSizingMode = 'AUTO';
1377
+ }
1378
+ }
1379
+ }
1380
+ } else if ('primaryAxisSizingMode' in child) {
1381
+ child.primaryAxisSizingMode = 'FIXED';
1382
+ }
1383
+ reapplyDirectionalBordersIfNeeded(child);
1384
+ } catch (_err) {
1385
+ // ignore
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ export function hasGridColumnsNode(node: SceneNode): boolean {
1391
+ return GRID_COLUMNS_NODES.has(node);
1392
+ }
1393
+
1394
+ export function getGridColumnsNode(node: SceneNode): number | null {
1395
+ const cols = GRID_COLUMNS_NODES.get(node);
1396
+ return cols != null ? cols : null;
1397
+ }
1398
+
1399
+ export function applyFlexGrowIfPossible(frame: FrameNode, widthOverride?: number): void {
1400
+ if (frame.layoutMode !== 'HORIZONTAL') return;
1401
+
1402
+ const padding = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
1403
+ const gap = frame.itemSpacing || 0;
1404
+ const children = frame.children.filter(child => {
1405
+ if (!('layoutPositioning' in child)) return true;
1406
+ return child.layoutPositioning !== 'ABSOLUTE';
1407
+ });
1408
+ if (children.length === 0) return;
1409
+
1410
+ let fixedWidth = 0;
1411
+ let growTotal = 0;
1412
+ const growChildren: SceneNode[] = [];
1413
+
1414
+ for (const child of children) {
1415
+ if (!('layoutGrow' in child)) continue;
1416
+ const grow = child.layoutGrow;
1417
+ if (Number.isFinite(grow) && grow > 0) {
1418
+ growTotal += grow;
1419
+ growChildren.push(child);
1420
+ } else {
1421
+ fixedWidth += child.width || 0;
1422
+ }
1423
+ }
1424
+
1425
+ if (growChildren.length === 0 || growTotal <= 0) return;
1426
+
1427
+ // Determine target width: use override, frame width, or compute based on children
1428
+ let targetWidth = widthOverride && Number.isFinite(widthOverride) ? widthOverride : frame.width;
1429
+
1430
+ // If no width available, compute a minimum width based on fixed children + space for grow children
1431
+ const totalGap = gap * Math.max(0, children.length - 1);
1432
+ if (!targetWidth || targetWidth <= 0) {
1433
+ // Use 150px per grow unit as a reasonable default for flex-grow children
1434
+ const growWidth = 150 * growTotal;
1435
+ targetWidth = padding + totalGap + fixedWidth + growWidth;
1436
+ }
1437
+
1438
+ // Resize frame to target width and set to FIXED
1439
+ try {
1440
+ frame.resize(targetWidth, frame.height);
1441
+ frame.primaryAxisSizingMode = 'FIXED';
1442
+ } catch (_err) {
1443
+ // ignore
1444
+ }
1445
+
1446
+ const remaining = targetWidth - padding - totalGap - fixedWidth;
1447
+ if (!Number.isFinite(remaining) || remaining <= 0) return;
1448
+
1449
+ for (const child of growChildren) {
1450
+ if (!('layoutGrow' in child)) continue;
1451
+ const grow = child.layoutGrow;
1452
+ const width = remaining * (grow / growTotal);
1453
+ if (!Number.isFinite(width) || width <= 0) continue;
1454
+ if (!('resize' in child)) continue;
1455
+ try {
1456
+ child.resize(width, child.height);
1457
+ if ('layoutMode' in child) {
1458
+ const childLayout = child.layoutMode;
1459
+ if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
1460
+ child.counterAxisSizingMode = 'FIXED';
1461
+ } else if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
1462
+ child.primaryAxisSizingMode = 'FIXED';
1463
+ }
1464
+ } else if ('primaryAxisSizingMode' in child) {
1465
+ child.primaryAxisSizingMode = 'FIXED';
1466
+ }
1467
+ } catch (_err) {
1468
+ // ignore
1469
+ }
1470
+ }
1471
+ }
1472
+
1473
+ /**
1474
+ * Vertical mirror of `applyFlexGrowIfPossible` for viewport-anchored frames.
1475
+ * When a VERTICAL frame has a FIXED primary axis (set by the story-level
1476
+ * viewport-height pre-pass), distribute the remaining height among grow
1477
+ * children proportional to their `layoutGrow` values. Grow children that are
1478
+ * themselves auto-layout frames have their own primary axis locked to FIXED so
1479
+ * the cascade continues into their descendants.
1480
+ */
1481
+ export function applyVerticalFlexGrowIfPossible(frame: FrameNode): void {
1482
+ if (frame.layoutMode !== 'VERTICAL') return;
1483
+ if (frame.primaryAxisSizingMode !== 'FIXED') return;
1484
+
1485
+ const padding = (frame.paddingTop || 0) + (frame.paddingBottom || 0);
1486
+ const gap = frame.itemSpacing || 0;
1487
+ const children = frame.children.filter(child => {
1488
+ if (!('layoutPositioning' in child)) return true;
1489
+ return child.layoutPositioning !== 'ABSOLUTE';
1490
+ });
1491
+ if (children.length === 0) return;
1492
+
1493
+ let fixedHeight = 0;
1494
+ let growTotal = 0;
1495
+ const growChildren: SceneNode[] = [];
1496
+
1497
+ for (const child of children) {
1498
+ if (!('layoutGrow' in child)) continue;
1499
+ const grow = child.layoutGrow;
1500
+ if (Number.isFinite(grow) && grow > 0) {
1501
+ growTotal += grow;
1502
+ growChildren.push(child);
1503
+ } else {
1504
+ fixedHeight += child.height || 0;
1505
+ }
1506
+ }
1507
+
1508
+ if (growChildren.length === 0 || growTotal <= 0) return;
1509
+
1510
+ const totalGap = gap * Math.max(0, children.length - 1);
1511
+ const remaining = frame.height - padding - totalGap - fixedHeight;
1512
+ if (!Number.isFinite(remaining) || remaining <= 0) return;
1513
+
1514
+ for (const child of growChildren) {
1515
+ if (!('layoutGrow' in child)) continue;
1516
+ const grow = child.layoutGrow;
1517
+ const height = remaining * (grow / growTotal);
1518
+ if (!Number.isFinite(height) || height <= 0) continue;
1519
+ if (!('resize' in child)) continue;
1520
+ try {
1521
+ child.resize(child.width, height);
1522
+ if ('layoutMode' in child) {
1523
+ const childLayout = child.layoutMode;
1524
+ if (childLayout === 'VERTICAL' && 'primaryAxisSizingMode' in child) {
1525
+ child.primaryAxisSizingMode = 'FIXED';
1526
+ } else if (childLayout === 'HORIZONTAL' && 'counterAxisSizingMode' in child) {
1527
+ child.counterAxisSizingMode = 'FIXED';
1528
+ }
1529
+ } else if ('primaryAxisSizingMode' in child) {
1530
+ child.primaryAxisSizingMode = 'FIXED';
1531
+ }
1532
+ } catch (_err) {
1533
+ // ignore
1534
+ }
1535
+ }
1536
+ }
1537
+
1538
+ // ---------------------------------------------------------------------------
1539
+ // Exports for tailwind.ts internal use (mark functions called during CSS processing)
1540
+ // ---------------------------------------------------------------------------
1541
+
1542
+ export {
1543
+ markFullHeightNode,
1544
+ markFixedHeightNode,
1545
+ markFixedWidthNode,
1546
+ markSelfAlignmentNode,
1547
+ markFlexBasisNode,
1548
+ markMinWidthNode,
1549
+ markMaxWidthNode,
1550
+ markGridColumnsNode,
1551
+ markColSpanNode,
1552
+ markFractionWidthNode,
1553
+ parseFractionToken,
1554
+ BORDER_WIDTH_CLASSES,
1555
+ DEFERRED_TOP_RELATIVE_NODES,
1556
+ };