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,319 @@
1
+ import { type JsxNode, type JsxElement, splitClassName } from './node-ir';
2
+ import { type ComponentDef } from './class-utils';
3
+ import { extractMaxWidth } from '../layout';
4
+ import { getClassesForBreakpoint, hasSignificantResponsiveChanges } from './responsive-analyzer';
5
+ import { isFrameworkAdapterDroppedTag } from '../framework-adapters';
6
+
7
+ /**
8
+ * Recursively collect every Tailwind class string from a JSX tree.
9
+ *
10
+ * Elements that the framework adapter will unconditionally drop at render
11
+ * time (e.g. `ScrollAreaPrimitive.Scrollbar` and friends) are skipped —
12
+ * including their subtree. Otherwise classes that never reach Figma
13
+ * (Scrollbar's `sm:bg-black/60`) would falsely trip pre-render scans like
14
+ * `treeHasResponsiveClasses`, producing duplicate-content Responsive
15
+ * previews on stories that are static at every breakpoint post-adapter.
16
+ */
17
+ export function collectTreeClasses(node: JsxNode | undefined, output: string[]): void {
18
+ if (!node) return;
19
+ if (node.type === 'element') {
20
+ const el = node as JsxElement;
21
+ if (isFrameworkAdapterDroppedTag(el.tagName)) return;
22
+ const className = el.props && el.props.className ? String(el.props.className) : '';
23
+ if (className) {
24
+ const list = splitClassName(className);
25
+ for (let i = 0; i < list.length; i++) output.push(list[i]);
26
+ }
27
+ const children = el.children || [];
28
+ for (let i = 0; i < children.length; i++) {
29
+ collectTreeClasses(children[i], output);
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Returns true when the tree contains classes that produce visible layout
36
+ * differences at responsive breakpoints.
37
+ */
38
+ export function treeHasResponsiveClasses(node: JsxNode | undefined): boolean {
39
+ const list: string[] = [];
40
+ collectTreeClasses(node, list);
41
+ return list.length > 0 && hasSignificantResponsiveChanges(list);
42
+ }
43
+
44
+ /**
45
+ * Remap every class in a className string to its breakpoint-specific override.
46
+ * Ensures that bare `grid` classes are paired with `grid-cols-1` if no column
47
+ * count was specified for this breakpoint.
48
+ */
49
+ export function mapClassNameForBreakpoint(value: string | undefined, breakpoint: string): string | undefined {
50
+ if (!value) return value;
51
+ const list = splitClassName(value);
52
+ if (list.length === 0) return value;
53
+ const mapped = getClassesForBreakpoint(list, breakpoint);
54
+ let hasGrid = false;
55
+ let hasGridCols = false;
56
+ for (let i = 0; i < mapped.length; i++) {
57
+ const cls = mapped[i];
58
+ if (cls === 'grid' || cls === 'inline-grid') hasGrid = true;
59
+ if (cls.startsWith('grid-cols-')) hasGridCols = true;
60
+ }
61
+ // Only inject grid-cols-1 when the original class list had responsive grid-cols
62
+ // variants (e.g. sm:grid-cols-3) that were stripped for this breakpoint.
63
+ // Without this guard, vertical-stack `grid` containers (like DialogContent) get
64
+ // grid-cols-1 injected, which flips their layoutMode to HORIZONTAL and causes
65
+ // resize code to lock the frame height at a wrong value, clipping content.
66
+ if (hasGrid && !hasGridCols) {
67
+ const hadResponsiveGridCols = list.some(cls => /^(?:sm|md|lg|xl|2xl):grid-cols-/.test(cls));
68
+ if (hadResponsiveGridCols) mapped.push('grid-cols-1');
69
+ }
70
+ return mapped.join(' ');
71
+ }
72
+
73
+ /**
74
+ * Deep-clone a JSX node, remapping all className values for a given breakpoint.
75
+ */
76
+ export function cloneJsxNodeForBreakpoint(node: JsxNode, breakpoint: string): JsxNode {
77
+ if (!node || node.type !== 'element') return node;
78
+ const el = node as JsxElement;
79
+ const nextProps = el.props ? Object.assign({}, el.props) : {};
80
+ if (nextProps.className) {
81
+ nextProps.className = mapClassNameForBreakpoint(String(nextProps.className), breakpoint) || '';
82
+ }
83
+ const nextChildren: JsxNode[] = [];
84
+ const children = el.children || [];
85
+ for (let i = 0; i < children.length; i++) {
86
+ nextChildren.push(cloneJsxNodeForBreakpoint(children[i], breakpoint));
87
+ }
88
+ return Object.assign({}, el, { props: nextProps, children: nextChildren });
89
+ }
90
+
91
+ /**
92
+ * Walk the JSX tree and return the first explicit `grid-cols-N` value found,
93
+ * or 1 if a grid layout is declared without a column count, or null when no
94
+ * grid layout is present.
95
+ */
96
+ export function extractGridColsFromTree(node: JsxNode | undefined): number | null {
97
+ if (!node || node.type !== 'element') return null;
98
+ const el = node as JsxElement;
99
+ const className = el.props && el.props.className ? String(el.props.className) : '';
100
+ const list = className ? splitClassName(className) : [];
101
+ let hasGrid = false;
102
+ for (let i = 0; i < list.length; i++) {
103
+ const cls = list[i];
104
+ if (cls === 'grid' || cls === 'inline-grid') hasGrid = true;
105
+ const match = cls.match(/^grid-cols-(\d+)$/);
106
+ if (match) return parseInt(match[1], 10);
107
+ }
108
+ const children = el.children || [];
109
+ for (let i = 0; i < children.length; i++) {
110
+ const found = extractGridColsFromTree(children[i]);
111
+ if (found != null) return found;
112
+ }
113
+ return hasGrid ? 1 : null;
114
+ }
115
+
116
+ /**
117
+ * Read only the root node's grid column intent. This avoids accidentally
118
+ * inheriting nested `grid-cols-*` from descendants (for example form rows in
119
+ * dialog content) when sizing top-level responsive preview containers.
120
+ */
121
+ export function extractRootGridColsFromTree(node: JsxNode | undefined): number | null {
122
+ if (!node || node.type !== 'element') return null;
123
+ const el = node as JsxElement;
124
+ const className = el.props && el.props.className ? String(el.props.className) : '';
125
+ const list = className ? splitClassName(className) : [];
126
+ let hasGrid = false;
127
+ for (let i = 0; i < list.length; i++) {
128
+ const cls = list[i];
129
+ if (cls === 'grid' || cls === 'inline-grid') hasGrid = true;
130
+ const match = cls.match(/^grid-cols-(\d+)$/);
131
+ if (match) return parseInt(match[1], 10);
132
+ }
133
+ return hasGrid ? 1 : null;
134
+ }
135
+
136
+ /**
137
+ * Walk down the leading single-child spine of the JSX tree and return the
138
+ * tightest `max-w-*` constraint where `w-full` is also set on the same node.
139
+ * Returns null when no constrained full-width container is found.
140
+ */
141
+ export function extractLeadingContainerMaxWidthFromTree(node: JsxNode | undefined): number | null {
142
+ let current: JsxNode | undefined = node;
143
+ let constrainedWidth: number | null = null;
144
+
145
+ while (current && current.type === 'element') {
146
+ const element = current as JsxElement;
147
+ const className = element.props && element.props.className ? String(element.props.className) : '';
148
+ const classes = className ? splitClassName(className) : [];
149
+ const hasFullWidth = classes.indexOf('w-full') !== -1;
150
+ const maxWidth = extractMaxWidth(classes);
151
+
152
+ if (hasFullWidth && maxWidth != null) {
153
+ constrainedWidth = constrainedWidth == null ? maxWidth : Math.min(constrainedWidth, maxWidth);
154
+ }
155
+
156
+ const children = element.children || [];
157
+ if (children.length !== 1 || !children[0] || children[0].type !== 'element') {
158
+ break;
159
+ }
160
+ current = children[0];
161
+ }
162
+
163
+ return constrainedWidth;
164
+ }
165
+
166
+ /**
167
+ * Propagate child-wildcard utility classes (`*:X`, `sm:*:X`, etc.) from a node
168
+ * to all of its direct element children, then remove them from the parent.
169
+ *
170
+ * This mirrors the CSS default where `align-items: stretch` (implicit in flex)
171
+ * makes children fill the cross-axis. A developer writing `*:w-full sm:*:w-auto`
172
+ * on a flex-col container intends each child to be full-width at mobile and
173
+ * auto-width at sm+. The Figma renderer already handles `w-full` and `sm:w-auto`
174
+ * on individual nodes, so we only need to propagate them down.
175
+ *
176
+ * Operates recursively over the whole tree so nested containers are handled too.
177
+ */
178
+ export function propagateChildSelectorClasses(node: JsxNode): JsxNode {
179
+ if (!node || node.type !== 'element') return node;
180
+ const el = node as JsxElement;
181
+ const className: string = el.props && el.props.className ? String(el.props.className) : '';
182
+ const classes = className ? splitClassName(className) : [];
183
+
184
+ // Collect *:X and bp:*:X classes, convert to the class that should be added to children.
185
+ // e.g. `*:w-full` → `w-full`, `sm:*:w-auto` → `sm:w-auto`
186
+ const rawPropagate: string[] = [];
187
+ const keepOnParent: string[] = [];
188
+ for (let i = 0; i < classes.length; i++) {
189
+ const cls = classes[i];
190
+ // bare `*:X`
191
+ const bareMatch = cls.match(/^\*:(.+)$/);
192
+ if (bareMatch) {
193
+ rawPropagate.push(bareMatch[1]);
194
+ continue;
195
+ }
196
+ // `bp:*:X`
197
+ const bpMatch = cls.match(/^([a-z0-9]+):\*:(.+)$/);
198
+ if (bpMatch) {
199
+ rawPropagate.push(bpMatch[1] + ':' + bpMatch[2]);
200
+ continue;
201
+ }
202
+ keepOnParent.push(cls);
203
+ }
204
+
205
+ // Deduplicate by Tailwind property prefix (last-wins, mirrors CSS cascade).
206
+ // This ensures that when mapClassNameForBreakpoint produces both *:w-full (base)
207
+ // and *:w-auto (from sm:*:w-auto at sm breakpoint), only w-auto is propagated —
208
+ // avoiding the w-full STRETCH from silently beating the later w-auto.
209
+ const lastByPrefix = new Map<string, string>();
210
+ for (let i = 0; i < rawPropagate.length; i++) {
211
+ const cls = rawPropagate[i];
212
+ // Strip any breakpoint prefix before extracting the property prefix.
213
+ const utilityPart = cls.replace(/^[a-z0-9]+:/, '');
214
+ const dashIdx = utilityPart.indexOf('-');
215
+ const prefix = dashIdx > 0 ? utilityPart.slice(0, dashIdx) : utilityPart;
216
+ lastByPrefix.set(prefix, cls);
217
+ }
218
+ const toPropagate = Array.from(lastByPrefix.values());
219
+
220
+ // Recursively process children first, then inject propagated classes
221
+ const nextChildren: JsxNode[] = [];
222
+ const children = el.children || [];
223
+ for (let i = 0; i < children.length; i++) {
224
+ let child = propagateChildSelectorClasses(children[i]);
225
+ if (toPropagate.length > 0 && child && child.type === 'element') {
226
+ const childEl = child as JsxElement;
227
+ const childClass: string = childEl.props && childEl.props.className
228
+ ? String(childEl.props.className)
229
+ : '';
230
+ const merged = toPropagate.join(' ') + (childClass ? ' ' + childClass : '');
231
+ child = Object.assign({}, childEl, {
232
+ props: Object.assign({}, childEl.props || {}, { className: merged }),
233
+ });
234
+ }
235
+ nextChildren.push(child);
236
+ }
237
+
238
+ const nextClassName = keepOnParent.join(' ');
239
+ return Object.assign({}, el, {
240
+ props: Object.assign({}, el.props || {}, { className: nextClassName }),
241
+ children: nextChildren,
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Deduplicate a list of classes and return them joined as a single string.
247
+ * Preserves first-occurrence order.
248
+ */
249
+ export function uniqueClassSignature(classes: string[] | undefined): string {
250
+ const seen: Record<string, boolean> = {};
251
+ const output: string[] = [];
252
+ const list = classes || [];
253
+ for (let i = 0; i < list.length; i++) {
254
+ const cls = list[i];
255
+ if (!cls || seen[cls]) continue;
256
+ seen[cls] = true;
257
+ output.push(cls);
258
+ }
259
+ return output.join(' ');
260
+ }
261
+
262
+ /**
263
+ * Produce a stable, deduplicated string signature of all classes in a JSX tree.
264
+ * Used for hashing / change detection.
265
+ */
266
+ export function treeClassSignature(node: JsxNode | undefined): string {
267
+ const classes: string[] = [];
268
+ collectTreeClasses(node, classes);
269
+ return uniqueClassSignature(classes);
270
+ }
271
+
272
+ /**
273
+ * Assemble the final class list for one variant of a CVA component definition,
274
+ * combining base classes + per-variant classes + any extra className prop.
275
+ */
276
+ export function buildCvaClassesForVariant(
277
+ def: ComponentDef,
278
+ props: Record<string, string>,
279
+ primaryKey: string,
280
+ primaryValue: string
281
+ ): string[] {
282
+ const classes: string[] = [];
283
+ if (def && def.baseClasses) {
284
+ for (let i = 0; i < def.baseClasses.length; i++) classes.push(def.baseClasses[i]);
285
+ }
286
+ const variants = def && def.variants ? def.variants : {};
287
+ const variantKeys = Object.keys(variants);
288
+ for (let i = 0; i < variantKeys.length; i++) {
289
+ const key = variantKeys[i];
290
+ let value: string | null = null;
291
+ if (key === primaryKey) {
292
+ value = primaryValue;
293
+ } else if (props && props[key]) {
294
+ value = props[key];
295
+ } else if (def && def.defaultVariants && def.defaultVariants[key]) {
296
+ value = def.defaultVariants[key];
297
+ } else if (variants[key] && variants[key][0]) {
298
+ value = variants[key][0];
299
+ }
300
+ if (value && def.variantClasses && def.variantClasses[key] && def.variantClasses[key][value]) {
301
+ const variantClasses = def.variantClasses[key][value];
302
+ for (let j = 0; j < variantClasses.length; j++) classes.push(variantClasses[j]);
303
+ }
304
+ }
305
+ const extra = splitClassName(props && props.className);
306
+ for (let i = 0; i < extra.length; i++) classes.push(extra[i]);
307
+ return classes;
308
+ }
309
+
310
+ /**
311
+ * Return the pixel width to use for a responsive preview frame.
312
+ * Falls back to a mobile-first 390 px when the breakpoint has no registered
313
+ * min-width or the breakpoint is 'base'.
314
+ */
315
+ export function getResponsivePreviewWidth(breakpointName: string, minWidth: number): number {
316
+ if (breakpointName === 'base') return 390;
317
+ if (Number.isFinite(minWidth) && minWidth > 0) return minWidth;
318
+ return 390;
319
+ }
@@ -1,4 +1,7 @@
1
- import { parseColor, type RGB } from './colors';
1
+ import { parseColor, type RGB } from '../tokens';
2
+ import type { ComponentDef } from '../components/scanner-types';
3
+ import { applyFrameworkAdapters } from '../framework-adapters';
4
+ import { mergeMissing, resolveValuePercents } from './adapter-utils';
2
5
 
3
6
  // ---------------------------------------------------------------------------
4
7
  // JSX tree types
@@ -73,9 +76,9 @@ export type NodeIR =
73
76
  | NodeIRRing;
74
77
 
75
78
  export type NodeIRHelpers = {
76
- getComponentDefByName: (name: string) => any | null;
77
- normalizeComponentDef: (raw: any) => any;
78
- getCompoundClasses: (def: any, tagName: string) => string[];
79
+ getComponentDefByName: (name: string) => ComponentDef | null;
80
+ normalizeComponentDef: (raw: ComponentDef) => ComponentDef;
81
+ getCompoundClasses: (def: ComponentDef, tagName: string) => string[];
79
82
  mergeClasses: (base: string[], extra: string[]) => string[];
80
83
  };
81
84
 
@@ -85,10 +88,22 @@ export type NodeIRHelpers = {
85
88
 
86
89
  export function splitClassName(value?: string): string[] {
87
90
  if (!value) return [];
88
- return String(value)
89
- .split(/\s+/)
90
- .map(c => c.replace(/^!/, '').replace(/!$/, ''))
91
- .filter(Boolean);
91
+ const out: string[] = [];
92
+ for (const raw of String(value).split(/\s+/)) {
93
+ const cleaned = raw.replace(/^!/, '').replace(/!$/, '');
94
+ if (!cleaned) continue;
95
+ // `size-full` is Tailwind shorthand for `w-full h-full`. Expand it here
96
+ // so every downstream parser/check that already understands w-full and
97
+ // h-full handles size-full automatically — no parallel code path. The
98
+ // numeric `size-N` form has its own dedicated handler in sizing.ts and
99
+ // is intentionally left alone.
100
+ if (cleaned === 'size-full') {
101
+ out.push('w-full', 'h-full');
102
+ continue;
103
+ }
104
+ out.push(cleaned);
105
+ }
106
+ return out;
92
107
  }
93
108
 
94
109
  export function resolveNodeIR(node: JsxNode): NodeIR | null {
@@ -140,7 +155,16 @@ export function applyNodeTransforms(
140
155
  colorGroup: Record<string, string>,
141
156
  helpers: NodeIRHelpers
142
157
  ): NodeIR {
143
- let next = flattenComponentNodes(node, null, helpers);
158
+ // Framework adapters run FIRST so the injected classes flow through the
159
+ // flatten merge (a wrapper compound's class list and the adapter's
160
+ // slot-injected classes end up on the same element) and through every
161
+ // downstream transform without parallel handling.
162
+ let next = applyFrameworkAdapters(node);
163
+ next = flattenComponentNodes(next, null, helpers);
164
+ // Native HTML input rewrites run BEFORE the visual transforms (space,
165
+ // divide, ring) so the synthetic tree they emit flows through the rest
166
+ // of the pipeline as ordinary div + Tailwind classes.
167
+ next = transformInputNodes(next);
144
168
  next = transformSpaceNodes(next);
145
169
  next = transformDivideNodes(next, colorGroup);
146
170
  next = transformRingNodes(next, colorGroup);
@@ -157,7 +181,7 @@ export function isElementLikeNode(node: NodeIR): node is NodeIRElement | NodeIRC
157
181
 
158
182
  function flattenComponentNodes(
159
183
  node: NodeIR,
160
- parentCompoundDef: any | null,
184
+ parentCompoundDef: ComponentDef | null,
161
185
  helpers: NodeIRHelpers
162
186
  ): NodeIR {
163
187
  if (node.kind === 'text' || node.kind === 'divider') {
@@ -229,6 +253,173 @@ function flattenComponentNodes(
229
253
  });
230
254
  }
231
255
 
256
+ // ---------------------------------------------------------------------------
257
+ // Native HTML input rewrites
258
+ //
259
+ // Some `<input>` types have no built-in className-driven appearance that
260
+ // Figma can render — most notably `type="range"`, which is a custom
261
+ // browser widget (track + thumb). The default input branch in
262
+ // `ui-builder.ts` reads `value` / `defaultValue` / `placeholder` and
263
+ // emits a text node — fine for text/number/email, useless (or worse,
264
+ // stalling) for range. This transform rewrites those special cases at
265
+ // the IR-transform stage into a synthetic Tailwind tree the existing
266
+ // pipeline already knows how to render.
267
+ //
268
+ // Currently handles: `type="range"`. Extend the dispatcher in
269
+ // `transformInputNodes` when other types need similar treatment (e.g.
270
+ // checkbox / radio with `checked` driving a visual indicator).
271
+ // ---------------------------------------------------------------------------
272
+
273
+ export function transformInputNodes(node: NodeIR): NodeIR {
274
+ if (node.kind === 'text' || node.kind === 'divider') {
275
+ return node;
276
+ }
277
+
278
+ if (node.kind === 'fragment') {
279
+ return recurseFragment(node, transformInputNodes);
280
+ }
281
+
282
+ if (node.kind === 'ring') {
283
+ const nextChild = transformInputNodes(node.child);
284
+ return nextChild === node.child ? node : Object.assign({}, node, { child: nextChild });
285
+ }
286
+
287
+ // Recurse first so any nested `<input>` is rewritten before its parent
288
+ // decides what to do with it. Preserve object identity when no
289
+ // descendant changed (keeps `NODE_LAYOUT_CACHE` warm).
290
+ let childrenChanged = false;
291
+ const nextChildren: NodeIR[] = [];
292
+ for (const child of node.children) {
293
+ const next = transformInputNodes(child);
294
+ if (next !== child) childrenChanged = true;
295
+ nextChildren.push(next);
296
+ }
297
+ const recursed: NodeIR = childrenChanged
298
+ ? (Object.assign({}, node, { children: nextChildren }) as NodeIR)
299
+ : node;
300
+
301
+ if (recursed.kind !== 'element') return recursed;
302
+ if (recursed.tagLower !== 'input') return recursed;
303
+ const type = (recursed.props && recursed.props.type) || '';
304
+ if (type !== 'range') return recursed;
305
+
306
+ return buildRangeSliderTree(recursed as NodeIRElement);
307
+ }
308
+
309
+ function recurseFragment(
310
+ frag: NodeIRFragment,
311
+ fn: (n: NodeIR) => NodeIR,
312
+ ): NodeIR {
313
+ let changed = false;
314
+ const next: NodeIR[] = [];
315
+ for (const c of frag.children) {
316
+ const r = fn(c);
317
+ if (r !== c) changed = true;
318
+ next.push(r);
319
+ }
320
+ return changed ? Object.assign({}, frag, { children: next }) : frag;
321
+ }
322
+
323
+ /**
324
+ * Rewrite `<input type="range" min=N max=M value=V disabled? className=...>`
325
+ * into a styled `<div>` tree mirroring the shape that shadcn Slider's
326
+ * adapter already produces (and which the renderer is known to handle):
327
+ *
328
+ * wrapper (relative flex h-4 items-center, consumer classes preserved)
329
+ * └── track (flow child: h-1.5 w-full rounded-full overflow-hidden bg-secondary)
330
+ * └── filled (flow child of track: h-full rounded-full bg-primary w-[N%])
331
+ * └── thumb (absolute sibling of track: size-4 rounded-full -translate-x-1/2 left-[N%])
332
+ *
333
+ * Why mirror shadcn's structure (track as flow child, thumb as absolute
334
+ * sibling)? An all-absolute-children wrapper has no flow content to size
335
+ * against — deferred-layout passes can stall trying to resolve a 0-content
336
+ * frame with `flex items-center`. The shadcn Slider already navigates this
337
+ * by making Track a flow child of Control; we copy that pattern.
338
+ *
339
+ * Vertical centering of the thumb comes from `flex items-center` on the
340
+ * wrapper combined with the thumb's static position (size-4 = wrapper
341
+ * height) — no `top-1/2 -translate-y-1/2` needed.
342
+ *
343
+ * Geometry: `pct = ((value - min) / (max - min)) * 100`. See
344
+ * `resolveValuePercents` in `./adapter-utils.ts`.
345
+ */
346
+ function buildRangeSliderTree(input: NodeIRElement): NodeIR {
347
+ const props = input.props || {};
348
+ const rawValue = props.value ?? props.defaultValue;
349
+ const pcts = resolveValuePercents(rawValue, props.min, props.max);
350
+ const pct = pcts[0] ?? 0;
351
+ const isDisabled = props.disabled !== undefined && props.disabled !== 'false';
352
+
353
+ // Preserve sizing-related classes from the consumer (`w-full`, `w-[Npx]`,
354
+ // `max-w-*`) onto the wrapper. Visual / state classes that applied to the
355
+ // native widget shape (`accent-primary`, focus rings) are kept but
356
+ // typically no-ops on the synthetic div.
357
+ const wrapperExtras = ['relative', 'flex', 'h-4', 'items-center'];
358
+ if (isDisabled) wrapperExtras.push('opacity-50');
359
+ const wrapperClasses = mergeMissing(input.classes, wrapperExtras);
360
+
361
+ const filled: NodeIR = makeRangeChild('div', [
362
+ 'h-full',
363
+ 'rounded-full',
364
+ 'bg-primary',
365
+ `w-[${formatPercent(pct)}%]`,
366
+ ]);
367
+
368
+ const track: NodeIRElement = {
369
+ kind: 'element',
370
+ tagName: 'div',
371
+ tagLower: 'div',
372
+ props: {},
373
+ classes: [
374
+ 'h-1.5',
375
+ 'w-full',
376
+ 'rounded-full',
377
+ 'overflow-hidden',
378
+ 'bg-secondary',
379
+ ],
380
+ children: [filled],
381
+ };
382
+
383
+ const thumb: NodeIR = makeRangeChild('div', [
384
+ 'absolute',
385
+ 'size-4',
386
+ 'rounded-full',
387
+ 'border-2',
388
+ 'border-primary',
389
+ 'bg-background',
390
+ '-translate-x-1/2',
391
+ `left-[${formatPercent(pct)}%]`,
392
+ ]);
393
+
394
+ return {
395
+ kind: 'element',
396
+ tagName: 'div',
397
+ tagLower: 'div',
398
+ props: {},
399
+ classes: wrapperClasses,
400
+ children: [track, thumb],
401
+ };
402
+ }
403
+
404
+ function makeRangeChild(tag: string, classes: string[]): NodeIRElement {
405
+ return {
406
+ kind: 'element',
407
+ tagName: tag,
408
+ tagLower: tag,
409
+ props: {},
410
+ classes,
411
+ children: [],
412
+ };
413
+ }
414
+
415
+ function formatPercent(pct: number): string {
416
+ // Two-decimal precision keeps thumb placement visually correct for
417
+ // common (min, max, value) triples without bloating the class name.
418
+ // `Number.toFixed` always renders fixed decimals; trim trailing zeros.
419
+ const fixed = pct.toFixed(2);
420
+ return fixed.replace(/\.?0+$/, '') || '0';
421
+ }
422
+
232
423
  function transformSpaceNodes(node: NodeIR): NodeIR {
233
424
  if (node.kind === 'text' || node.kind === 'divider') {
234
425
  return node;
@@ -479,38 +670,35 @@ function parseRingTokens(
479
670
  let opacity: number | null = null;
480
671
  let offsetWidth: number | null = null;
481
672
  let offsetColor: RGB | null = null;
482
- let sawRing = false;
673
+ let sawRingWidth = false;
483
674
 
484
675
  for (const raw of classes) {
485
676
  if (raw === 'ring') {
486
- sawRing = true;
677
+ sawRingWidth = true;
487
678
  width = width == null ? 3 : width;
488
679
  continue;
489
680
  }
490
681
  const widthMatch = raw.match(/^ring-(\d+)$/);
491
682
  if (widthMatch) {
492
- sawRing = true;
683
+ sawRingWidth = true;
493
684
  width = parseInt(widthMatch[1], 10);
494
685
  continue;
495
686
  }
496
687
  const bracketMatch = raw.match(/^ring-\[(\d+(?:\.\d+)?)px\]$/);
497
688
  if (bracketMatch) {
498
- sawRing = true;
689
+ sawRingWidth = true;
499
690
  width = parseFloat(bracketMatch[1]);
500
691
  continue;
501
692
  }
502
693
  const opacityMatch = raw.match(/^ring-opacity-(\d+)$/);
503
694
  if (opacityMatch) {
504
- sawRing = true;
505
695
  opacity = Math.max(0, Math.min(1, parseInt(opacityMatch[1], 10) / 100));
506
696
  continue;
507
697
  }
508
698
  if (raw === 'ring-inset') {
509
- sawRing = true;
510
699
  continue;
511
700
  }
512
701
  if (raw.startsWith('ring-offset-')) {
513
- sawRing = true;
514
702
  const offsetRaw = raw.slice('ring-offset-'.length);
515
703
  const offsetBracket = offsetRaw.match(/^\[(.+)\]$/);
516
704
  if (offsetBracket) {
@@ -530,7 +718,6 @@ function parseRingTokens(
530
718
  continue;
531
719
  }
532
720
  if (raw.startsWith('ring-')) {
533
- sawRing = true;
534
721
  const tokenRaw = raw.slice('ring-'.length);
535
722
  const parts = tokenRaw.split('/');
536
723
  const token = parts[0];
@@ -548,7 +735,9 @@ function parseRingTokens(
548
735
  }
549
736
  }
550
737
 
551
- if (!sawRing) return null;
738
+ // Tailwind ring modifiers like `ring-offset-*`, `ring-opacity-*`, and `ring-<color>`
739
+ // do not render a ring by themselves — they only modify an existing ring width.
740
+ if (!sawRingWidth) return null;
552
741
 
553
742
  if (width == null) width = 3;
554
743
  if (!color) {
@@ -49,12 +49,18 @@ function splitResponsiveClass(cls: string): { bucket: string; utility: string |
49
49
  }
50
50
 
51
51
  if (responsiveVariants.length === 0) {
52
- if (remainingVariants.length > 0) return null;
52
+ // Non-responsive variants like *:, hover:, focus: — keep the original class
53
+ // string in the base bucket so it appears at every breakpoint.
54
+ if (remainingVariants.length > 0) return { bucket: 'base', utility: cls };
53
55
  return { bucket: 'base', utility: utility };
54
56
  }
55
57
 
56
- if (remainingVariants.length > 0) return null;
57
58
  const bucket = responsiveVariants[responsiveVariants.length - 1];
59
+ if (remainingVariants.length > 0) {
60
+ // Mixed e.g. sm:*:w-auto — strip the responsive prefix but preserve the
61
+ // remaining variants so the output is *:w-auto at the sm bucket.
62
+ return { bucket: bucket, utility: remainingVariants.join(':') + ':' + utility };
63
+ }
58
64
  return { bucket: bucket, utility: utility };
59
65
  }
60
66
 
@@ -78,6 +84,30 @@ function buildResponsiveBuckets(classes: string[]): Record<string, string[]> {
78
84
  return buckets;
79
85
  }
80
86
 
87
+ const DISPLAY_UTILITIES = new Set([
88
+ 'hidden', 'block', 'inline-block', 'inline', 'flex', 'inline-flex',
89
+ 'grid', 'inline-grid', 'table', 'inline-table', 'contents', 'list-item',
90
+ 'flow-root', 'table-row', 'table-cell', 'table-caption', 'table-column',
91
+ 'table-column-group', 'table-footer-group', 'table-header-group',
92
+ 'table-row-group',
93
+ ]);
94
+
95
+ /**
96
+ * Returns true when the given class set resolves to `display: none` at the
97
+ * target breakpoint, after the base → sm → md → … cascade. Used to skip
98
+ * responsive-preview tiles where the component is hidden (e.g. md:hidden).
99
+ */
100
+ export function isHiddenAtBreakpoint(classes: string[], breakpoint: string): boolean {
101
+ const effective = getClassesForBreakpoint(classes, breakpoint);
102
+ let lastDisplay: string | null = null;
103
+ for (let i = 0; i < effective.length; i++) {
104
+ const cls = effective[i];
105
+ const bare = cls.charAt(0) === '!' ? cls.slice(1) : cls;
106
+ if (DISPLAY_UTILITIES.has(bare)) lastDisplay = bare;
107
+ }
108
+ return lastDisplay === 'hidden';
109
+ }
110
+
81
111
  export function extractBreakpointsFromClasses(classes: string[]): BreakpointInfo[] {
82
112
  const buckets = buildResponsiveBuckets(classes);
83
113
  const hasResponsive = BREAKPOINT_ORDER.some(bp => (buckets[bp] && buckets[bp].length > 0));