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,237 @@
1
+ import { getBaseClass, mergeClasses, parseUtilityClass } from '../tailwind';
2
+ import { parseSquareSizeToken } from '../layout';
3
+ import { getComponentDefByName } from '../components';
4
+ import {
5
+ isAccordionRootTag,
6
+ isAccordionItemTag,
7
+ isSelectItemIndicatorTag,
8
+ isRadioGroupIndicatorTag,
9
+ } from './tag-predicates';
10
+ import type { NodeIR } from '../tailwind';
11
+ import type { RenderContext } from './render-context';
12
+
13
+ /**
14
+ * NodeIR tree helpers shared by ui-builder and the rendering pipeline.
15
+ * Mostly about resolving the *effective* classes for a node (taking
16
+ * scanned component defs into account), per-child render-context
17
+ * derivation, and small structural utilities.
18
+ */
19
+
20
+ /**
21
+ * Unwraps transparent single-child component wrappers so the caller's loop
22
+ * sees the visual child directly. Matches the same short-circuit applied
23
+ * during scanner classification.
24
+ *
25
+ * Without the `compDef` guard a real CVA component like `<Badge variant="soft">`
26
+ * (which has empty `classes` because the className is computed inside the
27
+ * component) would be unwrapped to its single text child, dropping the
28
+ * symbol entirely.
29
+ */
30
+ export function unwrapTransparentWrapper(node: NodeIR): NodeIR {
31
+ let current = node;
32
+ for (let i = 0; i < 8; i++) {
33
+ if (current.kind !== 'component') return current;
34
+ if ((current.classes || []).length !== 0) return current;
35
+ if (!current.children || current.children.length !== 1) return current;
36
+ if (current.tagName.toLowerCase().endsWith('trigger')) return current;
37
+ if (getComponentDefByName(current.tagName)) return current;
38
+ // Context-sensitive indicator wrappers (Select/RadioGroup item indicators)
39
+ // gate their child's visibility on the parent Item being selected/checked.
40
+ // The gating check lives in `buildFigmaNode` and only runs when this
41
+ // function preserves the wrapper — without this guard, the wrapper was
42
+ // unwrapped to its check/dot icon BEFORE the suppression could fire, and
43
+ // every non-selected item rendered a stale indicator (the recurring
44
+ // "checkmarks on every Select item" bug).
45
+ if (isSelectItemIndicatorTag(current.tagName)) return current;
46
+ if (isRadioGroupIndicatorTag(current.tagName)) return current;
47
+ current = current.children[0];
48
+ }
49
+ return current;
50
+ }
51
+
52
+ export function createPerChildRenderContext(
53
+ baseContext: RenderContext,
54
+ parentNode: NodeIR,
55
+ child: NodeIR,
56
+ accordionItemOrdinal: { value: number }
57
+ ): RenderContext {
58
+ const childContext: RenderContext = { ...baseContext };
59
+ if (
60
+ (parentNode.kind === 'element' || parentNode.kind === 'component') &&
61
+ isAccordionRootTag(parentNode.tagName) &&
62
+ (child.kind === 'element' || child.kind === 'component') &&
63
+ isAccordionItemTag(child.tagName)
64
+ ) {
65
+ childContext.accordionItemIndex = accordionItemOrdinal.value++;
66
+ }
67
+ return childContext;
68
+ }
69
+
70
+ export function getRenderableChildren(node: NodeIR, classes: string[]): NodeIR[] {
71
+ if (node.kind !== 'element' && node.kind !== 'component') return [];
72
+ const children = node.children.slice();
73
+ if (node.kind !== 'element' || node.tagLower !== 'table') return children;
74
+ if (!classes.includes('caption-bottom')) return children;
75
+
76
+ const captions: NodeIR[] = [];
77
+ const others: NodeIR[] = [];
78
+ for (const child of children) {
79
+ if ((child.kind === 'element' || child.kind === 'component') && child.tagLower === 'caption') captions.push(child);
80
+ else others.push(child);
81
+ }
82
+ return captions.length > 0 ? others.concat(captions) : children;
83
+ }
84
+
85
+ export function normalizeComponentLookupKey(value: string): string {
86
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
87
+ }
88
+
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ export function shouldMergeDefBaseClassesForTag(def: any, tagName: string): boolean {
91
+ if (!def || !def.name || !tagName) return false;
92
+ // Do not merge scanned component base classes into intrinsic HTML tags.
93
+ // Example: a native `button` inside Dialog.Close should not inherit the
94
+ // global Button component's class bag.
95
+ if (tagName === tagName.toLowerCase()) return false;
96
+ return normalizeComponentLookupKey(def.name) === normalizeComponentLookupKey(tagName);
97
+ }
98
+
99
+ /**
100
+ * Unwraps the scanner's nested `{ analysis: ... }` envelope when present,
101
+ * preserving the metadata fields (stories, layout, responsive, colorScheme,
102
+ * etc.) that live alongside `analysis`.
103
+ */
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ export function normalizeComponentDef(raw: any): any {
106
+ if (!raw || !raw.analysis) return raw;
107
+
108
+ // Scanner output stores component metadata (stories, layout, responsive, colorScheme)
109
+ // alongside the nested `analysis`. Many render paths want the analysis shape but still
110
+ // need the attached metadata, so preserve it when normalizing.
111
+ return {
112
+ ...raw.analysis,
113
+ filePath: raw.analysis.filePath || raw.filePath,
114
+ stories: Array.isArray(raw.analysis.stories)
115
+ ? raw.analysis.stories
116
+ : (Array.isArray(raw.stories) ? raw.stories : []),
117
+ hasStory: typeof raw.analysis.hasStory === 'boolean'
118
+ ? raw.analysis.hasStory
119
+ : !!raw.hasStory,
120
+ layout: raw.layout,
121
+ responsive: raw.responsive,
122
+ colorScheme: raw.colorScheme,
123
+ kind: raw.kind,
124
+ usesCount: raw.usesCount,
125
+ usedByCount: raw.usedByCount,
126
+ isLeaf: raw.isLeaf,
127
+ symbolCandidate: raw.symbolCandidate,
128
+ safeTextOverrideProps: Array.isArray(raw.safeTextOverrideProps) ? raw.safeTextOverrideProps : undefined,
129
+ };
130
+ }
131
+
132
+ export function getNodeEffectiveClasses(node: NodeIR): string[] {
133
+ if (node.kind !== 'element' && node.kind !== 'component') return [];
134
+ if (node.kind === 'element') return node.classes || [];
135
+
136
+ const ownClasses = node.classes || [];
137
+ const def = getComponentDefByName(node.tagName);
138
+ if (!def) return ownClasses;
139
+ const normalized = normalizeComponentDef(def);
140
+ const baseClasses = Array.isArray(normalized?.baseClasses)
141
+ ? normalized.baseClasses
142
+ : (Array.isArray(normalized?.classes) ? normalized.classes : []);
143
+ if (!baseClasses || baseClasses.length === 0) return ownClasses;
144
+ if (!shouldMergeDefBaseClassesForTag(normalized, node.tagName)) return ownClasses;
145
+ if (normalized?.type === 'simple') {
146
+ // Scanner "simple" class bags are file-wide and can include child-only display/layout classes.
147
+ // Prefer the instance classes; only fall back to sanitized base classes when none exist.
148
+ if (ownClasses.length > 0) return ownClasses;
149
+ return pickPreservedWrapperBaseClasses(baseClasses);
150
+ }
151
+ return mergeClasses(baseClasses, ownClasses);
152
+ }
153
+
154
+ export function getNodeMarginTopPx(node: NodeIR): number {
155
+ const classes = getNodeEffectiveClasses(node);
156
+ let maxMarginTop = 0;
157
+ for (const cls of classes) {
158
+ const atom = parseUtilityClass(cls);
159
+ if (!atom || !atom.utility) continue;
160
+ if (Array.isArray(atom.variants) && atom.variants.length > 0) continue;
161
+ const match = atom.utility.match(/^mt-(.+)$/);
162
+ if (!match) continue;
163
+ const resolved = parseSquareSizeToken(match[1]);
164
+ if (resolved != null && Number.isFinite(resolved) && resolved > maxMarginTop) {
165
+ maxMarginTop = resolved;
166
+ }
167
+ }
168
+ return maxMarginTop;
169
+ }
170
+
171
+ /**
172
+ * From a scanned component's bag of base classes, keep only the structural
173
+ * intent (positioning, container spacing, the first display class, gap /
174
+ * grid-template tokens) while dropping child-only classes that would
175
+ * over-style a wrapper frame.
176
+ */
177
+ export function pickPreservedWrapperBaseClasses(defClasses: string[]): string[] {
178
+ if (!Array.isArray(defClasses) || defClasses.length === 0) return [];
179
+
180
+ const out: string[] = [];
181
+ let pickedDisplay = false;
182
+
183
+ for (const cls of defClasses) {
184
+ const base = getBaseClass(cls);
185
+ if (!base) continue;
186
+
187
+ // Positioning + container spacing
188
+ if (base === 'relative' || base === 'isolate') {
189
+ out.push(cls);
190
+ continue;
191
+ }
192
+ if (base.startsWith('p-') || base.startsWith('px-') || base.startsWith('py-')) {
193
+ out.push(cls);
194
+ continue;
195
+ }
196
+ if (base.startsWith('rounded')) {
197
+ out.push(cls);
198
+ continue;
199
+ }
200
+
201
+ // Preserve only the first display class from base definitions.
202
+ // Scanner class bags can include child classes; taking the first display signal
203
+ // keeps root intent (e.g. grid) and avoids later child overrides (e.g. inline-flex).
204
+ const isDisplayClass =
205
+ base === 'flex'
206
+ || base === 'inline-flex'
207
+ || base === 'grid'
208
+ || base === 'inline-grid'
209
+ || base === 'block'
210
+ || base === 'inline-block'
211
+ || base === 'contents'
212
+ || base === 'flow-root';
213
+ if (isDisplayClass) {
214
+ if (!pickedDisplay) {
215
+ out.push(cls);
216
+ pickedDisplay = true;
217
+ }
218
+ continue;
219
+ }
220
+
221
+ if (
222
+ base.startsWith('gap-')
223
+ || base.startsWith('gap-x-')
224
+ || base.startsWith('gap-y-')
225
+ || base.startsWith('space-x-')
226
+ || base.startsWith('space-y-')
227
+ || base.startsWith('grid-cols-')
228
+ || base.startsWith('grid-rows-')
229
+ || base.startsWith('auto-cols-')
230
+ || base.startsWith('auto-rows-')
231
+ ) {
232
+ out.push(cls);
233
+ }
234
+ }
235
+
236
+ return out;
237
+ }
@@ -0,0 +1,196 @@
1
+ import { parseUtilityClass } from '../tailwind';
2
+ import type { NodeIR } from '../tailwind';
3
+
4
+ /**
5
+ * Evaluates Tailwind v4 conditional variants (`data-[…]`, `has-[…]`,
6
+ * `has-data-[…]`, compound `has-[[…].…]`) against a resolved NodeIR. Used
7
+ * by ui-builder to "activate" conditionally-applied utilities — i.e. when
8
+ * the variant predicate matches the node's actual props/structure, the
9
+ * underlying utility is appended to the live class list.
10
+ *
11
+ * Pure: NodeIR-only walk, no Figma calls.
12
+ */
13
+
14
+ /**
15
+ * Aliases that map a `data-*` lookup to the React/JSX prop conventions
16
+ * various component libraries use to express the same state. The variant
17
+ * engine itself only knows `data-*` selectors, but consumers write
18
+ * `defaultPressed`, `defaultChecked`, `disabled`, etc. as plain props.
19
+ *
20
+ * Without this table, `data-[pressed]:bg-accent` on a `<Toggle defaultPressed>`
21
+ * never resolves — there's no literal `data-pressed` attribute on the node,
22
+ * only the React prop. With it, `defaultPressed` flowing through after
23
+ * scanner expansion is treated as if the element had `data-pressed="true"`
24
+ * for variant-matching purposes.
25
+ *
26
+ * Only consulted by `getNodePropValue` when a lookup for a literal `data-*`
27
+ * key misses — so it never overrides an explicit attribute the consumer set.
28
+ */
29
+ const DATA_ATTR_PROP_ALIASES: Record<string, ReadonlyArray<string>> = {
30
+ 'data-pressed': ['defaultPressed', 'pressed'],
31
+ 'data-disabled': ['disabled', 'aria-disabled'],
32
+ 'data-checked': ['checked', 'defaultChecked', 'aria-checked'],
33
+ 'data-selected': ['selected', 'aria-selected'],
34
+ 'data-open': ['open', 'defaultOpen'],
35
+ };
36
+
37
+ function isTruthyPropValue(value: unknown): boolean {
38
+ if (value === true) return true;
39
+ if (typeof value === 'string') {
40
+ const lower = value.trim().toLowerCase();
41
+ return lower === 'true' || lower === 'on';
42
+ }
43
+ return false;
44
+ }
45
+
46
+ function getNodePropValue(node: NodeIR, key: string): string | null {
47
+ if ((node.kind !== 'element' && node.kind !== 'component') || !node.props) return null;
48
+ const props = node.props as Record<string, unknown>;
49
+ const value = props[key];
50
+ if (value != null) return String(value);
51
+ const aliases = DATA_ATTR_PROP_ALIASES[key];
52
+ if (aliases) {
53
+ for (let i = 0; i < aliases.length; i++) {
54
+ if (isTruthyPropValue(props[aliases[i]])) return 'true';
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function nodeMatchesDataVariant(node: NodeIR, variant: string): boolean {
61
+ const equalMatch = variant.match(/^data-\[([a-zA-Z0-9_-]+)=([^\]]+)\]$/);
62
+ if (equalMatch) {
63
+ const attr = 'data-' + equalMatch[1];
64
+ const expected = equalMatch[2];
65
+ const actual = getNodePropValue(node, attr);
66
+ return actual != null && actual.trim() === expected;
67
+ }
68
+ const presentMatch = variant.match(/^data-\[([a-zA-Z0-9_-]+)\]$/);
69
+ if (presentMatch) {
70
+ const attr = 'data-' + presentMatch[1];
71
+ return getNodePropValue(node, attr) != null;
72
+ }
73
+ // Tailwind v4 / shadcn bare syntax: `data-highlighted` (without brackets).
74
+ const bareMatch = variant.match(/^data-([a-zA-Z][a-zA-Z0-9_-]*)$/);
75
+ if (bareMatch) {
76
+ const attr = 'data-' + bareMatch[1];
77
+ return getNodePropValue(node, attr) != null;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ function getNodeChildren(node: NodeIR): NodeIR[] {
83
+ if (node.kind === 'element' || node.kind === 'component' || node.kind === 'fragment') {
84
+ return node.children;
85
+ }
86
+ if (node.kind === 'ring') return [node.child];
87
+ return [];
88
+ }
89
+
90
+ function nodeHasDescendantDataVariant(node: NodeIR, variant: string): boolean {
91
+ const equalMatch = variant.match(/^has-data-\[([a-zA-Z0-9_-]+)=([^\]]+)\]$/);
92
+ const presentMatch = variant.match(/^has-data-\[([a-zA-Z0-9_-]+)\]$/);
93
+ if (!equalMatch && !presentMatch) return false;
94
+
95
+ const attr = 'data-' + (equalMatch ? equalMatch[1] : presentMatch![1]);
96
+ const expected = equalMatch ? equalMatch[2] : null;
97
+
98
+ const stack: NodeIR[] = getNodeChildren(node).slice();
99
+ while (stack.length > 0) {
100
+ const current = stack.pop() as NodeIR;
101
+ const value = getNodePropValue(current, attr);
102
+ if (value != null) {
103
+ if (expected == null || value.trim() === expected) return true;
104
+ }
105
+ const children = getNodeChildren(current);
106
+ if (children.length > 0) {
107
+ for (let i = 0; i < children.length; i++) {
108
+ stack.push(children[i]);
109
+ }
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ function nodeHasClass(node: NodeIR, className: string): boolean {
116
+ if (node.kind !== 'element' && node.kind !== 'component') return false;
117
+ return Array.isArray(node.classes) && node.classes.indexOf(className) !== -1;
118
+ }
119
+
120
+ function nodeHasDescendantCompoundSelector(node: NodeIR, variant: string): boolean {
121
+ // Matches patterns like `has-[[data-slot=card-header].border-b]` — any
122
+ // descendant that carries BOTH the data attribute and the class.
123
+ const compoundMatch = variant.match(/^has-\[\[([a-zA-Z0-9_-]+)(?:=([^\]]+))?\]\.([a-zA-Z0-9_:\[\]\/-]+)\]$/);
124
+ if (!compoundMatch) return false;
125
+ const attr = compoundMatch[1];
126
+ const expected = compoundMatch[2] != null ? compoundMatch[2] : null;
127
+ const className = compoundMatch[3];
128
+
129
+ const stack: NodeIR[] = getNodeChildren(node).slice();
130
+ while (stack.length > 0) {
131
+ const current = stack.pop() as NodeIR;
132
+ const attrValue = getNodePropValue(current, attr);
133
+ const attrMatches = attrValue != null && (expected == null || attrValue.trim() === expected);
134
+ if (attrMatches && nodeHasClass(current, className)) return true;
135
+ const children = getNodeChildren(current);
136
+ for (let i = 0; i < children.length; i++) stack.push(children[i]);
137
+ }
138
+ return false;
139
+ }
140
+
141
+ function nodeMatchesHasVariant(node: NodeIR, variant: string): boolean {
142
+ if (variant === 'has-[>img:first-child]') {
143
+ const children = getNodeChildren(node);
144
+ if (children.length === 0) return false;
145
+ for (let i = 0; i < children.length; i++) {
146
+ const child = children[i];
147
+ if (child.kind !== 'element' && child.kind !== 'component') continue;
148
+ return child.tagLower === 'img';
149
+ }
150
+ return false;
151
+ }
152
+ if (nodeHasDescendantDataVariant(node, variant)) return true;
153
+ return nodeHasDescendantCompoundSelector(node, variant);
154
+ }
155
+
156
+ function isActivatedConditionalVariant(node: NodeIR, variant: string): boolean {
157
+ if (!variant) return false;
158
+ if (nodeMatchesDataVariant(node, variant)) return true;
159
+ if (nodeMatchesHasVariant(node, variant)) return true;
160
+ return false;
161
+ }
162
+
163
+ /**
164
+ * For each class with conditional variants (`data-*`, `has-*`), if every
165
+ * variant predicate matches the node, append the underlying utility to
166
+ * the active class list. This is the "if the data-state is open, also
167
+ * apply `bg-accent`" expansion the renderer needs to do at draw time.
168
+ */
169
+ export function expandActiveConditionalVariants(classes: string[], node: NodeIR): string[] {
170
+ if (!classes || classes.length === 0) return classes;
171
+ const out = classes.slice();
172
+ const seen: Record<string, true> = {};
173
+ for (let i = 0; i < out.length; i++) {
174
+ seen[out[i]] = true;
175
+ }
176
+
177
+ for (let i = 0; i < classes.length; i++) {
178
+ const atom = parseUtilityClass(classes[i]);
179
+ if (!atom || !atom.utility) continue;
180
+ if (!Array.isArray(atom.variants) || atom.variants.length === 0) continue;
181
+
182
+ let allMatched = true;
183
+ for (let j = 0; j < atom.variants.length; j++) {
184
+ if (!isActivatedConditionalVariant(node, atom.variants[j])) {
185
+ allMatched = false;
186
+ break;
187
+ }
188
+ }
189
+ if (!allMatched) continue;
190
+ if (seen[atom.utility]) continue;
191
+ seen[atom.utility] = true;
192
+ out.push(atom.utility);
193
+ }
194
+
195
+ return out;
196
+ }
@@ -0,0 +1,104 @@
1
+ import { findChildByName, getFrameHash, setFrameHash, hashString, hashDef } from '../cache';
2
+ import { RENDER_ENGINE_VERSION } from '../render-engine-version';
3
+ import { tagGeneratedNode } from './generated-node';
4
+ import { getThemeContext } from './theme-context';
5
+ import { ENABLE_SYMBOL_MASTERS, ensureThemeComponentLibrary } from './master-shared';
6
+ import { buildNonCvaSymbolSourceNode } from './story-frames';
7
+ import { ensureCvaComponentSet } from './cva-master';
8
+ import { ensureStateComponentSet } from './state-master';
9
+ import type { StoryBuilderContext } from './story-builder-context';
10
+
11
+ /**
12
+ * Master creation for non-CVA component types (simple / compound) and the
13
+ * preflight pass that warms all three master kinds (CVA, state, non-CVA)
14
+ * for a given theme.
15
+ */
16
+
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ export function buildBasicComponentMasterHash(def: any, theme: string): string {
19
+ const themeContext = getThemeContext(theme);
20
+ return hashString(
21
+ hashDef(def)
22
+ + ':'
23
+ + theme
24
+ + ':basic-symbol-v3:'
25
+ + RENDER_ENGINE_VERSION
26
+ + ':'
27
+ + JSON.stringify({
28
+ colorGroup: themeContext.colorGroup,
29
+ radiusGroup: themeContext.radiusGroup,
30
+ })
31
+ );
32
+ }
33
+
34
+ export function ensureNonCvaComponentMaster(
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ def: any,
37
+ theme: string,
38
+ ctx: StoryBuilderContext
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ ): any | null {
41
+ if (!ENABLE_SYMBOL_MASTERS) return null;
42
+ if (!def || def.type === 'cva') return null;
43
+ if (def.symbolCandidate === false) return null;
44
+
45
+ const themeLibrary = ensureThemeComponentLibrary(theme);
46
+ if (!themeLibrary) return null;
47
+
48
+ const name = def.name + ' [' + theme + ']';
49
+ const masterHash = buildBasicComponentMasterHash(def, theme);
50
+ const existing = findChildByName(themeLibrary, name);
51
+ if (existing && existing.type === 'COMPONENT' && getFrameHash(existing) === masterHash) {
52
+ return existing;
53
+ }
54
+ if (existing) {
55
+ existing.remove();
56
+ }
57
+
58
+ const sourceNode = buildNonCvaSymbolSourceNode(def, theme, ctx);
59
+ if (!sourceNode) return null;
60
+ themeLibrary.appendChild(sourceNode);
61
+
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ let component: any = null;
64
+ try {
65
+ component = figma.createComponentFromNode(sourceNode);
66
+ } catch (_error) {
67
+ try {
68
+ sourceNode.remove();
69
+ } catch (_cleanupError) {
70
+ // ignore cleanup errors
71
+ }
72
+ return null;
73
+ }
74
+ if (!component) return null;
75
+
76
+ component.name = name;
77
+ component.visible = false;
78
+ if (component.parent !== themeLibrary) {
79
+ themeLibrary.appendChild(component);
80
+ }
81
+ setFrameHash(component, masterHash);
82
+ tagGeneratedNode(component, 'component-master:' + def.name + ':' + theme);
83
+ return component;
84
+ }
85
+
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ export function warmSymbolMasters(defs: any[], theme: string, ctx: StoryBuilderContext): void {
88
+ if (!ENABLE_SYMBOL_MASTERS || !Array.isArray(defs) || defs.length === 0) return;
89
+ for (let i = 0; i < defs.length; i++) {
90
+ const def = defs[i];
91
+ if (!def || def.symbolCandidate === false) continue;
92
+ try {
93
+ if (def.type === 'cva') {
94
+ ensureCvaComponentSet(def, theme, ctx);
95
+ } else if (def.type === 'state') {
96
+ ensureStateComponentSet(def, theme, ctx);
97
+ } else if (def.type === 'simple' || def.type === 'compound') {
98
+ ensureNonCvaComponentMaster(def, theme, ctx);
99
+ }
100
+ } catch (_error) {
101
+ // Keep runtime resilient: missing master creation should not block raw preview rendering.
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Portal-aware story-tree resolution.
3
+ *
4
+ * Radix UI components like Dialog / Popover / Tooltip render content via
5
+ * React portals. When the scanner walks a story's JSX tree, portal-rooted
6
+ * subtrees are tagged `__fromPortal: true`. This module decides what to do
7
+ * with those tagged nodes for each story:
8
+ *
9
+ * - **Closed-state stories** (e.g. Dialog.Default — no `defaultOpen`):
10
+ * strip portal subtrees so only the trigger button shows in Figma.
11
+ * Mirrors browser closed state — the portal panel isn't visible.
12
+ *
13
+ * - **Open-state stories** (e.g. Popover.OpenPanel with `defaultOpen`):
14
+ * keep the full tree so both trigger and panel render. Storybook shows
15
+ * both in this case, and earlier behaviour (stripping the trigger) made
16
+ * open stories look like orphan panels.
17
+ *
18
+ * The decision happens up-front in `resolvePortalAwareStoryTree`; the
19
+ * other exports are building blocks for callers that need finer control.
20
+ */
21
+
22
+ /**
23
+ * Removes nodes marked __fromPortal from the tree.
24
+ * Used for closed-state stories (e.g. Select.Default, Dialog.Default) where Radix UI
25
+ * renders portal content in SSR even when closed — only the trigger should show in Figma.
26
+ */
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ export function filterPortalNodesFromTree(node: any): any {
29
+ if (!node || typeof node !== 'object') return node;
30
+ if (node.__fromPortal || (node.props && node.props.__fromPortal)) return null;
31
+ const children = Array.isArray(node.children) ? node.children : [];
32
+ if (children.length === 0) return node;
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ const filtered: any[] = [];
35
+ let changed = false;
36
+ for (let i = 0; i < children.length; i++) {
37
+ const result = filterPortalNodesFromTree(children[i]);
38
+ if (result === null) {
39
+ changed = true;
40
+ continue;
41
+ }
42
+ if (result !== children[i]) changed = true;
43
+ filtered.push(result);
44
+ }
45
+ if (!changed) return node;
46
+ return Object.assign({}, node, { children: filtered });
47
+ }
48
+
49
+ /**
50
+ * Extracts the first node marked __fromPortal from the tree.
51
+ * Used for open-state stories (e.g. Popover.OpenPanel, Tooltip.OpenPanel) where
52
+ * the popup content should render in Figma, not the trigger button.
53
+ */
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ export function extractPortalContentFromTree(node: any): any {
56
+ if (!node || typeof node !== 'object') return null;
57
+ if (node.props && node.props.__fromPortal) return node;
58
+ if (Array.isArray(node.children)) {
59
+ for (const child of node.children) {
60
+ const found = extractPortalContentFromTree(child);
61
+ if (found) return found;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Returns true if the jsxTree contains any element whose tagName ends with "trigger".
69
+ */
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ function treeHasTriggerElement(node: any): boolean {
72
+ if (!node || typeof node !== 'object') return false;
73
+ if (node.tagName && typeof node.tagName === 'string' &&
74
+ node.tagName.toLowerCase().endsWith('trigger')) return true;
75
+ if (Array.isArray(node.children)) {
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ return node.children.some((c: any) => treeHasTriggerElement(c));
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Returns true if the jsxTree contains any node marked with __fromPortal by the scanner.
84
+ */
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ function treeHasPortalContent(node: any): boolean {
87
+ if (!node || typeof node !== 'object') return false;
88
+ if (node.props && node.props.__fromPortal) return true;
89
+ if (Array.isArray(node.children)) {
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ return node.children.some((c: any) => treeHasPortalContent(c));
92
+ }
93
+ return false;
94
+ }
95
+
96
+ /**
97
+ * Returns true if any node within the first few levels has defaultOpen or open=true.
98
+ * Used to distinguish intentionally-open stories from closed-state Radix SSR renders.
99
+ */
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ export function treeHasOpenState(node: any, depth: number = 0): boolean {
102
+ if (!node || typeof node !== 'object' || depth > 4) return false;
103
+ const p = node.props;
104
+ if (p && (p.defaultOpen === 'true' || p.defaultOpen === true || p.open === 'true' || p.open === true)) return true;
105
+ if (Array.isArray(node.children)) {
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ return node.children.some((c: any) => treeHasOpenState(c, depth + 1));
108
+ }
109
+ return false;
110
+ }
111
+
112
+ /**
113
+ * Returns true when the story jsxTree has both a trigger element and portal-unwrapped content
114
+ * AND the component is intentionally open (defaultOpen). Used for open-state stories like
115
+ * Popover.OpenPanel, Tooltip.OpenPanel.
116
+ */
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
+ export function jsxTreeIsPortalTriggerOnly(tree: any): boolean {
119
+ return treeHasTriggerElement(tree) && treeHasPortalContent(tree);
120
+ }
121
+
122
+ /**
123
+ * Resolve story jsxTree for portal components:
124
+ * - closed stories: remove portal subtree so only trigger remains (matches
125
+ * browser closed-state — portal isn't visible).
126
+ * - open stories: keep the full tree so both trigger and panel render. This
127
+ * mirrors how Storybook renders the open state (e.g. Popover.OpenPanel,
128
+ * Select.OpenPanel) — users see the button that opened the panel.
129
+ * Previously we stripped the trigger for most portals, which made Popover
130
+ * / Tooltip / DropdownMenu open stories look like orphan panels.
131
+ */
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ export function resolvePortalAwareStoryTree(tree: any): any {
134
+ if (!tree) return null;
135
+ if (!jsxTreeIsPortalTriggerOnly(tree)) return tree;
136
+ if (treeHasOpenState(tree)) return tree;
137
+ return filterPortalNodesFromTree(tree);
138
+ }