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,120 @@
1
+ import { parseColor } from '../tokens';
2
+ import { extractArbitraryValue, parseLength } from '../tailwind';
3
+
4
+ export type RingInfo = { width: number; color: { r: number; g: number; b: number; a?: number } };
5
+
6
+ /**
7
+ * Parse a ring-width utility (e.g. "ring", "ring-2", "ring-[3px]") to pixels.
8
+ * Returns null for non-width ring utilities (color, inset, offset).
9
+ */
10
+ export function parseRingWidth(utility: string): number | null {
11
+ if (utility === 'ring') return 3;
12
+ if (!utility.startsWith('ring-')) return null;
13
+ const token = utility.substring(5);
14
+ if (token === 'inset' || token.startsWith('offset-')) return null;
15
+ if (token.startsWith('[')) {
16
+ const arbitrary = extractArbitraryValue(utility);
17
+ if (!arbitrary) return null;
18
+ return parseLength(arbitrary);
19
+ }
20
+ const num = parseFloat(token);
21
+ if (!Number.isNaN(num) && String(num) === token) return num;
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Parse a ring-color utility (e.g. "ring-primary", "ring-primary/50") to RGB.
27
+ * Returns null for non-color ring utilities.
28
+ */
29
+ export function parseRingColor(
30
+ utility: string,
31
+ colorGroup: Record<string, string>
32
+ ): { r: number; g: number; b: number; a?: number } | null {
33
+ if (!utility.startsWith('ring-')) return null;
34
+ const token = utility.substring(5);
35
+ if (token === 'inset' || token.startsWith('offset-')) return null;
36
+ if (token.startsWith('[')) return null;
37
+ const num = parseFloat(token);
38
+ if (!Number.isNaN(num) && String(num) === token) return null;
39
+ let colorToken = token;
40
+ let opacityMultiplier: number | null = null;
41
+ const slashIndex = token.lastIndexOf('/');
42
+ if (slashIndex > 0 && slashIndex < token.length - 1) {
43
+ colorToken = token.substring(0, slashIndex);
44
+ const opacityRaw = token.substring(slashIndex + 1).trim();
45
+ const opacityNum = parseFloat(opacityRaw);
46
+ if (!Number.isNaN(opacityNum)) {
47
+ opacityMultiplier = Math.max(0, Math.min(1, opacityNum / 100));
48
+ }
49
+ }
50
+ const resolved = colorGroup[colorToken];
51
+ if (!resolved) return null;
52
+ const parsed = parseColor(resolved);
53
+ if (opacityMultiplier == null) return parsed;
54
+ const baseAlpha = parsed.a == null ? 1 : parsed.a;
55
+ return {
56
+ r: parsed.r,
57
+ g: parsed.g,
58
+ b: parsed.b,
59
+ a: Math.max(0, Math.min(1, baseAlpha * opacityMultiplier)),
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Derive a RingInfo from a list of Tailwind classes.
65
+ * Returns null when no ring is declared OR when only a ring colour is
66
+ * present without an accompanying width utility.
67
+ *
68
+ * CSS subtlety: in Tailwind, `ring-COLOR` (e.g. `ring-destructive`)
69
+ * sets the ring's color via `--tw-ring-color` but does NOT make the
70
+ * ring visible — that requires a width utility (`ring`, `ring-2`,
71
+ * `ring-[3px]`, …). shadcn's invalid-input pattern relies on this:
72
+ *
73
+ * `aria-invalid:ring-destructive/20 focus-visible:ring-[3px]`
74
+ *
75
+ * "When invalid, the ring is destructive-tinted; when focused, the
76
+ * ring becomes 3px visible." Without focus, ring width = 0, so the
77
+ * invalid-but-unfocused input renders with just the red border, no
78
+ * ring. Previously this function defaulted width to 3 whenever a
79
+ * color was present — so the State Matrix `error` variant rendered
80
+ * with a doubled red ring outside the destructive border, instead of
81
+ * just the border. Now: no width → no ring.
82
+ */
83
+ export function getRingInfoFromClasses(
84
+ classes: string[],
85
+ colorGroup: Record<string, string>
86
+ ): RingInfo | null {
87
+ let width: number | null = null;
88
+ let color: { r: number; g: number; b: number; a?: number } | null = null;
89
+
90
+ for (let i = 0; i < classes.length; i++) {
91
+ const cls = classes[i];
92
+ const nextWidth = parseRingWidth(cls);
93
+ if (nextWidth != null) width = nextWidth;
94
+ const nextColor = parseRingColor(cls, colorGroup);
95
+ if (nextColor) color = nextColor;
96
+ }
97
+
98
+ if (width == null) return null;
99
+ if (!color) {
100
+ const fallback = colorGroup.ring || colorGroup.primary;
101
+ if (!fallback) return null;
102
+ color = parseColor(fallback);
103
+ }
104
+ if (!width || width <= 0) return null;
105
+ return { width: width, color: color };
106
+ }
107
+
108
+ // Ring rendering — the OVERLAY-FRAME implementation, the FIXED-toggle
109
+ // invariant, and the post-pass scheduling all live in
110
+ // `src/layout/deferred-layout.ts` (`markRingNode` / `applyRingIfPossible`).
111
+ // This file owns the *parsing* contract only:
112
+ //
113
+ // parseRingWidth / parseRingColor / getRingInfoFromClasses
114
+ //
115
+ // Callers in the parser path (`tailwind.ts`) and the imperative frame
116
+ // builders (`component-gen.ts`, `cva-master.ts`, `preview-builder.ts`,
117
+ // `state-master.ts`) all funnel through that single deferred entry point.
118
+ // See `.ai/troubleshooting.md` "DO NOT use Figma DROP_SHADOW spread" and
119
+ // "State Matrix focus/error variants render TALLER/WIDER" for the
120
+ // architectural rationale.
@@ -0,0 +1,143 @@
1
+ import { parseUtilityClass } from '../tailwind';
2
+
3
+ /**
4
+ * Parse a Tailwind size token (e.g. "4", "px", "[1.5rem]") to pixels.
5
+ * This is the canonical implementation — handles px, rem, em, and Tailwind
6
+ * scale multiplier (×4). story-builder and ui-builder both import from here.
7
+ */
8
+ export function parseSquareSizeToken(token: string): number | null {
9
+ const normalized = String(token || '').trim();
10
+ if (!normalized) return null;
11
+ if (normalized === 'px') return 1;
12
+ if (normalized.startsWith('[') && normalized.endsWith(']')) {
13
+ const arbitrary = normalized.slice(1, -1).trim();
14
+ if (!arbitrary) return null;
15
+ if (arbitrary.endsWith('px')) {
16
+ const px = parseFloat(arbitrary.slice(0, -2));
17
+ return Number.isFinite(px) ? px : null;
18
+ }
19
+ if (arbitrary.endsWith('rem') || arbitrary.endsWith('em')) {
20
+ const rem = parseFloat(arbitrary.slice(0, -3));
21
+ return Number.isFinite(rem) ? rem * 16 : null;
22
+ }
23
+ const raw = parseFloat(arbitrary);
24
+ return Number.isFinite(raw) ? raw : null;
25
+ }
26
+ const numeric = parseFloat(normalized);
27
+ if (!Number.isNaN(numeric)) return numeric * 4;
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Resolve fixed dimensions from variantless Tailwind sizing utilities.
33
+ *
34
+ * Supports `size-*`, `w-*`, and `h-*` so all fixed-size controls use the same
35
+ * sizing rule regardless of which builder renders them.
36
+ */
37
+ export function resolveFixedBoxSizeFromClasses(classes: string[]): { width: number | null; height: number | null } {
38
+ let width: number | null = null;
39
+ let height: number | null = null;
40
+
41
+ for (let i = 0; i < classes.length; i++) {
42
+ const atom = parseUtilityClass(classes[i]);
43
+ if (!atom || !atom.utility) continue;
44
+ if (Array.isArray(atom.variants) && atom.variants.length > 0) continue;
45
+ const utility = atom.utility || '';
46
+
47
+ const sizeMatch = utility.match(/^size-(.+)$/);
48
+ if (sizeMatch) {
49
+ const resolved = parseSquareSizeToken(sizeMatch[1]);
50
+ if (resolved != null && resolved > 0) {
51
+ width = resolved;
52
+ height = resolved;
53
+ }
54
+ continue;
55
+ }
56
+
57
+ const widthMatch = utility.match(/^w-(.+)$/);
58
+ if (widthMatch) {
59
+ const token = widthMatch[1];
60
+ // Skip fractional widths (`w-1/2`, `w-2/3`, …); they're parent-relative
61
+ // and resolved via markFractionWidthNode, not as a fixed size.
62
+ // Without this guard, parseSquareSizeToken("2/3") → parseFloat("2/3") → 2
63
+ // → 8px, clobbering the correctly-resolved fractional width.
64
+ if (
65
+ token !== 'full' && token !== 'screen' && token !== 'auto'
66
+ && token !== 'fit' && token !== 'min' && token !== 'max'
67
+ && !token.includes('/')
68
+ ) {
69
+ const resolved = parseSquareSizeToken(token);
70
+ if (resolved != null && resolved > 0) width = resolved;
71
+ }
72
+ continue;
73
+ }
74
+
75
+ const heightMatch = utility.match(/^h-(.+)$/);
76
+ if (heightMatch) {
77
+ const token = heightMatch[1];
78
+ if (
79
+ token !== 'full' && token !== 'screen' && token !== 'auto'
80
+ && token !== 'fit' && token !== 'min' && token !== 'max'
81
+ && !token.includes('/')
82
+ ) {
83
+ const resolved = parseSquareSizeToken(token);
84
+ if (resolved != null && resolved > 0) height = resolved;
85
+ }
86
+ }
87
+ }
88
+
89
+ return { width, height };
90
+ }
91
+
92
+ /**
93
+ * Return the resolved pixel size from a variantless `size-*` utility, or null.
94
+ */
95
+ export function getSquareSizeFromClasses(classes: string[]): number | null {
96
+ const fixed = resolveFixedBoxSizeFromClasses(classes);
97
+ if (fixed.width != null && fixed.height != null && fixed.width === fixed.height) {
98
+ return fixed.width;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * After auto-layout is applied, re-enforce fixed dimensions declared by
105
+ * `size-*`, `w-*`, and `h-*` utilities so Figma's layout engine cannot
106
+ * stretch or squeeze the node.
107
+ */
108
+ export function enforceFixedBoxSizingAfterLayout(node: SceneNode, classes: string[]): void {
109
+ const fixed = resolveFixedBoxSizeFromClasses(classes);
110
+ if (fixed.width == null && fixed.height == null) return;
111
+ if (!node || !('resize' in node)) return;
112
+
113
+ const currentWidth = 'width' in node && typeof node.width === 'number' ? node.width : 1;
114
+ const currentHeight = 'height' in node && typeof node.height === 'number' ? node.height : 1;
115
+ const targetWidth = fixed.width != null ? fixed.width : currentWidth;
116
+ const targetHeight = fixed.height != null ? fixed.height : currentHeight;
117
+
118
+ try {
119
+ node.resize(Math.max(1, targetWidth), Math.max(1, targetHeight));
120
+ if ('layoutGrow' in node) node.layoutGrow = 0;
121
+
122
+ const mode = 'layoutMode' in node ? String(node.layoutMode || '') : '';
123
+ if ('layoutSizingHorizontal' in node && fixed.width != null) node.layoutSizingHorizontal = 'FIXED';
124
+ if ('layoutSizingVertical' in node && fixed.height != null) node.layoutSizingVertical = 'FIXED';
125
+
126
+ if (mode === 'HORIZONTAL') {
127
+ if (fixed.width != null && 'primaryAxisSizingMode' in node) node.primaryAxisSizingMode = 'FIXED';
128
+ if (fixed.height != null && 'counterAxisSizingMode' in node) node.counterAxisSizingMode = 'FIXED';
129
+ } else if (mode === 'VERTICAL') {
130
+ if (fixed.height != null && 'primaryAxisSizingMode' in node) node.primaryAxisSizingMode = 'FIXED';
131
+ if (fixed.width != null && 'counterAxisSizingMode' in node) node.counterAxisSizingMode = 'FIXED';
132
+ } else {
133
+ if (fixed.height != null && 'primaryAxisSizingMode' in node) node.primaryAxisSizingMode = 'FIXED';
134
+ if (fixed.width != null && 'counterAxisSizingMode' in node) node.counterAxisSizingMode = 'FIXED';
135
+ }
136
+ } catch (_err) {
137
+ // ignore
138
+ }
139
+ }
140
+
141
+ export function enforceSquareSizingAfterLayout(node: SceneNode, classes: string[]): void {
142
+ enforceFixedBoxSizingAfterLayout(node, classes);
143
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Decision helper for the "resize text node to parent's content width" step
3
+ * inside `buildFigmaNode`'s frame-with-text branch.
4
+ *
5
+ * Extracted into a pure function because the decision has historically been
6
+ * brittle — site (c) of the recurring inline-flex pill bug lives in this
7
+ * branch (see `tools/figma-plugin/.ai/troubleshooting.md`). Keeping it pure
8
+ * makes the truth table testable without stubbing `buildFigmaNode`'s whole
9
+ * dependency surface.
10
+ *
11
+ * Returns the target width (positive number) if the text node should be
12
+ * resized, or `undefined` if no resize should occur — including when the
13
+ * computed width would be 0 or negative after padding subtraction.
14
+ */
15
+ export interface TextResizeFrameInput {
16
+ // Match Figma's FrameNode['layoutMode'] which includes 'GRID'.
17
+ layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'GRID';
18
+ primaryAxisAlignItems: string;
19
+ primaryAxisSizingMode: 'AUTO' | 'FIXED';
20
+ paddingLeft: number;
21
+ paddingRight: number;
22
+ }
23
+
24
+ export function resolveTextResizeWidth(
25
+ frame: TextResizeFrameInput,
26
+ contextMaxWidth: number | undefined | null,
27
+ ): number | undefined {
28
+ if (contextMaxWidth == null || !Number.isFinite(contextMaxWidth) || contextMaxWidth <= 0) {
29
+ return undefined;
30
+ }
31
+
32
+ // In a HORIZONTAL frame with CENTER alignment, don't force the text node
33
+ // to a fixed width — let it stay HUG-sized so the frame's CENTER alignment
34
+ // can position it (e.g. a numbered circle with justify-center).
35
+ const isHorizontalCentered =
36
+ frame.layoutMode === 'HORIZONTAL' && frame.primaryAxisAlignItems === 'CENTER';
37
+
38
+ // Skip the resize for HORIZONTAL frames that hug their content width
39
+ // (inline-flex pills / chips / badges). Their natural width IS the text
40
+ // width — if we resize the text to the parent's maxWidth, the hug-frame
41
+ // inherits that width and stretches across the whole card. This is the
42
+ // recurring inline-flex pill bug — site (c).
43
+ const isHorizontalHugging =
44
+ frame.layoutMode === 'HORIZONTAL' && frame.primaryAxisSizingMode === 'AUTO';
45
+
46
+ if (isHorizontalCentered || isHorizontalHugging) return undefined;
47
+
48
+ const padding = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
49
+ const target = contextMaxWidth - padding;
50
+ return target > 0 ? target : undefined;
51
+ }
@@ -1,5 +1,5 @@
1
1
  import { LayoutParser } from './layout-parser';
2
- import { type NodeIR, isElementLikeNode } from './node-ir';
2
+ import { type NodeIR, isElementLikeNode, getBaseClass } from '../tailwind';
3
3
 
4
4
  export type NodeLayoutComputed = {
5
5
  layoutIR: ReturnType<typeof LayoutParser.parseToIR>;
@@ -30,6 +30,18 @@ const BLOCK_TAGS = new Set([
30
30
  'tbody',
31
31
  'tfoot',
32
32
  'tr',
33
+ // Heading and paragraph tags are block-level in CSS and stretch to fill a
34
+ // flex-col parent, giving text-center/text-right meaningful visual effect.
35
+ // Note: shouldStretchToParentWidth guards against mx-auto (which signals
36
+ // explicit HUG+centering intent) and w-*/max-w-* (explicit width constraints).
37
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p',
38
+ // `a`, `button`, and `label` are inline by CSS default, but become block-level
39
+ // when given `display: flex|grid|block` via classes. The frame-creation branch
40
+ // in ui-builder only reaches this check when the element has layout classes /
41
+ // children / background, i.e. after it has been promoted to block-level, so
42
+ // including them here is safe. Plain inline usage goes through the inline-text
43
+ // path and never hits shouldStretchToParentWidth.
44
+ 'a', 'button', 'label',
33
45
  ]);
34
46
 
35
47
  const GRID_BREAKPOINTS: Record<string, number> = {
@@ -40,10 +52,101 @@ const GRID_BREAKPOINTS: Record<string, number> = {
40
52
  '2xl': 1536,
41
53
  };
42
54
 
43
- export function getBaseClass(value: string): string | null {
44
- if (!value) return null;
45
- if (value.indexOf(':') !== -1) return null;
46
- return value;
55
+ const DISPLAY_UTILITIES = new Set([
56
+ 'block',
57
+ 'inline-block',
58
+ 'inline',
59
+ 'flex',
60
+ 'inline-flex',
61
+ 'grid',
62
+ 'inline-grid',
63
+ 'contents',
64
+ 'table',
65
+ 'table-row',
66
+ 'table-cell',
67
+ 'flow-root',
68
+ 'list-item',
69
+ ]);
70
+
71
+ function resolveActiveDisplayUtility(classes: string[] | undefined, availableWidth?: number): string | null {
72
+ if (!classes || classes.length === 0) return null;
73
+ type DisplayCandidate = { minWidth: number; utility: string; order: number };
74
+ const candidates: DisplayCandidate[] = [];
75
+
76
+ for (let i = 0; i < classes.length; i++) {
77
+ const cls = classes[i];
78
+ const parts = cls.split(':');
79
+ const tail = parts[parts.length - 1];
80
+ if (!DISPLAY_UTILITIES.has(tail)) continue;
81
+ const prefixes = parts.slice(0, -1);
82
+
83
+ let minWidth = 0;
84
+ let unsupported = false;
85
+ for (let j = 0; j < prefixes.length; j++) {
86
+ const bp = GRID_BREAKPOINTS[prefixes[j]];
87
+ if (!bp && bp !== 0) {
88
+ unsupported = true;
89
+ break;
90
+ }
91
+ if (bp > minWidth) minWidth = bp;
92
+ }
93
+ if (unsupported) continue;
94
+ candidates.push({ minWidth: minWidth, utility: tail, order: i });
95
+ }
96
+
97
+ if (candidates.length === 0) return null;
98
+
99
+ if (availableWidth != null && Number.isFinite(availableWidth)) {
100
+ let chosen: DisplayCandidate | null = null;
101
+ for (let i = 0; i < candidates.length; i++) {
102
+ const candidate = candidates[i];
103
+ if (availableWidth < candidate.minWidth) continue;
104
+ if (
105
+ !chosen ||
106
+ candidate.minWidth > chosen.minWidth ||
107
+ (candidate.minWidth === chosen.minWidth && candidate.order > chosen.order)
108
+ ) {
109
+ chosen = candidate;
110
+ }
111
+ }
112
+ return chosen ? chosen.utility : null;
113
+ }
114
+
115
+ let chosen: DisplayCandidate | null = null;
116
+ for (let i = 0; i < candidates.length; i++) {
117
+ const candidate = candidates[i];
118
+ if (
119
+ !chosen ||
120
+ candidate.minWidth > chosen.minWidth ||
121
+ (candidate.minWidth === chosen.minWidth && candidate.order > chosen.order)
122
+ ) {
123
+ chosen = candidate;
124
+ }
125
+ }
126
+ return chosen ? chosen.utility : null;
127
+ }
128
+
129
+ function stripVariantPrefix(value: string): string {
130
+ if (!value) return value;
131
+ const idx = value.lastIndexOf(':');
132
+ return idx === -1 ? value : value.slice(idx + 1);
133
+ }
134
+
135
+ function hasOutOfFlowPositioning(classes: string[] | undefined): boolean {
136
+ if (!classes || classes.length === 0) return false;
137
+ for (const cls of classes) {
138
+ const base = stripVariantPrefix(cls);
139
+ if (base === 'absolute' || base === 'fixed' || base === 'sticky') {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
146
+ function nodeHasOutOfFlowPositioning(node: NodeIR): boolean {
147
+ if (node.kind === 'ring') return hasOutOfFlowPositioning(node.classes);
148
+ if (isElementLikeNode(node)) return hasOutOfFlowPositioning(node.classes);
149
+ return false;
47
150
  }
48
151
 
49
152
  export function extractFixedWidth(classes: string[] | undefined): number | null {
@@ -86,17 +189,15 @@ export function extractMaxWidth(classes: string[] | undefined): number | null {
86
189
 
87
190
  export function extractGridColumns(classes: string[] | undefined, availableWidth?: number): number | null {
88
191
  if (!classes) return null;
192
+ const activeDisplay = resolveActiveDisplayUtility(classes, availableWidth);
193
+ const displayIsGrid = activeDisplay === 'grid' || activeDisplay === 'inline-grid';
89
194
  const responsivePrefixes = new Set(Object.keys(GRID_BREAKPOINTS));
90
195
  let baseCols: number | null = null;
91
196
  let best: { bp: number; cols: number } | null = null;
92
- let hasBaseGrid = false;
93
197
  for (const cls of classes) {
94
198
  const parts = cls.split(':');
95
199
  const tail = parts[parts.length - 1];
96
200
  const prefixes = parts.slice(0, -1);
97
- if ((tail === 'grid' || tail === 'inline-grid') && prefixes.length === 0) {
98
- hasBaseGrid = true;
99
- }
100
201
  const match = tail.match(/^grid-cols-(\d+)$/);
101
202
  if (!match) continue;
102
203
  const cols = parseInt(match[1], 10);
@@ -117,19 +218,21 @@ export function extractGridColumns(classes: string[] | undefined, availableWidth
117
218
  }
118
219
  }
119
220
 
120
- if (!hasBaseGrid && baseCols == null && !best) return null;
221
+ if (!displayIsGrid && baseCols == null && !best) return null;
121
222
 
122
223
  if (availableWidth != null && Number.isFinite(availableWidth)) {
224
+ if (!displayIsGrid) return null;
123
225
  if (best && availableWidth >= best.bp) return best.cols;
124
226
  if (baseCols != null) return baseCols;
125
227
  // Tailwind defaults to one implicit column for grid containers until a responsive override applies.
126
- if (hasBaseGrid) return 1;
228
+ if (displayIsGrid) return 1;
127
229
  return best ? best.cols : null;
128
230
  }
129
231
 
232
+ if (!displayIsGrid && baseCols == null && !best) return null;
130
233
  if (baseCols != null) return baseCols;
131
234
  if (best) return best.cols;
132
- if (hasBaseGrid) return 1;
235
+ if (displayIsGrid) return 1;
133
236
  return null;
134
237
  }
135
238
 
@@ -150,15 +253,28 @@ export function extractGridBreakpointWidth(classes: string[] | undefined): numbe
150
253
 
151
254
  export function shouldStretchToParentWidth(tag: string, classes: string[]): boolean {
152
255
  if (!BLOCK_TAGS.has(tag)) return false;
256
+ if (hasOutOfFlowPositioning(classes)) return false;
153
257
  for (const cls of classes) {
154
258
  const base = getBaseClass(cls);
155
259
  if (!base) continue;
260
+ // Out-of-flow positioned nodes should not inherit vertical stretch behavior.
261
+ // These nodes are anchored by top/right/bottom/left (e.g. dialog close button).
262
+ if (base === 'absolute' || base === 'fixed' || base === 'sticky') {
263
+ return false;
264
+ }
156
265
  if (base === 'inline' || base === 'inline-block' || base === 'inline-flex' || base === 'inline-grid') {
157
266
  return false;
158
267
  }
159
268
  if (base.startsWith('w-') || base.startsWith('max-w-') || base.startsWith('min-w-')) {
160
269
  return false;
161
270
  }
271
+ // `size-N` (Tailwind's both-axis shorthand) pins the width, so the element
272
+ // should NOT stretch to parent width — same intent as `w-N`. Numeric and
273
+ // arbitrary forms only; `size-full` was already normalized into `w-full`
274
+ // by `splitClassName`, so it never appears here as a separate token.
275
+ if (base.startsWith('size-')) {
276
+ return false;
277
+ }
162
278
  if (base.startsWith('self-')) {
163
279
  return false;
164
280
  }
@@ -182,13 +298,13 @@ export function resolveExplicitWidthFromClasses(classes: string[]): number | nul
182
298
  }
183
299
 
184
300
  export function getNodeLayoutComputed(node: NodeIR): NodeLayoutComputed {
185
- const cached = NODE_LAYOUT_CACHE.get(node as any);
301
+ const cached = NODE_LAYOUT_CACHE.get(node);
186
302
  if (cached) return cached;
187
303
 
188
304
  const classes = isElementLikeNode(node) ? node.classes : [];
189
305
  const semanticLayoutClasses = getSemanticLayoutClasses(node);
190
306
  const layoutClasses = semanticLayoutClasses.length > 0
191
- ? [...semanticLayoutClasses, ...classes]
307
+ ? semanticLayoutClasses.concat(classes)
192
308
  : classes;
193
309
  const layoutIR = LayoutParser.parseToIR(layoutClasses);
194
310
  const maxWidth = extractMaxWidth(classes);
@@ -208,7 +324,8 @@ export function getNodeLayoutComputed(node: NodeIR): NodeLayoutComputed {
208
324
  );
209
325
  const hasExplicitSize = classes.some(c =>
210
326
  /^w-\d+$/.test(c) || /^w-\[/.test(c) || /^h-\d+$/.test(c) || /^h-\[/.test(c) ||
211
- c === 'w-full' || c === 'h-full' || /^w-\d+\/\d+$/.test(c)
327
+ c === 'w-full' || c === 'h-full' || /^w-\d+\/\d+$/.test(c) ||
328
+ /^size-\d+(?:\.\d+)?$/.test(c) || /^size-\[/.test(c) || c === 'size-full'
212
329
  );
213
330
 
214
331
  const computed: NodeLayoutComputed = {
@@ -220,7 +337,7 @@ export function getNodeLayoutComputed(node: NodeIR): NodeLayoutComputed {
220
337
  hasFlexChildClass: hasFlexChildClass,
221
338
  hasExplicitSize: hasExplicitSize,
222
339
  };
223
- NODE_LAYOUT_CACHE.set(node as any, computed);
340
+ NODE_LAYOUT_CACHE.set(node, computed);
224
341
  return computed;
225
342
  }
226
343
 
@@ -253,7 +370,8 @@ export function solveLayoutWidths(node: NodeIR, availableWidth?: number): NodeIR
253
370
  }
254
371
 
255
372
  if (node.kind === 'ring') {
256
- let childWidth: number | undefined = availableWidth;
373
+ const ringIsOutOfFlow = hasOutOfFlowPositioning(node.classes);
374
+ let childWidth: number | undefined = ringIsOutOfFlow ? undefined : availableWidth;
257
375
  if (childWidth != null) {
258
376
  childWidth = Math.max(0, childWidth - (node.ringWidth + node.offsetWidth) * 2);
259
377
  }
@@ -283,8 +401,12 @@ export function solveLayoutWidths(node: NodeIR, availableWidth?: number): NodeIR
283
401
  }
284
402
  // Component wrappers (Card, CardHeader, etc.) are not in BLOCK_TAGS but should
285
403
  // still inherit the parent's available width when they have no explicit width of their own.
404
+ // Restrict this to components that actually wrap children. Leaf components include
405
+ // icon components (XIcon, ChevronDown, etc.) and forcing full width on them causes
406
+ // dialog close buttons and similar absolute controls to misplace.
286
407
  if (widthOverride == null && availableWidth != null && node.kind === 'component') {
287
- if (!hasNonFullWidthClass(classes)) {
408
+ const hasChildren = Array.isArray(node.children) && node.children.length > 0;
409
+ if (hasChildren && !hasNonFullWidthClass(classes) && !hasOutOfFlowPositioning(classes)) {
288
410
  widthOverride = availableWidth;
289
411
  }
290
412
  }
@@ -321,12 +443,18 @@ export function solveLayoutWidths(node: NodeIR, availableWidth?: number): NodeIR
321
443
  let remainingChildWidth: number | null = null;
322
444
  if (layoutIR.layoutMode === 'HORIZONTAL' && contentWidth != null && node.children && node.children.length > 0) {
323
445
  const gap = layoutIR.gapX != null ? layoutIR.gapX : layoutIR.gap;
324
- const totalGap = gap * Math.max(0, node.children.length - 1);
446
+ // CSS flex: absolute/fixed children are out-of-flow and don't consume space
447
+ // in the row. Excluding them prevents their width from shrinking siblings
448
+ // (e.g. an absolute check-indicator in a Select item would otherwise steal
449
+ // 14px from the label text's allocation and force wrapping).
450
+ const inFlowChildren = node.children
451
+ .filter(isElementLikeNode)
452
+ .filter((child) => !nodeHasOutOfFlowPositioning(child));
453
+ const totalGap = gap * Math.max(0, inFlowChildren.length - 1);
325
454
  let fixedTotal = 0;
326
455
  let growCount = 0;
327
456
  let variableCount = 0;
328
- for (const child of node.children) {
329
- if (!isElementLikeNode(child)) continue;
457
+ for (const child of inFlowChildren) {
330
458
  const explicitChildWidth = resolveExplicitWidthFromChild(child);
331
459
  if (explicitChildWidth != null) {
332
460
  fixedTotal += explicitChildWidth;
@@ -351,22 +479,25 @@ export function solveLayoutWidths(node: NodeIR, availableWidth?: number): NodeIR
351
479
  const nextChildren: NodeIR[] = [];
352
480
  for (const child of node.children) {
353
481
  let nextChild = child;
354
- let nextWidth = childWidth;
355
- if (gridChildWidth != null) {
356
- nextChild = enforceFixedWidthOnGridChild(child, gridChildWidth);
357
- nextWidth = gridChildWidth;
358
- } else if (layoutIR.layoutMode === 'HORIZONTAL' && contentWidth != null) {
359
- const explicitChildWidth = resolveExplicitWidthFromChild(child);
360
- if (explicitChildWidth != null) {
361
- nextWidth = explicitChildWidth;
362
- } else if (flexChildWidth != null && isElementLikeNode(child) && hasGrowClass(child.classes)) {
363
- nextChild = enforceFixedWidthOnFlexChild(child, flexChildWidth);
364
- nextWidth = flexChildWidth;
365
- } else if (remainingChildWidth != null && isElementLikeNode(child)) {
366
- nextChild = enforceFixedWidthOnGridChild(child, remainingChildWidth);
367
- nextWidth = remainingChildWidth;
368
- } else {
369
- nextWidth = undefined;
482
+ const childIsOutOfFlow = nodeHasOutOfFlowPositioning(child);
483
+ let nextWidth = childIsOutOfFlow ? undefined : childWidth;
484
+ if (!childIsOutOfFlow) {
485
+ if (gridChildWidth != null) {
486
+ nextChild = enforceFixedWidthOnGridChild(child, gridChildWidth);
487
+ nextWidth = gridChildWidth;
488
+ } else if (layoutIR.layoutMode === 'HORIZONTAL' && contentWidth != null) {
489
+ const explicitChildWidth = resolveExplicitWidthFromChild(child);
490
+ if (explicitChildWidth != null) {
491
+ nextWidth = explicitChildWidth;
492
+ } else if (flexChildWidth != null && isElementLikeNode(child) && hasGrowClass(child.classes)) {
493
+ nextChild = enforceFixedWidthOnFlexChild(child, flexChildWidth);
494
+ nextWidth = flexChildWidth;
495
+ } else if (remainingChildWidth != null && isElementLikeNode(child)) {
496
+ nextChild = enforceFixedWidthOnGridChild(child, remainingChildWidth);
497
+ nextWidth = remainingChildWidth;
498
+ } else {
499
+ nextWidth = undefined;
500
+ }
370
501
  }
371
502
  }
372
503
  nextChildren.push(solveLayoutWidths(nextChild, nextWidth));