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,80 @@
1
+ /**
2
+ * LayoutIR — the intermediate representation produced by the Tailwind →
3
+ * Figma layout parser. The split parsers (flex, grid, spacing, alignment,
4
+ * sizing, responsive) all read class lists and write into a single LayoutIR
5
+ * instance via small `parseX(classes, ir)` helpers. The orchestrator in
6
+ * `layout-parser.ts` (`LayoutParser.parseToIR`) calls them in order and
7
+ * hands the result to `LayoutParser.applyToFrame`.
8
+ *
9
+ * This file owns:
10
+ * - the `LayoutIR` shape itself
11
+ * - the `SizingMode` enum reused on width / height axes
12
+ * - the IR factory `makeEmptyIR()` so each parser starts from a known
13
+ * baseline rather than each call site repeating the defaults
14
+ */
15
+
16
+ /**
17
+ * Sizing mode for a single axis (width or height).
18
+ * - FIXED: explicit pixel size (w-20, h-10)
19
+ * - HUG: shrink to content (default for text, no size class)
20
+ * - FILL: expand to fill available space (flex-1, w-full inside a flex parent)
21
+ */
22
+ export type SizingMode = 'FIXED' | 'HUG' | 'FILL';
23
+
24
+ /**
25
+ * Captures the Tailwind layout intent before any Figma node is touched.
26
+ */
27
+ export interface LayoutIR {
28
+ // Container properties
29
+ layoutMode: 'HORIZONTAL' | 'VERTICAL' | 'NONE';
30
+ wrap: boolean;
31
+ gap: number;
32
+ gapX?: number;
33
+ gapY?: number;
34
+
35
+ // Padding
36
+ paddingTop: number;
37
+ paddingRight: number;
38
+ paddingBottom: number;
39
+ paddingLeft: number;
40
+
41
+ // Alignment (for containers)
42
+ mainAlign: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN';
43
+ crossAlign: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'BASELINE';
44
+
45
+ // Sizing (resolved based on context)
46
+ widthMode: SizingMode;
47
+ heightMode: SizingMode;
48
+ fixedWidth?: number;
49
+ fixedHeight?: number;
50
+ widthFraction?: number; // 0.0-1.0 for fractional widths (w-1/2, w-1/3, etc.)
51
+ heightFraction?: number; // 0.0-1.0 for fractional heights
52
+ minWidth?: number; // pixel floor from `min-w-N` / `min-w-[Npx]`
53
+ minHeight?: number; // pixel floor from `min-h-N` / `min-h-[Npx]`
54
+
55
+ // Child-specific (applied when this node is a child of a flex parent)
56
+ grow: number; // 0 = don't grow, 1 = grow
57
+ shrinkZero?: boolean; // `shrink-0` / `flex-shrink-0` — force layoutGrow = 0
58
+ selfAlign?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH';
59
+ }
60
+
61
+ /**
62
+ * Default IR — every parser-side function mutates this in place rather
63
+ * than constructing partial objects, so the shape stays consistent.
64
+ */
65
+ export function makeEmptyIR(): LayoutIR {
66
+ return {
67
+ layoutMode: 'NONE',
68
+ wrap: false,
69
+ gap: 0,
70
+ paddingTop: 0,
71
+ paddingRight: 0,
72
+ paddingBottom: 0,
73
+ paddingLeft: 0,
74
+ mainAlign: 'MIN',
75
+ crossAlign: 'MIN',
76
+ widthMode: 'HUG',
77
+ heightMode: 'HUG',
78
+ grow: 0,
79
+ };
80
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Detect the LayoutIR `layoutMode` from Tailwind display + direction
3
+ * utilities. Spans flex AND grid because both end up writing to the
4
+ * same IR field — Figma auto-layout has only HORIZONTAL / VERTICAL /
5
+ * NONE, with no separate flex/grid distinction.
6
+ *
7
+ * Inputs handled:
8
+ * - `flex` / `inline-flex` → HORIZONTAL (CSS default flex-direction: row)
9
+ * - `flex-col` / `flex-col-reverse` → VERTICAL
10
+ * - `flex-row` / `flex-row-reverse` → HORIZONTAL
11
+ * - `grid` / `inline-grid` → VERTICAL by default; HORIZONTAL when a
12
+ * `grid-cols-N` class is also present (the columns wrap children
13
+ * horizontally; the actual column count is applied later by
14
+ * `markGridColumnsNode`, not in the IR itself)
15
+ *
16
+ * Class-order semantics matter: a later utility overrides earlier ones,
17
+ * which is what makes responsive variants like
18
+ * `flex flex-col sm:flex-row` work — the `sm:` prefix is stripped at the
19
+ * breakpoint resolver, leaving `flex flex-col flex-row` at the `sm`
20
+ * pass. Trailing `flex-row` then wins.
21
+ *
22
+ * Behaviour preserved 1:1 from the original `parseLayoutMode` static
23
+ * method on `LayoutParser`. Locked in by the layout-mode regression test.
24
+ */
25
+
26
+ export function parseLayoutMode(classes: string[]): 'HORIZONTAL' | 'VERTICAL' | 'NONE' {
27
+ let mode: 'HORIZONTAL' | 'VERTICAL' | 'NONE' = 'NONE';
28
+ // Track whether flex-direction was explicitly set: in real CSS,
29
+ // `display:flex` does not reset `flex-direction`, so a trailing bare
30
+ // `flex` after `flex-col` must not override the direction back to row.
31
+ let directionSet = false;
32
+ for (const cls of classes) {
33
+ if (cls === 'flex' || cls === 'inline-flex') {
34
+ if (!directionSet) mode = 'HORIZONTAL'; // default flex direction is row
35
+ continue;
36
+ }
37
+ if (cls === 'grid' || cls === 'inline-grid') {
38
+ // grid without grid-cols-N is a single-column layout (like flex-col).
39
+ // grid WITH grid-cols-N wraps horizontally — the column count is
40
+ // applied later by markGridColumnsNode, not stored in the IR.
41
+ const hasColumns = classes.some(c => /^grid-cols-\d+$/.test(c));
42
+ mode = hasColumns ? 'HORIZONTAL' : 'VERTICAL';
43
+ directionSet = true;
44
+ continue;
45
+ }
46
+ if (cls === 'flex-col' || cls === 'flex-col-reverse') {
47
+ mode = 'VERTICAL';
48
+ directionSet = true;
49
+ continue;
50
+ }
51
+ if (cls === 'flex-row' || cls === 'flex-row-reverse') {
52
+ mode = 'HORIZONTAL';
53
+ directionSet = true;
54
+ }
55
+ }
56
+ return mode;
57
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Tailwind sizing utilities → LayoutIR.
3
+ * Owns: w-*, h-*, size-*, w-full, h-full, h-screen, min-h-screen,
4
+ * fractional w/h (w-1/2, w-2/3, h-1/3, …), arbitrary `[Xpx/rem/em]`,
5
+ * named max-w (xs, sm, …, 7xl, prose, screen-*).
6
+ *
7
+ * Behaviour preserved 1:1 from the original `parseSizing` static method
8
+ * on `LayoutParser`. Locked in by the sizing regression test.
9
+ */
10
+
11
+ import type { LayoutIR } from './ir';
12
+ import { resolveSpacing } from './spacing-scale';
13
+
14
+ /**
15
+ * Tailwind fractional width/height utilities — map to a 0..1 fraction.
16
+ * In Figma we materialize fractions as proportional grow on the parent's
17
+ * primary axis (see `parseSizing` setting `widthMode: 'FILL'` + `widthFraction`).
18
+ */
19
+ const FRACTIONAL_SIZES: Record<string, number> = {
20
+ // Halves
21
+ '1/2': 0.5,
22
+ // Thirds
23
+ '1/3': 1 / 3,
24
+ '2/3': 2 / 3,
25
+ // Quarters
26
+ '1/4': 0.25,
27
+ '2/4': 0.5,
28
+ '3/4': 0.75,
29
+ // Fifths
30
+ '1/5': 0.2,
31
+ '2/5': 0.4,
32
+ '3/5': 0.6,
33
+ '4/5': 0.8,
34
+ // Sixths
35
+ '1/6': 1 / 6,
36
+ '2/6': 2 / 6,
37
+ '3/6': 0.5,
38
+ '4/6': 4 / 6,
39
+ '5/6': 5 / 6,
40
+ // Twelfths
41
+ '1/12': 1 / 12,
42
+ '2/12': 2 / 12,
43
+ '3/12': 0.25,
44
+ '4/12': 4 / 12,
45
+ '5/12': 5 / 12,
46
+ '6/12': 0.5,
47
+ '7/12': 7 / 12,
48
+ '8/12': 8 / 12,
49
+ '9/12': 0.75,
50
+ '10/12': 10 / 12,
51
+ '11/12': 11 / 12,
52
+ // Full
53
+ 'full': 1.0,
54
+ };
55
+
56
+ /**
57
+ * Tailwind named max-width tokens (default theme).
58
+ * Treated as fixed widths — Figma has no "max" constraint that maps
59
+ * cleanly onto a frame, so the closest faithful render is to pin the
60
+ * frame at the named max value.
61
+ */
62
+ const NAMED_MAX_WIDTHS: Record<string, number> = {
63
+ 'xs': 320, 'sm': 384, 'md': 448, 'lg': 512, 'xl': 576,
64
+ '2xl': 672, '3xl': 768, '4xl': 896, '5xl': 1024, '6xl': 1152, '7xl': 1280,
65
+ 'prose': 640,
66
+ 'screen-sm': 640, 'screen-md': 768, 'screen-lg': 1024, 'screen-xl': 1280, 'screen-2xl': 1536,
67
+ };
68
+
69
+ /**
70
+ * Parse sizing utilities into `ir.widthMode/heightMode/fixedWidth/
71
+ * fixedHeight/widthFraction/heightFraction`. Mutates the IR in place.
72
+ */
73
+ export function parseSizing(classes: string[], ir: LayoutIR): void {
74
+ for (const cls of classes) {
75
+ // Fixed width: w-20 (scale), w-[100px] (arbitrary)
76
+ const wScaleMatch = cls.match(/^w-(\d+(?:\.\d+)?)$/);
77
+ if (wScaleMatch) {
78
+ const val = resolveSpacing(wScaleMatch[1]);
79
+ if (val > 0) {
80
+ ir.widthMode = 'FIXED';
81
+ ir.fixedWidth = val;
82
+ }
83
+ continue;
84
+ }
85
+
86
+ const wArbitraryMatch = cls.match(/^w-\[(\d+(?:\.\d+)?)(px|rem|em)?\]$/);
87
+ if (wArbitraryMatch) {
88
+ let val = parseFloat(wArbitraryMatch[1]);
89
+ const unit = wArbitraryMatch[2] || 'px';
90
+ if (unit === 'rem' || unit === 'em') val *= 16;
91
+ ir.widthMode = 'FIXED';
92
+ ir.fixedWidth = val;
93
+ continue;
94
+ }
95
+
96
+ // Arbitrary percentage width: `w-[60%]` → parent-relative fraction.
97
+ // Used by the shadcn Progress adapter (indicator inside track) and by
98
+ // any consumer who writes percent widths directly in className.
99
+ const wPercentMatch = cls.match(/^w-\[(\d+(?:\.\d+)?)%\]$/);
100
+ if (wPercentMatch) {
101
+ const pct = parseFloat(wPercentMatch[1]);
102
+ if (Number.isFinite(pct) && pct >= 0) {
103
+ ir.widthMode = 'FILL';
104
+ ir.widthFraction = Math.max(0, Math.min(1, pct / 100));
105
+ }
106
+ continue;
107
+ }
108
+
109
+ // Fractional width: w-1/2, w-1/3, w-2/3, etc.
110
+ const wFractionMatch = cls.match(/^w-(\d+\/\d+)$/);
111
+ if (wFractionMatch) {
112
+ const fraction = FRACTIONAL_SIZES[wFractionMatch[1]];
113
+ if (fraction !== undefined) {
114
+ ir.widthMode = 'FILL';
115
+ ir.widthFraction = fraction;
116
+ }
117
+ continue;
118
+ }
119
+
120
+ // Fixed height: h-20 (scale), h-[100px] (arbitrary)
121
+ const hScaleMatch = cls.match(/^h-(\d+(?:\.\d+)?)$/);
122
+ if (hScaleMatch) {
123
+ const val = resolveSpacing(hScaleMatch[1]);
124
+ if (val > 0) {
125
+ ir.heightMode = 'FIXED';
126
+ ir.fixedHeight = val;
127
+ }
128
+ continue;
129
+ }
130
+
131
+ const hArbitraryMatch = cls.match(/^h-\[(\d+(?:\.\d+)?)(px|rem|em)?\]$/);
132
+ if (hArbitraryMatch) {
133
+ let val = parseFloat(hArbitraryMatch[1]);
134
+ const unit = hArbitraryMatch[2] || 'px';
135
+ if (unit === 'rem' || unit === 'em') val *= 16;
136
+ ir.heightMode = 'FIXED';
137
+ ir.fixedHeight = val;
138
+ continue;
139
+ }
140
+
141
+ // Arbitrary percentage height: `h-[40%]` — mirror of `w-[N%]`.
142
+ const hPercentMatch = cls.match(/^h-\[(\d+(?:\.\d+)?)%\]$/);
143
+ if (hPercentMatch) {
144
+ const pct = parseFloat(hPercentMatch[1]);
145
+ if (Number.isFinite(pct) && pct >= 0) {
146
+ ir.heightMode = 'FILL';
147
+ ir.heightFraction = Math.max(0, Math.min(1, pct / 100));
148
+ }
149
+ continue;
150
+ }
151
+
152
+ // Fractional height: h-1/2, h-1/3, h-2/3, etc.
153
+ const hFractionMatch = cls.match(/^h-(\d+\/\d+)$/);
154
+ if (hFractionMatch) {
155
+ const fraction = FRACTIONAL_SIZES[hFractionMatch[1]];
156
+ if (fraction !== undefined) {
157
+ ir.heightMode = 'FILL';
158
+ ir.heightFraction = fraction;
159
+ }
160
+ continue;
161
+ }
162
+
163
+ // Size (both dimensions): size-20
164
+ const sizeMatch = cls.match(/^size-(\d+(?:\.\d+)?)$/);
165
+ if (sizeMatch) {
166
+ const val = resolveSpacing(sizeMatch[1]);
167
+ if (val > 0) {
168
+ ir.widthMode = 'FIXED';
169
+ ir.heightMode = 'FIXED';
170
+ ir.fixedWidth = val;
171
+ ir.fixedHeight = val;
172
+ }
173
+ continue;
174
+ }
175
+
176
+ // Fill width / height (resolved later in applyChildProperties).
177
+ if (cls === 'w-full') {
178
+ ir.widthMode = 'FILL';
179
+ }
180
+ if (cls === 'h-full' || cls === 'h-screen' || cls === 'min-h-screen') {
181
+ // h-screen / min-h-screen behave like h-full once resolveStoryLayoutHeight
182
+ // pins the story frame's primary axis to a fixed viewport height.
183
+ ir.heightMode = 'FILL';
184
+ }
185
+
186
+ // Min-height (scale): `min-h-20` → 80px floor on frame height.
187
+ // Used by shadcn Textarea (`min-h-20`), tall hero containers, etc.
188
+ const minHScaleMatch = cls.match(/^min-h-(\d+(?:\.\d+)?)$/);
189
+ if (minHScaleMatch) {
190
+ const val = resolveSpacing(minHScaleMatch[1]);
191
+ if (val > 0) ir.minHeight = val;
192
+ continue;
193
+ }
194
+ const minHArbitraryMatch = cls.match(/^min-h-\[(\d+(?:\.\d+)?)(px|rem|em)?\]$/);
195
+ if (minHArbitraryMatch) {
196
+ let val = parseFloat(minHArbitraryMatch[1]);
197
+ const unit = minHArbitraryMatch[2] || 'px';
198
+ if (unit === 'rem' || unit === 'em') val *= 16;
199
+ if (val > 0) ir.minHeight = val;
200
+ continue;
201
+ }
202
+
203
+ // Min-width — mirror of min-h-*.
204
+ const minWScaleMatch = cls.match(/^min-w-(\d+(?:\.\d+)?)$/);
205
+ if (minWScaleMatch) {
206
+ const val = resolveSpacing(minWScaleMatch[1]);
207
+ if (val > 0) ir.minWidth = val;
208
+ continue;
209
+ }
210
+ const minWArbitraryMatch = cls.match(/^min-w-\[(\d+(?:\.\d+)?)(px|rem|em)?\]$/);
211
+ if (minWArbitraryMatch) {
212
+ let val = parseFloat(minWArbitraryMatch[1]);
213
+ const unit = minWArbitraryMatch[2] || 'px';
214
+ if (unit === 'rem' || unit === 'em') val *= 16;
215
+ if (val > 0) ir.minWidth = val;
216
+ continue;
217
+ }
218
+
219
+ // Max-width with fixed scale value
220
+ const maxWMatch = cls.match(/^max-w-(\d+(?:\.\d+)?)$/);
221
+ if (maxWMatch) {
222
+ const val = resolveSpacing(maxWMatch[1]);
223
+ if (val > 0) {
224
+ ir.widthMode = 'FIXED';
225
+ ir.fixedWidth = val;
226
+ }
227
+ continue;
228
+ }
229
+
230
+ // Named max-width (max-w-xl, max-w-2xl, …)
231
+ const maxWNamedMatch = cls.match(/^max-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|prose|screen-sm|screen-md|screen-lg|screen-xl|screen-2xl)$/);
232
+ if (maxWNamedMatch) {
233
+ const val = NAMED_MAX_WIDTHS[maxWNamedMatch[1]];
234
+ if (val) {
235
+ ir.widthMode = 'FIXED';
236
+ ir.fixedWidth = val;
237
+ }
238
+ continue;
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tailwind spacing scale (in px) and the resolver function.
3
+ * Shared by the spacing parser (gap, padding) and the sizing parser
4
+ * (w-*, h-* in scale units). Pure: no Figma dependency, no IR mutation.
5
+ *
6
+ * Based on Tailwind v3+ docs: 1rem = 16px, scale base unit 0.25rem (4px).
7
+ */
8
+
9
+ const SPACING_SCALE: Record<string, number> = {
10
+ 'px': 1, // Special 1px value
11
+ '0': 0,
12
+ '0.5': 2, // 0.125rem
13
+ '1': 4,
14
+ '1.5': 6,
15
+ '2': 8,
16
+ '2.5': 10,
17
+ '3': 12,
18
+ '3.5': 14,
19
+ '4': 16,
20
+ '5': 20,
21
+ '6': 24,
22
+ '7': 28,
23
+ '8': 32,
24
+ '9': 36,
25
+ '10': 40,
26
+ '11': 44,
27
+ '12': 48,
28
+ '14': 56,
29
+ '16': 64,
30
+ '20': 80,
31
+ '24': 96,
32
+ '28': 112,
33
+ '32': 128,
34
+ '36': 144,
35
+ '40': 160,
36
+ '44': 176,
37
+ '48': 192,
38
+ '52': 208,
39
+ '56': 224,
40
+ '60': 240,
41
+ '64': 256,
42
+ '72': 288,
43
+ '80': 320,
44
+ '96': 384,
45
+ };
46
+
47
+ /**
48
+ * Resolve a single spacing token value to its pixel size.
49
+ * Accepts:
50
+ * - Tailwind scale values: '4' → 16, '0.5' → 2, 'px' → 1
51
+ * - Arbitrary values: '[12px]', '[1.5rem]', '[1em]' (rem/em → px at 16px base)
52
+ * - Bare numbers not in the scale: parsed and multiplied by 4 (Tailwind default unit)
53
+ * - Unknown: 0
54
+ */
55
+ export function resolveSpacing(value: string): number {
56
+ // Arbitrary value [Xpx] / [Xrem] / [Xem]
57
+ if (value.startsWith('[') && value.endsWith(']')) {
58
+ const inner = value.slice(1, -1);
59
+ const match = inner.match(/^(\d+(?:\.\d+)?)(px|rem|em)?$/);
60
+ if (match) {
61
+ let num = parseFloat(match[1]);
62
+ const unit = match[2] || 'px';
63
+ if (unit === 'rem' || unit === 'em') num *= 16;
64
+ return num;
65
+ }
66
+ }
67
+
68
+ if (SPACING_SCALE[value] !== undefined) {
69
+ return SPACING_SCALE[value];
70
+ }
71
+
72
+ const num = parseFloat(value);
73
+ if (!isNaN(num)) {
74
+ return num * 4;
75
+ }
76
+
77
+ return 0;
78
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tailwind spacing utilities → LayoutIR.
3
+ * Owns: gap-*, gap-x-*, gap-y-*, space-x-*, space-y-*, p-*, px-*, py-*,
4
+ * pt-*, pr-*, pb-*, pl-*.
5
+ *
6
+ * Each parser function reads a class list and mutates the IR in place.
7
+ * Behaviour is preserved 1:1 from the original parseGap / parsePadding
8
+ * methods on `LayoutParser`. Locked in by the spacing regression test.
9
+ */
10
+
11
+ import type { LayoutIR } from './ir';
12
+ import { resolveSpacing } from './spacing-scale';
13
+
14
+ /**
15
+ * Parse gap and space utilities into `ir.gap`, `ir.gapX`, `ir.gapY`.
16
+ * `space-x-*` / `space-y-*` map onto gap on the matching axis (Tailwind
17
+ * uses sibling margins; Figma auto-layout has no margin so we collapse
18
+ * the intent into the parent's gap).
19
+ */
20
+ export function parseGap(classes: string[], ir: LayoutIR): void {
21
+ for (const cls of classes) {
22
+ // gap-* (both axes)
23
+ const gapMatch = cls.match(/^gap-(\d+(?:\.\d+)?|\[.+\])$/);
24
+ if (gapMatch) {
25
+ const val = resolveSpacing(gapMatch[1]);
26
+ ir.gap = val;
27
+ ir.gapX = val;
28
+ ir.gapY = val;
29
+ continue;
30
+ }
31
+
32
+ // gap-x-*
33
+ const gapXMatch = cls.match(/^gap-x-(\d+(?:\.\d+)?|\[.+\])$/);
34
+ if (gapXMatch) {
35
+ ir.gapX = resolveSpacing(gapXMatch[1]);
36
+ if (ir.layoutMode === 'HORIZONTAL') {
37
+ ir.gap = ir.gapX;
38
+ }
39
+ continue;
40
+ }
41
+
42
+ // gap-y-*
43
+ const gapYMatch = cls.match(/^gap-y-(\d+(?:\.\d+)?|\[.+\])$/);
44
+ if (gapYMatch) {
45
+ ir.gapY = resolveSpacing(gapYMatch[1]);
46
+ if (ir.layoutMode === 'VERTICAL') {
47
+ ir.gap = ir.gapY;
48
+ }
49
+ continue;
50
+ }
51
+
52
+ // space-x-* → gap on horizontal layouts only. Negative form
53
+ // (`-space-x-2`) drives overlap (Avatar groups, badge clusters); maps to
54
+ // a negative item-spacing in Figma's auto-layout.
55
+ const spaceXMatch = cls.match(/^(-)?space-x-(\d+(?:\.\d+)?|\[.+\])$/);
56
+ if (spaceXMatch && ir.layoutMode === 'HORIZONTAL') {
57
+ const sign = spaceXMatch[1] ? -1 : 1;
58
+ ir.gap = sign * resolveSpacing(spaceXMatch[2]);
59
+ continue;
60
+ }
61
+
62
+ // space-y-* → gap on vertical layouts only. Negative form mirrors -space-x-*.
63
+ const spaceYMatch = cls.match(/^(-)?space-y-(\d+(?:\.\d+)?|\[.+\])$/);
64
+ if (spaceYMatch && ir.layoutMode === 'VERTICAL') {
65
+ const sign = spaceYMatch[1] ? -1 : 1;
66
+ ir.gap = sign * resolveSpacing(spaceYMatch[2]);
67
+ continue;
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Parse padding utilities into `ir.paddingTop / Right / Bottom / Left`.
74
+ */
75
+ export function parsePadding(classes: string[], ir: LayoutIR): void {
76
+ for (const cls of classes) {
77
+ const pMatch = cls.match(/^p-(\d+(?:\.\d+)?|\[.+\])$/);
78
+ if (pMatch) {
79
+ const val = resolveSpacing(pMatch[1]);
80
+ ir.paddingTop = val;
81
+ ir.paddingRight = val;
82
+ ir.paddingBottom = val;
83
+ ir.paddingLeft = val;
84
+ continue;
85
+ }
86
+
87
+ const pxMatch = cls.match(/^px-(\d+(?:\.\d+)?|\[.+\])$/);
88
+ if (pxMatch) {
89
+ const val = resolveSpacing(pxMatch[1]);
90
+ ir.paddingLeft = val;
91
+ ir.paddingRight = val;
92
+ continue;
93
+ }
94
+
95
+ const pyMatch = cls.match(/^py-(\d+(?:\.\d+)?|\[.+\])$/);
96
+ if (pyMatch) {
97
+ const val = resolveSpacing(pyMatch[1]);
98
+ ir.paddingTop = val;
99
+ ir.paddingBottom = val;
100
+ continue;
101
+ }
102
+
103
+ const ptMatch = cls.match(/^pt-(\d+(?:\.\d+)?|\[.+\])$/);
104
+ if (ptMatch) {
105
+ ir.paddingTop = resolveSpacing(ptMatch[1]);
106
+ continue;
107
+ }
108
+ const prMatch = cls.match(/^pr-(\d+(?:\.\d+)?|\[.+\])$/);
109
+ if (prMatch) {
110
+ ir.paddingRight = resolveSpacing(prMatch[1]);
111
+ continue;
112
+ }
113
+ const pbMatch = cls.match(/^pb-(\d+(?:\.\d+)?|\[.+\])$/);
114
+ if (pbMatch) {
115
+ ir.paddingBottom = resolveSpacing(pbMatch[1]);
116
+ continue;
117
+ }
118
+ const plMatch = cls.match(/^pl-(\d+(?:\.\d+)?|\[.+\])$/);
119
+ if (plMatch) {
120
+ ir.paddingLeft = resolveSpacing(plMatch[1]);
121
+ continue;
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Orchestrator helper — calls parseGap and parsePadding in order.
128
+ * Provided for callers that want a single entrypoint for "all the
129
+ * spacing utilities in one go".
130
+ */
131
+ export function parseSpacing(classes: string[], ir: LayoutIR): void {
132
+ parseGap(classes, ir);
133
+ parsePadding(classes, ir);
134
+ }