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
@@ -8,7 +8,7 @@
8
8
  * - Simple: Components with static classes
9
9
  */
10
10
 
11
- import { Project, SyntaxKind, Node, SourceFile } from 'ts-morph';
11
+ import { Project, SyntaxKind, Node, SourceFile, type JsxAttribute } from 'ts-morph';
12
12
  import * as path from 'path';
13
13
  import * as fs from 'fs';
14
14
  import type {
@@ -27,7 +27,24 @@ import type {
27
27
  JsxText,
28
28
  IconImportSpec,
29
29
  } from './types';
30
- import { groupClassesByState, STATE_MODIFIERS } from './tailwind-parser';
30
+ import { groupClassesByState, OWN_STATE_MODIFIERS } from './tailwind-parser';
31
+ import { twMerge } from 'tailwind-merge';
32
+
33
+ /**
34
+ * Apply tailwind-merge to a space-joined class string to resolve conflicting utilities
35
+ * (e.g. `w-3/4 w-full` → `w-full`). Runs on the output of resolved `cn()` / `clsx()` /
36
+ * `twMerge()` / `cva()` call expressions so Figma renders the same class set the browser
37
+ * would. Falls back to the raw input on any parsing error to stay defensive.
38
+ */
39
+ function resolveClassConflicts(input: string): string {
40
+ const trimmed = input.trim();
41
+ if (!trimmed) return '';
42
+ try {
43
+ return twMerge(trimmed);
44
+ } catch {
45
+ return trimmed;
46
+ }
47
+ }
31
48
 
32
49
  // ============================================================================
33
50
  // Helpers
@@ -43,6 +60,46 @@ function decodeHtmlEntities(text: string): string {
43
60
  return text.replace(/&[a-zA-Z]+;/g, (entity) => HTML_ENTITIES[entity] ?? entity);
44
61
  }
45
62
 
63
+ /**
64
+ * Normalize JsxText whitespace the same way Babel/React do.
65
+ *
66
+ * IMPORTANT: callers must pass `child.getFullText()`, NOT `child.getText()`.
67
+ * ts-morph's `getText()` strips leading trivia from JsxText nodes — for the
68
+ * JsxText between `</span>` and `are`, getText() returns "are the design..."
69
+ * with the leading space LOST. That breaks inter-segment spacing
70
+ * (`Your <span>foo</span> bar` collapses to `Your foobar`). `getFullText()`
71
+ * preserves all whitespace, which is exactly what Babel's transform needs.
72
+ *
73
+ * Algorithm (mirrors babel's cleanJSXElementLiteralChild):
74
+ * - Tabs become spaces.
75
+ * - Leading whitespace is stripped on every line EXCEPT the first.
76
+ * - Trailing whitespace is stripped on every line EXCEPT the last.
77
+ * - Whitespace-only lines are dropped.
78
+ * - Non-empty lines are joined with a single space, except the last
79
+ * non-empty line which is appended without a trailing separator.
80
+ *
81
+ * Result: `Your\n bar` → "Your bar"; ` are the design\n system.` →
82
+ * " are the design system." (leading space preserved).
83
+ */
84
+ function normalizeJsxText(raw: string): string {
85
+ const lines = raw.split(/\r\n|\n|\r/);
86
+ let lastNonEmpty = -1;
87
+ for (let i = 0; i < lines.length; i++) {
88
+ if (/[^ \t]/.test(lines[i])) lastNonEmpty = i;
89
+ }
90
+ if (lastNonEmpty < 0) return '';
91
+ let out = '';
92
+ for (let i = 0; i < lines.length; i++) {
93
+ let line = lines[i].replace(/\t/g, ' ');
94
+ if (i !== 0) line = line.replace(/^[ ]+/, '');
95
+ if (i !== lines.length - 1) line = line.replace(/[ ]+$/, '');
96
+ if (!line) continue;
97
+ out += line;
98
+ if (i !== lastNonEmpty) out += ' ';
99
+ }
100
+ return out;
101
+ }
102
+
46
103
  // ============================================================================
47
104
  // Component to HTML Element Mapping
48
105
  // ============================================================================
@@ -55,6 +112,21 @@ const COMPONENT_TO_HTML_MAP: Record<string, string> = {
55
112
  Link: 'a', // next/link
56
113
  };
57
114
 
115
+ // Internal-only marker variant of JsxElement used as a transient sentinel
116
+ // when expanding portal trees: nodes flagged with `__portalSkip` are
117
+ // dropped before the JSX tree is returned to consumers, so the flag
118
+ // never surfaces in scanner output.
119
+ type PortalSkipFlagged = { __portalSkip?: boolean };
120
+
121
+ // Generic expression-evaluation result used by the prop-context resolution path.
122
+ // The scanner threads JS literal values (string | number | boolean | null | array
123
+ // | object) through the AST walk; consumers later branch on `typeof` / `Array.isArray`.
124
+ // Kept as `any` so the call sites can index / access without a manual narrowing
125
+ // dance — the alias documents intent and keeps the literal `any` keyword out of
126
+ // the rest of the file.
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ type ResolvedExpressionValue = any;
129
+
58
130
  // ============================================================================
59
131
  // Scanner Class
60
132
  // ============================================================================
@@ -66,17 +138,39 @@ export class ComponentScanner {
66
138
  // Cache for resolved imported component JSX trees
67
139
  private importedComponentCache: Map<string, { sourceFile: SourceFile; componentName: string }>;
68
140
  // Cache for array values found in source files (for .map() expansion)
69
- private arrayValueCache: Map<string, any[]>;
141
+ private arrayValueCache: Map<string, ResolvedExpressionValue[]>;
142
+ // Cache: component definition node → set of prop names that the
143
+ // component cascades to its children via a `<*.Provider value={{ ... }}>`
144
+ // wrapper. Keyed by Node identity (not name) so two different `Group`
145
+ // functions in unrelated source files don't collide. Empty set means
146
+ // "analyzed, no cascade detected" — still cached to skip the body walk
147
+ // on subsequent invocations of the same component.
148
+ private providerCascadeNamesCache: WeakMap<Node, Set<string>>;
149
+ // Re-entry guard for `resolveLocalIdentifier` — prevents infinite recursion
150
+ // when a local const's initializer references another local const that
151
+ // (directly or transitively) references the first one. Keyed by
152
+ // `${filePath}:${name}` so unrelated identifiers don't block each other.
153
+ private localResolutionStack: Set<string> = new Set();
70
154
 
71
155
  constructor(config: ScannerConfig) {
72
156
  this.config = config;
73
157
  this.project = new Project({
74
158
  tsConfigFilePath: path.resolve(process.cwd(), 'tsconfig.json'),
75
159
  skipAddingFilesFromTsConfig: true,
160
+ // Scanner doesn't need full TS semantic analysis — it operates on
161
+ // the AST shape (`getJsxChildren`, `getOpeningElement`, etc.) and
162
+ // resolves imports by walking ts-morph's source files directly.
163
+ // Default settings load DOM / Node typings + traverse every
164
+ // import for type-checking, which is the lion's share of scan
165
+ // time. Skipping them cuts scan time substantially with no loss
166
+ // of scan correctness (regression suite covers the AST traversal).
167
+ skipFileDependencyResolution: true,
168
+ skipLoadingLibFiles: true,
76
169
  });
77
170
  this.iconImports = new Map();
78
171
  this.importedComponentCache = new Map();
79
172
  this.arrayValueCache = new Map();
173
+ this.providerCascadeNamesCache = new WeakMap();
80
174
  }
81
175
 
82
176
  /**
@@ -105,9 +199,17 @@ export class ComponentScanner {
105
199
  try {
106
200
  const analysis = this.analyzeFile(filePath);
107
201
  if (analysis) {
108
- // Check for co-located story file
109
- const storyPath = filePath.replace(/\.tsx$/, '.stories.tsx');
110
- if (fs.existsSync(storyPath)) {
202
+ // Check for co-located story file — accept either `.stories.tsx`
203
+ // or `.stories.ts`. Storybook's `npx storybook init` for React
204
+ // projects emits `.stories.ts` by default; older Inkbridge
205
+ // versions only matched `.tsx` and silently reported "0 stories".
206
+ // The story parser is JSX-aware (ts-morph) and handles both.
207
+ const storyPathTsx = filePath.replace(/\.tsx$/, '.stories.tsx');
208
+ const storyPathTs = filePath.replace(/\.tsx$/, '.stories.ts');
209
+ const storyPath = fs.existsSync(storyPathTsx)
210
+ ? storyPathTsx
211
+ : (fs.existsSync(storyPathTs) ? storyPathTs : null);
212
+ if (storyPath) {
111
213
  analysis.hasStory = true;
112
214
  try {
113
215
  analysis.stories = this.parseStories(storyPath);
@@ -202,7 +304,6 @@ export class ComponentScanner {
202
304
  */
203
305
  analyzeFile(filePath: string): ComponentAnalysis | null {
204
306
  const sourceFile = this.project.addSourceFileAtPath(filePath);
205
- const fileName = path.basename(filePath, '.tsx');
206
307
 
207
308
  // Check for CVA usage first (most structured)
208
309
  const cvaAnalysis = this.analyzeCVA(sourceFile, filePath);
@@ -232,6 +333,10 @@ export class ComponentScanner {
232
333
  // ===========================================================================
233
334
 
234
335
  private analyzeCVA(sourceFile: SourceFile, filePath: string): CVAComponentAnalysis | null {
336
+ if (this.shouldDeferCvaToCompound(sourceFile, filePath)) {
337
+ return null;
338
+ }
339
+
235
340
  // Find cva() call expressions
236
341
  const cvaCalls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)
237
342
  .filter(call => call.getExpression().getText() === 'cva');
@@ -376,15 +481,20 @@ export class ComponentScanner {
376
481
  // ===========================================================================
377
482
 
378
483
  private analyzeState(sourceFile: SourceFile, filePath: string): StateComponentAnalysis | null {
379
- const allClasses = this.extractAllClassesFromFile(sourceFile);
484
+ const rootClasses = this.getRootClassesForStateAnalysis(sourceFile, filePath);
485
+ const allClasses = rootClasses.length > 0
486
+ ? rootClasses
487
+ : this.extractAllClassesFromFile(sourceFile);
380
488
 
381
489
  if (allClasses.length === 0) {
382
490
  return null;
383
491
  }
384
492
 
385
- // Check if any classes have state modifiers
493
+ // Check if any classes have an OWN state modifier (excludes
494
+ // `group-*:` / `peer-*:` — those are passive reactions to a
495
+ // parent's state, not the component's own variant axis).
386
496
  const hasStateModifiers = allClasses.some(cls =>
387
- STATE_MODIFIERS.some(mod => cls.startsWith(mod))
497
+ OWN_STATE_MODIFIERS.some(mod => cls.startsWith(mod))
388
498
  );
389
499
 
390
500
  if (!hasStateModifiers) {
@@ -403,15 +513,19 @@ export class ComponentScanner {
403
513
  }
404
514
 
405
515
  const states: Record<string, StateInfo> = {};
516
+ const hasDataDisabledState = allClasses.some(cls => cls.startsWith('data-[disabled]:') || cls.startsWith('group-data-[disabled]:'));
517
+ const hasNativeDisabledState = allClasses.some(cls => cls.startsWith('disabled:') || cls.startsWith('peer-disabled:'));
518
+ const hasDataCheckedState = allClasses.some(cls => cls.startsWith('data-[checked]:') || cls.startsWith('data-[state=checked]:') || cls.startsWith('group-data-[checked]:'));
519
+ const hasNativeCheckedState = allClasses.some(cls => cls.startsWith('checked:'));
406
520
 
407
521
  for (const [stateName, classes] of Object.entries(groupedClasses)) {
408
522
  let trigger = '';
409
523
  if (stateName === 'hover') trigger = 'hover:';
410
524
  else if (stateName === 'focus') trigger = 'focus-visible:';
411
- else if (stateName === 'disabled') trigger = 'disabled:';
525
+ else if (stateName === 'disabled') trigger = hasDataDisabledState ? 'data-[disabled]:' : (hasNativeDisabledState ? 'disabled:' : 'disabled:');
412
526
  else if (stateName === 'error') trigger = 'aria-invalid:';
413
527
  else if (stateName === 'active') trigger = 'active:';
414
- else if (stateName === 'checked') trigger = 'data-[state=checked]:';
528
+ else if (stateName === 'checked') trigger = hasDataCheckedState ? 'data-[checked]:' : (hasNativeCheckedState ? 'checked:' : 'data-[checked]:');
415
529
  else if (stateName === 'open') trigger = 'data-[state=open]:';
416
530
 
417
531
  states[stateName] = {
@@ -431,6 +545,25 @@ export class ComponentScanner {
431
545
  };
432
546
  }
433
547
 
548
+ private getRootClassesForStateAnalysis(sourceFile: SourceFile, filePath: string): string[] {
549
+ const nameCandidates = this.getComponentNameCandidates(filePath);
550
+ let jsxTree: JsxNode | null = null;
551
+ for (const candidate of nameCandidates) {
552
+ jsxTree = this.extractComponentJsxTree(sourceFile, candidate);
553
+ if (jsxTree) break;
554
+ }
555
+ if (!jsxTree || jsxTree.type !== 'element') {
556
+ return [];
557
+ }
558
+
559
+ const root = jsxTree as JsxElement;
560
+ const className = root.props && typeof root.props.className === 'string'
561
+ ? root.props.className
562
+ : '';
563
+ if (!className) return [];
564
+ return className.split(/\s+/).filter(Boolean);
565
+ }
566
+
434
567
  private hasRootStateModifier(sourceFile: SourceFile, filePath: string): boolean {
435
568
  const nameCandidates = this.getComponentNameCandidates(filePath);
436
569
  let jsxTree: JsxNode | null = null;
@@ -447,9 +580,14 @@ export class ComponentScanner {
447
580
  ? root.props.className
448
581
  : '';
449
582
  if (!className) {
450
- // Wrapper components (root tag is another component like <Hero>) should not
451
- // be marked as state components from nested hover/focus classes.
452
- if (/^[A-Z]/.test(root.tagName)) {
583
+ // Wrapper components (root tag is another component like <Hero>) and
584
+ // fragment wrappers (`<>...</>`, emitted as an element with empty
585
+ // tagName) are not state primitives — any nested hover/focus class
586
+ // belongs to a deeply nested element (e.g. a chip with hover styles
587
+ // inside a marketing section), not to the component itself. This
588
+ // prevents marketing molecules like HeroSection from being wrongly
589
+ // classified as `state` and rendered as a symbol/state matrix.
590
+ if (root.tagName === '' || /^[A-Z]/.test(root.tagName)) {
453
591
  return false;
454
592
  }
455
593
  // Keep state analysis for primitives that use dynamic className builders
@@ -459,7 +597,7 @@ export class ComponentScanner {
459
597
 
460
598
  const classes = className.split(/\s+/).filter(Boolean);
461
599
  for (const cls of classes) {
462
- for (const modifier of STATE_MODIFIERS) {
600
+ for (const modifier of OWN_STATE_MODIFIERS) {
463
601
  if (cls.startsWith(modifier)) {
464
602
  return true;
465
603
  }
@@ -554,11 +692,77 @@ export class ComponentScanner {
554
692
 
555
693
  /**
556
694
  * Extract JSX tree from a function body (arrow function, function expression, or function declaration).
695
+ * `propsContext` is the outer-scope context. The function's own parameters
696
+ * (e.g. `field` in `<FormField render={({ field }) => ...} />`) shadow but
697
+ * the rest of the outer scope is still resolvable via the parent's
698
+ * propsContext — pass it through so closure variables like `errors` keep
699
+ * resolving inside the callback body.
700
+ */
701
+ /**
702
+ * Build a `propsContext` map from a story's `args` object. Args
703
+ * extracted from the source code are always strings (the scanner
704
+ * keeps `args.foo = "0.1482"`), but JSX expressions like
705
+ * `{rewardSol.toFixed(4)}` need a numeric receiver to evaluate. Try
706
+ * to parse each value as a number first; fall back to the literal
707
+ * string for non-numeric args (`label = "Click me"`). Booleans are
708
+ * matched by exact "true" / "false".
557
709
  */
710
+ private buildArgsPropsContext(args: Record<string, string>): Map<string, ResolvedExpressionValue> {
711
+ const out = new Map<string, ResolvedExpressionValue>();
712
+ for (const key in args) {
713
+ if (!Object.prototype.hasOwnProperty.call(args, key)) continue;
714
+ const raw = args[key];
715
+ if (raw === 'true') { out.set(key, true); continue; }
716
+ if (raw === 'false') { out.set(key, false); continue; }
717
+ if (raw !== '' && !isNaN(Number(raw))) { out.set(key, Number(raw)); continue; }
718
+ out.set(key, raw);
719
+ }
720
+ return out;
721
+ }
722
+
723
+ /**
724
+ * Open the imported component's source file (already discovered via
725
+ * `relativeImports`), find its function declaration, and walk its
726
+ * JSX body with `argsContext` so per-story expressions like
727
+ * `{rewardSol.toFixed(4)}` resolve to concrete text. Returns `null`
728
+ * when the file can't be loaded or no exported component matches.
729
+ */
730
+ private buildStoryJsxTreeFromImportedComponent(
731
+ importedPath: string,
732
+ componentName: string,
733
+ argsContext: Map<string, ResolvedExpressionValue>,
734
+ ): JsxNode | null {
735
+ let sourceFile: SourceFile | null = null;
736
+ try {
737
+ sourceFile = this.project.addSourceFileAtPathIfExists(importedPath) || null;
738
+ } catch (_e) {
739
+ sourceFile = null;
740
+ }
741
+ if (!sourceFile) return null;
742
+
743
+ const localComponents = new Map<string, Node>();
744
+ for (const func of sourceFile.getFunctions()) {
745
+ const name = func.getName();
746
+ if (name) localComponents.set(name, func);
747
+ }
748
+ for (const varStmt of sourceFile.getVariableStatements()) {
749
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
750
+ const name = decl.getName();
751
+ const init = decl.getInitializer();
752
+ if (init) localComponents.set(name, init);
753
+ }
754
+ }
755
+ const relativeImports = this.collectComponentImports(sourceFile);
756
+ const compNode = localComponents.get(componentName);
757
+ if (!compNode) return null;
758
+ return this.extractJsxTreeFromFunctionBody(compNode, relativeImports, localComponents, argsContext);
759
+ }
760
+
558
761
  private extractJsxTreeFromFunctionBody(
559
762
  funcNode: Node,
560
763
  relativeImports: Map<string, string>,
561
- localComponents: Map<string, Node>
764
+ localComponents: Map<string, Node>,
765
+ propsContext: Map<string, ResolvedExpressionValue> = new Map()
562
766
  ): JsxNode | null {
563
767
  const jsxElements = funcNode.getDescendantsOfKind(SyntaxKind.JsxElement);
564
768
  const selfClosing = funcNode.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
@@ -574,7 +778,7 @@ export class ComponentScanner {
574
778
  }
575
779
  }
576
780
 
577
- return this.buildJsxTree(rootJsx, localComponents, relativeImports);
781
+ return this.buildJsxTree(rootJsx, localComponents, relativeImports, propsContext);
578
782
  }
579
783
 
580
784
  // ===========================================================================
@@ -637,12 +841,19 @@ export class ComponentScanner {
637
841
  }
638
842
  }
639
843
 
640
- // Find the meta's component name (from `component: Button`)
844
+ // Find the meta's component name (from `component: Button`).
845
+ // Unwrap `as` and `satisfies` casts — Storybook templates ship with
846
+ // `const meta = { ... } satisfies Meta<typeof X>`, which would
847
+ // otherwise hide the ObjectLiteralExpression we walk to find
848
+ // `component`. Missing this meant args-only stories synthesised no
849
+ // instance, so "simple"-typed components (e.g. a plain function
850
+ // component with props rendered via `args`) showed up empty on the
851
+ // generated design system page.
641
852
  let metaComponentName: string | undefined;
642
853
  for (const varStmt of sourceFile.getVariableStatements()) {
643
854
  for (const decl of varStmt.getDeclarationList().getDeclarations()) {
644
855
  if (decl.getName() === 'meta') {
645
- const init = decl.getInitializer();
856
+ const init = this.unwrapStaticValueExpression(decl.getInitializer()) || decl.getInitializer();
646
857
  if (init && Node.isObjectLiteralExpression(init)) {
647
858
  const compProp = init.getProperty('component');
648
859
  if (compProp && Node.isPropertyAssignment(compProp)) {
@@ -663,7 +874,8 @@ export class ComponentScanner {
663
874
  const storyName = decl.getName();
664
875
  if (storyName === 'meta' || storyName === 'default') continue;
665
876
 
666
- const init = decl.getInitializer();
877
+ // Unwrap `as`/`satisfies` casts (same reason as `meta` above).
878
+ const init = this.unwrapStaticValueExpression(decl.getInitializer()) || decl.getInitializer();
667
879
  if (!init || !Node.isObjectLiteralExpression(init)) continue;
668
880
 
669
881
  const story: StoryInfo = {
@@ -699,21 +911,70 @@ export class ComponentScanner {
699
911
  delete instance.props.children;
700
912
  story.instances.push(instance);
701
913
 
702
- // For args-based stories, build jsxTree from the local component definition
703
- const localCompBody = localComponents.get(metaComponentName);
704
- if (localCompBody && !story.jsxTree) {
705
- // Find the root JSX element within the component body
706
- // (handles arrow functions like `() => (<div>...</div>)`)
707
- const jsxElements = localCompBody.getDescendantsOfKind(SyntaxKind.JsxElement);
708
- const selfClosingElements = localCompBody.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
709
- const allJsx: Node[] = [...jsxElements, ...selfClosingElements];
710
- if (allJsx.length > 0) {
711
- // Find root (fewest ancestors)
712
- let root = allJsx[0];
713
- for (const el of allJsx) {
714
- if (el.getAncestors().length < root.getAncestors().length) root = el;
914
+ // For args-based stories, build a story-specific jsxTree
915
+ // with the story's args plumbed through as `propsContext`.
916
+ // This lets JSX expressions like `{rewardSol.toFixed(4)}`
917
+ // resolve to concrete text in the rendered design system
918
+ // page (previously they were silently dropped, leaving the
919
+ // component looking empty even when args were supplied).
920
+ //
921
+ // Two source-locations to handle:
922
+ // 1. `localComponents.get(name)` component defined in
923
+ // the same story file (unusual, but supported).
924
+ // 2. The imported component's source file (typical
925
+ // shadcn / project layout: `<Component>.stories.tsx`
926
+ // next to `<Component>.tsx`). Resolved via
927
+ // `relativeImports`.
928
+ if (!story.jsxTree) {
929
+ const argsContext = this.buildArgsPropsContext(args);
930
+ // Walk the args initializer AST so prop values that are
931
+ // arrays, objects, or identifiers referencing such
932
+ // (e.g. `blocks: mockBlocks`) resolve to their real
933
+ // structure — not the string-coerced text. Without
934
+ // this an args-based BlockTable story would see
935
+ // `blocks = "mockBlocks"` and the .map call inside the
936
+ // component couldn't resolve the iteration source.
937
+ for (const prop of argsInit.getProperties()) {
938
+ if (!Node.isPropertyAssignment(prop)) continue;
939
+ const propInit = prop.getInitializer();
940
+ if (!propInit) continue;
941
+ const resolved = this.resolveExpressionValue(propInit, new Map());
942
+ if (resolved !== undefined && resolved !== null) {
943
+ argsContext.set(prop.getName(), resolved);
944
+ }
945
+ }
946
+ const localCompBody = localComponents.get(metaComponentName);
947
+ if (localCompBody) {
948
+ story.jsxTree = this.extractJsxTreeFromFunctionBody(
949
+ localCompBody, relativeImports, localComponents, argsContext,
950
+ ) || undefined;
951
+ } else {
952
+ const importedPath = relativeImports.get(metaComponentName);
953
+ if (importedPath) {
954
+ story.jsxTree = this.buildStoryJsxTreeFromImportedComponent(
955
+ importedPath, metaComponentName, argsContext,
956
+ ) || undefined;
715
957
  }
716
- story.jsxTree = this.buildJsxTree(root, localComponents, relativeImports);
958
+ }
959
+
960
+ // Inject `args.children` as a text node when the
961
+ // built tree has no element/text children of its own.
962
+ // Many shadcn primitives use `<Primitive {...props} />`
963
+ // to forward children, so the scanner sees a bare
964
+ // element — the children value lives in the args
965
+ // object but never makes it into the rendered tree.
966
+ // Without this an args-based `<Label>Email</Label>`
967
+ // story renders as an empty Label frame in Figma.
968
+ if (
969
+ story.jsxTree
970
+ && story.jsxTree.type === 'element'
971
+ && (!story.jsxTree.children || story.jsxTree.children.length === 0)
972
+ && typeof args.children === 'string'
973
+ && args.children.length > 0
974
+ ) {
975
+ story.jsxTree.children = [
976
+ { type: 'text', content: args.children },
977
+ ];
717
978
  }
718
979
  }
719
980
  }
@@ -786,14 +1047,14 @@ export class ComponentScanner {
786
1047
  story.jsxTree = this.buildJsxTree(root, localComponents, relativeImports);
787
1048
 
788
1049
  // Extract layout classes from root element (any tag, not just div)
789
- let attrs: any[] = [];
1050
+ let attrs: JsxAttribute[] = [];
790
1051
  if (Node.isJsxElement(root)) {
791
1052
  attrs = root.getOpeningElement().getDescendantsOfKind(SyntaxKind.JsxAttribute);
792
1053
  } else if (Node.isJsxSelfClosingElement(root)) {
793
1054
  attrs = root.getDescendantsOfKind(SyntaxKind.JsxAttribute);
794
1055
  }
795
1056
  const classAttr = attrs.find(a => {
796
- const nameNode = (a as any).getNameNode?.();
1057
+ const nameNode = a.getNameNode();
797
1058
  return nameNode ? nameNode.getText() === 'className' : false;
798
1059
  });
799
1060
  if (classAttr) {
@@ -812,13 +1073,23 @@ export class ComponentScanner {
812
1073
  el.getOpeningElement(),
813
1074
  tagName
814
1075
  );
815
- const textChildren = el.getJsxChildren()
816
- .filter(child => Node.isJsxText(child))
817
- .map(child => decodeHtmlEntities(child.getText().trim()))
818
- .filter(Boolean)
819
- .join(' ');
820
- if (textChildren) {
821
- instance.children = textChildren;
1076
+ const structuredChildren = this.extractStructuredChildrenFromJsxElement(
1077
+ el,
1078
+ localComponents,
1079
+ relativeImports,
1080
+ new Map()
1081
+ );
1082
+ if (structuredChildren.length > 0) {
1083
+ instance.props.__jsxChildren = structuredChildren;
1084
+ }
1085
+ const literalChildren = this.extractLiteralChildrenText(el.getJsxChildren());
1086
+ if (literalChildren) {
1087
+ instance.children = literalChildren;
1088
+ } else {
1089
+ const contextualLabel = this.extractContextualInstanceLabel(el);
1090
+ if (contextualLabel) {
1091
+ instance.children = contextualLabel;
1092
+ }
822
1093
  }
823
1094
  story.instances.push(instance);
824
1095
  }
@@ -828,11 +1099,226 @@ export class ComponentScanner {
828
1099
  const tagName = el.getTagNameNode().getText();
829
1100
  if (importedComponents.has(tagName)) {
830
1101
  const instance = this.extractInstanceFromSelfClosing(el, tagName);
1102
+ const contextualLabel = this.extractContextualInstanceLabel(el);
1103
+ if (contextualLabel) {
1104
+ instance.children = contextualLabel;
1105
+ }
831
1106
  story.instances.push(instance);
832
1107
  }
833
1108
  }
834
1109
  }
835
1110
 
1111
+ private extractStructuredChildrenFromJsxElement(
1112
+ element: Node,
1113
+ localComponents: Map<string, Node>,
1114
+ relativeImports: Map<string, string>,
1115
+ propsContext: Map<string, ResolvedExpressionValue> = new Map()
1116
+ ): JsxNode[] {
1117
+ if (!Node.isJsxElement(element)) return [];
1118
+
1119
+ const out: JsxNode[] = [];
1120
+ for (const child of element.getJsxChildren()) {
1121
+ if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
1122
+ const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext);
1123
+ if ((childNode as JsxNode & PortalSkipFlagged).__portalSkip) continue;
1124
+ out.push(childNode);
1125
+ continue;
1126
+ }
1127
+
1128
+ if (Node.isJsxText(child)) {
1129
+ // JSX collapses internal whitespace (including newlines) to a single
1130
+ // space — matching Babel / React's transform. Naive `.trim()` would
1131
+ // leave a literal newline embedded between e.g. "design\nsystem",
1132
+ // which the Figma plugin renders as a hard line break. Naive
1133
+ // `.replace(/\s+/g, ' ').trim()` drops boundary whitespace and
1134
+ // breaks inter-segment spacing for `Your <span>foo</span> bar`.
1135
+ const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
1136
+ if (text) out.push({ type: 'text', content: text });
1137
+ continue;
1138
+ }
1139
+
1140
+ if (!Node.isJsxExpression(child)) continue;
1141
+ const expr = child.getExpression();
1142
+ if (!expr) continue;
1143
+
1144
+ if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '??') {
1145
+ let chosen: Node = expr.getLeft();
1146
+ const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
1147
+ if (leftValue === undefined || leftValue === null) {
1148
+ chosen = expr.getRight();
1149
+ }
1150
+ if (Node.isParenthesizedExpression(chosen)) {
1151
+ chosen = chosen.getExpression();
1152
+ }
1153
+ if (Node.isJsxElement(chosen) || Node.isJsxSelfClosingElement(chosen)) {
1154
+ const chosenNode = this.buildJsxTree(chosen, localComponents, relativeImports, propsContext);
1155
+ if (!(chosenNode as JsxNode & PortalSkipFlagged).__portalSkip) out.push(chosenNode);
1156
+ continue;
1157
+ }
1158
+ const resolvedChosen = this.resolveExpressionValue(chosen, propsContext);
1159
+ if (resolvedChosen !== undefined && resolvedChosen !== null) {
1160
+ this.pushResolvedPropValue(out, resolvedChosen);
1161
+ continue;
1162
+ }
1163
+ }
1164
+
1165
+ if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
1166
+ out.push({ type: 'text', content: decodeHtmlEntities(expr.getLiteralValue()) });
1167
+ continue;
1168
+ }
1169
+
1170
+ if (Node.isCallExpression(expr)) {
1171
+ const callExpr = expr.getExpression();
1172
+ if (Node.isPropertyAccessExpression(callExpr) && callExpr.getName() === 'map') {
1173
+ const expandedChildren = this.expandMapCall(
1174
+ expr,
1175
+ element.getSourceFile(),
1176
+ localComponents,
1177
+ relativeImports,
1178
+ propsContext
1179
+ );
1180
+ if (expandedChildren.length > 0) out.push(...expandedChildren);
1181
+ continue;
1182
+ }
1183
+ }
1184
+
1185
+ if (Node.isIdentifier(expr) && propsContext.has(expr.getText())) {
1186
+ this.pushResolvedPropValue(out, propsContext.get(expr.getText()));
1187
+ continue;
1188
+ }
1189
+
1190
+ if (Node.isPropertyAccessExpression(expr)) {
1191
+ const objExpr = expr.getExpression();
1192
+ const propName = expr.getName();
1193
+ if (Node.isIdentifier(objExpr) && propsContext.has(objExpr.getText())) {
1194
+ const obj = propsContext.get(objExpr.getText());
1195
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj) && propName in obj) {
1196
+ this.pushResolvedPropValue(out, obj[propName]);
1197
+ continue;
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ const resolved = this.resolveExpressionValue(expr, propsContext);
1203
+ if (resolved !== undefined) {
1204
+ this.pushResolvedPropValue(out, resolved);
1205
+ continue;
1206
+ }
1207
+
1208
+ const exprText = expr.getText();
1209
+ const isConditional = exprText.includes('?') || exprText.includes('&&') || exprText.includes('||');
1210
+ const isFunction = exprText.includes('=>') || exprText.includes('function');
1211
+ if (exprText && !isConditional && !isFunction) {
1212
+ out.push({ type: 'text', content: `{${exprText}}` });
1213
+ }
1214
+ }
1215
+ return out;
1216
+ }
1217
+
1218
+ private extractLiteralChildrenText(children: Node[]): string {
1219
+ if (!Array.isArray(children) || children.length === 0) return '';
1220
+ const parts: string[] = [];
1221
+ for (const child of children) {
1222
+ const text = this.extractLiteralTextFromJsxNode(child, { includeComponentSubtrees: false });
1223
+ if (text) parts.push(text);
1224
+ }
1225
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
1226
+ }
1227
+
1228
+ private extractContextualInstanceLabel(node: Node): string | null {
1229
+ const parent = node.getParent();
1230
+ if (!parent || !Node.isJsxElement(parent)) return null;
1231
+
1232
+ const parentTagName = parent.getOpeningElement().getTagNameNode().getText().toLowerCase();
1233
+ const jsxChildren = parent.getJsxChildren();
1234
+ const parts: string[] = [];
1235
+ let siblingComponentCount = 0;
1236
+
1237
+ for (const child of jsxChildren) {
1238
+ if (child.getPos() === node.getPos() && child.getEnd() === node.getEnd()) continue;
1239
+
1240
+ if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
1241
+ const tagName = Node.isJsxElement(child)
1242
+ ? child.getOpeningElement().getTagNameNode().getText()
1243
+ : child.getTagNameNode().getText();
1244
+ const isComponentTag = /^[A-Z]/.test(tagName) || tagName.includes('.');
1245
+ if (isComponentTag) {
1246
+ siblingComponentCount++;
1247
+ continue;
1248
+ }
1249
+ const nestedText = this.extractLiteralTextFromJsxNode(child);
1250
+ if (nestedText) parts.push(nestedText);
1251
+ continue;
1252
+ }
1253
+
1254
+ if (Node.isJsxText(child)) {
1255
+ const text = decodeHtmlEntities(child.getText().replace(/\s+/g, ' ').trim());
1256
+ if (text) parts.push(text);
1257
+ continue;
1258
+ }
1259
+
1260
+ if (Node.isJsxExpression(child)) {
1261
+ const expr = child.getExpression();
1262
+ if (!expr) continue;
1263
+ if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
1264
+ const text = decodeHtmlEntities(expr.getLiteralValue().trim());
1265
+ if (text) parts.push(text);
1266
+ continue;
1267
+ }
1268
+ const resolved = this.resolveExpressionValue(expr, new Map());
1269
+ if (resolved == null) continue;
1270
+ const text = decodeHtmlEntities(String(resolved).replace(/\s+/g, ' ').trim());
1271
+ if (text) parts.push(text);
1272
+ }
1273
+ }
1274
+
1275
+ const label = parts.join(' ').replace(/\s+/g, ' ').trim();
1276
+ if (!label) return null;
1277
+
1278
+ if (parentTagName === 'label') return label;
1279
+ if (siblingComponentCount === 0) return label;
1280
+ return null;
1281
+ }
1282
+
1283
+ private extractLiteralTextFromJsxNode(
1284
+ node: Node,
1285
+ options?: { includeComponentSubtrees?: boolean }
1286
+ ): string {
1287
+ if (Node.isJsxText(node)) {
1288
+ return decodeHtmlEntities(node.getText().replace(/\s+/g, ' ').trim());
1289
+ }
1290
+
1291
+ if (Node.isJsxExpression(node)) {
1292
+ const expr = node.getExpression();
1293
+ if (!expr) return '';
1294
+ if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
1295
+ return decodeHtmlEntities(expr.getLiteralValue().replace(/\s+/g, ' ').trim());
1296
+ }
1297
+ const resolved = this.resolveExpressionValue(expr, new Map());
1298
+ if (resolved == null) return '';
1299
+ return decodeHtmlEntities(String(resolved).replace(/\s+/g, ' ').trim());
1300
+ }
1301
+
1302
+ if (Node.isJsxElement(node)) {
1303
+ const includeComponentSubtrees = options && options.includeComponentSubtrees === false ? false : true;
1304
+ if (!includeComponentSubtrees) {
1305
+ const tagName = node.getOpeningElement().getTagNameNode().getText();
1306
+ if (/^[A-Z]/.test(tagName)) {
1307
+ return '';
1308
+ }
1309
+ }
1310
+ const parts: string[] = [];
1311
+ const children = node.getJsxChildren();
1312
+ for (const child of children) {
1313
+ const text = this.extractLiteralTextFromJsxNode(child, options);
1314
+ if (text) parts.push(text);
1315
+ }
1316
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
1317
+ }
1318
+
1319
+ return '';
1320
+ }
1321
+
836
1322
  private extractDecoratorWrappers(
837
1323
  decoratorsProp: Node | undefined
838
1324
  ): Array<{ tagName: string; classes: string[] }> {
@@ -906,7 +1392,7 @@ export class ComponentScanner {
906
1392
 
907
1393
  private extractLayoutClassesFromRoot(root: Node): { tagName: string; classes: string[] } | null {
908
1394
  let tagName = '';
909
- let attrs: any[] = [];
1395
+ let attrs: JsxAttribute[] = [];
910
1396
  if (Node.isJsxElement(root)) {
911
1397
  tagName = root.getOpeningElement().getTagNameNode().getText();
912
1398
  attrs = root.getOpeningElement().getDescendantsOfKind(SyntaxKind.JsxAttribute);
@@ -916,7 +1402,7 @@ export class ComponentScanner {
916
1402
  }
917
1403
  if (!ComponentScanner.DECORATOR_BLOCK_TAGS.has(tagName.toLowerCase())) return null;
918
1404
  const classAttr = attrs.find(a => {
919
- const nameNode = (a as any).getNameNode?.();
1405
+ const nameNode = a.getNameNode();
920
1406
  return nameNode ? nameNode.getText() === 'className' : false;
921
1407
  });
922
1408
  if (!classAttr) return null;
@@ -933,7 +1419,11 @@ export class ComponentScanner {
933
1419
 
934
1420
  if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
935
1421
  importBasePath = path.resolve(baseDir, moduleSpec);
936
- } else if (moduleSpec.startsWith('~/')) {
1422
+ } else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
1423
+ // Both ~/* and @/* are common conventions for the `src/*` path alias in
1424
+ // Next.js projects. inkbridge uses ~/, inkbridge-starter uses @/. Treat
1425
+ // them identically so the scanner resolves component imports in either
1426
+ // layout.
937
1427
  importBasePath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
938
1428
  }
939
1429
 
@@ -993,7 +1483,8 @@ export class ComponentScanner {
993
1483
  node: Node,
994
1484
  localComponents: Map<string, Node>,
995
1485
  relativeImports: Map<string, string> = new Map(),
996
- propsContext: Map<string, any> = new Map()
1486
+ propsContext: Map<string, ResolvedExpressionValue> = new Map(),
1487
+ providerStack: ReadonlyArray<Record<string, ResolvedExpressionValue>> = []
997
1488
  ): JsxNode {
998
1489
  if (Node.isJsxElement(node)) {
999
1490
  const rawTagName = node.getOpeningElement().getTagNameNode().getText();
@@ -1002,40 +1493,116 @@ export class ComponentScanner {
1002
1493
  const props = this.extractPropsFromNode(node.getOpeningElement(), rawTagName, propsContext).props;
1003
1494
  const children: JsxNode[] = [];
1004
1495
 
1496
+ // If this is a component invocation whose body wraps children in
1497
+ // `<*.Provider value={{ ... }}>`, pre-resolve the cascade values
1498
+ // from this invocation's props and push them onto the providerStack
1499
+ // so descendants pick them up as defaults. This statically models
1500
+ // the React Context cascade pattern shadcn uses for `ToggleGroup`'s
1501
+ // variant/size, `RadioGroup`'s value, etc.
1502
+ let childProviderStack: ReadonlyArray<Record<string, ResolvedExpressionValue>> = providerStack;
1503
+ if (/^[A-Z]/.test(rawTagName)) {
1504
+ const cascadeNames = this.detectProviderCascadeNamesForComponent(rawTagName, localComponents, relativeImports);
1505
+ if (cascadeNames.size > 0) {
1506
+ const earlyEvaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
1507
+ const cascadeValues: Record<string, ResolvedExpressionValue> = {};
1508
+ let hasAny = false;
1509
+ for (const name of cascadeNames) {
1510
+ if (earlyEvaluatedProps[name] !== undefined) {
1511
+ cascadeValues[name] = earlyEvaluatedProps[name];
1512
+ hasAny = true;
1513
+ }
1514
+ }
1515
+ if (hasAny) {
1516
+ childProviderStack = providerStack.concat([cascadeValues]);
1517
+ }
1518
+ }
1519
+ }
1520
+
1005
1521
  for (const child of node.getJsxChildren()) {
1006
1522
  if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
1007
- const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext);
1008
- if ((childNode as any).__portalSkip) continue;
1009
- children.push(childNode);
1523
+ const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext, childProviderStack);
1524
+ if ((childNode as JsxNode & PortalSkipFlagged).__portalSkip) continue;
1525
+ this.pushFlatteningTransparentProvider(children, childNode);
1010
1526
  } else if (Node.isJsxText(child)) {
1011
- const text = decodeHtmlEntities(child.getText().trim());
1527
+ const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
1012
1528
  if (text) {
1013
1529
  children.push({ type: 'text', content: text });
1014
1530
  }
1015
1531
  } else if (Node.isJsxExpression(child)) {
1016
1532
  const expr = child.getExpression();
1017
- if (expr && Node.isStringLiteral(expr)) {
1533
+ if (expr && Node.isBinaryExpression(expr)) {
1534
+ const op = expr.getOperatorToken().getText();
1535
+ if (op === '??') {
1536
+ const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
1537
+ const chosen = (leftValue === undefined || leftValue === null)
1538
+ ? expr.getRight()
1539
+ : expr.getLeft();
1540
+ this.pushChosenJsxBranch(chosen, children, localComponents, relativeImports, propsContext);
1541
+ } else if (op === '&&') {
1542
+ // Short-circuit: render the right side only when the left is
1543
+ // statically truthy. If the left can't be resolved, skip — the
1544
+ // design-system render shouldn't speculate about runtime state.
1545
+ const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
1546
+ if (this.isExpressionTruthy(leftValue)) {
1547
+ this.pushChosenJsxBranch(expr.getRight(), children, localComponents, relativeImports, propsContext);
1548
+ }
1549
+ } else if (op === '||') {
1550
+ const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
1551
+ if (this.isExpressionTruthy(leftValue)) {
1552
+ this.pushChosenJsxBranch(expr.getLeft(), children, localComponents, relativeImports, propsContext);
1553
+ } else if (leftValue !== undefined) {
1554
+ this.pushChosenJsxBranch(expr.getRight(), children, localComponents, relativeImports, propsContext);
1555
+ }
1556
+ // Unresolvable left → skip
1557
+ }
1558
+ } else if (expr && Node.isConditionalExpression(expr)) {
1559
+ // Ternary `cond ? a : b` — pick a branch when the condition is
1560
+ // statically resolvable. Same conservative policy as `&&`/`||`:
1561
+ // unresolvable condition skips both branches.
1562
+ const condValue = this.resolveExpressionValue(expr.getCondition(), propsContext);
1563
+ if (condValue !== undefined) {
1564
+ const chosen = this.isExpressionTruthy(condValue)
1565
+ ? expr.getWhenTrue()
1566
+ : expr.getWhenFalse();
1567
+ this.pushChosenJsxBranch(chosen, children, localComponents, relativeImports, propsContext);
1568
+ }
1569
+ } else if (expr && Node.isStringLiteral(expr)) {
1018
1570
  children.push({ type: 'text', content: expr.getLiteralValue() });
1019
1571
  } else if (expr && Node.isCallExpression(expr)) {
1020
- // Check if this is a .map() call
1572
+ // .map() calls expand into multiple children; every other
1573
+ // CallExpression (e.g. `{rewardSol.toFixed(4)}`) is resolved
1574
+ // via `resolveExpressionValue` which knows how to evaluate
1575
+ // common Number/String methods against the prop values in
1576
+ // `propsContext`. Without this fallback the expression was
1577
+ // silently dropped and args-based stories rendered the
1578
+ // component shell with the runtime values missing.
1021
1579
  const callExpr = expr.getExpression();
1022
1580
  if (Node.isPropertyAccessExpression(callExpr) && callExpr.getName() === 'map') {
1023
- // Try to expand the .map() call, passing propsContext for array resolution
1024
1581
  const expandedChildren = this.expandMapCall(expr, node.getSourceFile(), localComponents, relativeImports, propsContext);
1025
1582
  if (expandedChildren.length > 0) {
1026
1583
  children.push(...expandedChildren);
1027
1584
  }
1585
+ } else {
1586
+ const resolved = this.resolveExpressionValue(expr, propsContext);
1587
+ if (resolved !== undefined && resolved !== null) {
1588
+ this.pushResolvedPropValue(children, resolved);
1589
+ }
1028
1590
  }
1029
1591
  } else if (expr && Node.isIdentifier(expr) && propsContext.has(expr.getText())) {
1030
1592
  this.pushResolvedPropValue(children, propsContext.get(expr.getText()));
1031
1593
  } else if (expr && Node.isIdentifier(expr)) {
1032
- // Try to resolve as a module-level const string (e.g. const LONG = "...")
1033
- const varDecl = node.getSourceFile().getVariableDeclaration(expr.getText());
1034
- if (varDecl) {
1035
- const init = varDecl.getInitializer();
1036
- if (init && Node.isStringLiteral(init)) {
1037
- children.push({ type: 'text', content: init.getLiteralValue() });
1038
- }
1594
+ // Delegate to resolveExpressionValue so function-body-local `const`
1595
+ // declarations (e.g. `const statusText = sourceLabel === "db" ? ...`)
1596
+ // resolve the same way they do for className identifiers. Without
1597
+ // this, a JSX text child like `<span>{statusText}</span>` falls
1598
+ // through to the "unresolved" branch and renders nothing — even
1599
+ // when the local const's initializer would resolve fine against
1600
+ // the current propsContext. Same scope-walk fix as the className
1601
+ // path; the bug only shows on JSX *children* because the children
1602
+ // pipeline used a custom (module-level-only) lookup here.
1603
+ const resolved = this.resolveExpressionValue(expr, propsContext);
1604
+ if (resolved !== undefined && resolved !== null) {
1605
+ this.pushResolvedPropValue(children, resolved);
1039
1606
  }
1040
1607
  } else if (expr && Node.isPropertyAccessExpression(expr)) {
1041
1608
  // Resolve {plan.name} style expressions when `plan` is an object in propsContext
@@ -1060,12 +1627,87 @@ export class ComponentScanner {
1060
1627
  }
1061
1628
  }
1062
1629
 
1630
+ // Selection-match pressed-state injection: when an element has a
1631
+ // `defaultValue` (or `value`) prop containing an array or string,
1632
+ // mark each child whose own `value` prop matches with
1633
+ // `defaultPressed="true"`. The variant engine's data-pressed alias
1634
+ // then activates `data-[pressed]:*` CSS at render time. Mirrors
1635
+ // base-ui's ToggleGroup / RadioGroup runtime behaviour, which
1636
+ // compares the group's value(s) against each item's value and
1637
+ // pushes `data-pressed`/`data-checked` to the matching items.
1638
+ //
1639
+ // Only fires when defaultValue/value is a parsed array or string —
1640
+ // i.e. the scanner could statically resolve the prop. Dynamic
1641
+ // values stay unhandled (acceptable for design-system previews).
1642
+ const selectionDefault =
1643
+ (props as Record<string, unknown>).defaultValue
1644
+ ?? (props as Record<string, unknown>).value;
1645
+ if (selectionDefault !== undefined && selectionDefault !== null) {
1646
+ const acceptableArray = Array.isArray(selectionDefault)
1647
+ ? selectionDefault.map((v) => String(v))
1648
+ : (typeof selectionDefault === 'string' || typeof selectionDefault === 'number')
1649
+ ? [String(selectionDefault)]
1650
+ : null;
1651
+ if (acceptableArray && acceptableArray.length > 0) {
1652
+ for (let i = 0; i < children.length; i++) {
1653
+ const child = children[i];
1654
+ if (!child || child.type !== 'element' || !child.props) continue;
1655
+ const childValue = (child.props as Record<string, unknown>).value;
1656
+ if (childValue === undefined || childValue === null) continue;
1657
+ if (acceptableArray.indexOf(String(childValue)) === -1) continue;
1658
+ if (child.props.defaultPressed !== undefined || child.props['data-pressed'] !== undefined) continue;
1659
+ child.props.defaultPressed = 'true';
1660
+ }
1661
+ }
1662
+ }
1663
+
1664
+ // React Context.Provider is a non-rendering wrapper (it only sets a
1665
+ // context value). Unwrap to its single child so the plugin doesn't
1666
+ // emit an empty frame around the actual content. Common after
1667
+ // expanding shadcn primitives like <FormItem> →
1668
+ // <FormItemContext.Provider><div/></FormItemContext.Provider>.
1669
+ if (rawTagName.endsWith('.Provider') && children.length === 1 && children[0].type === 'element') {
1670
+ return children[0];
1671
+ }
1672
+
1063
1673
  if (/^[A-Z]/.test(rawTagName)) {
1064
1674
  const evaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
1675
+ // Apply provider-cascade defaults: for each provider value pushed
1676
+ // by an ancestor invocation (see childProviderStack assembly
1677
+ // above), inject any prop the invocation hasn't already set. This
1678
+ // is the static analog of React's `useContext` — at runtime
1679
+ // descendants would read the context value; here we propagate it
1680
+ // through the expansion's resolved props instead.
1681
+ if (providerStack.length > 0) {
1682
+ for (let i = 0; i < providerStack.length; i++) {
1683
+ const provider = providerStack[i];
1684
+ for (const key in provider) {
1685
+ if (!Object.prototype.hasOwnProperty.call(provider, key)) continue;
1686
+ if (evaluatedProps[key] === undefined) {
1687
+ evaluatedProps[key] = provider[key];
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1065
1692
  if (children.length > 0) {
1066
1693
  evaluatedProps.children = children;
1067
1694
  }
1068
1695
 
1696
+ // Pre-expansion: a callback-style render prop (e.g. shadcn's <FormField
1697
+ // render={({ field }) => <FormItem/>}>) becomes invisible after
1698
+ // expansion because the wrapper expands to a Controller-like internal
1699
+ // tree that has no Trigger leaf for attachRenderPropToExpanded to fill.
1700
+ // Use the callback's JSX directly instead.
1701
+ const callbackContent = this.extractRenderPropCallbackJsxNode(
1702
+ node.getOpeningElement(),
1703
+ localComponents,
1704
+ relativeImports,
1705
+ propsContext
1706
+ );
1707
+ if (callbackContent) {
1708
+ return callbackContent;
1709
+ }
1710
+
1069
1711
  const localDef = localComponents.get(rawTagName);
1070
1712
  if (localDef) {
1071
1713
  const expandedLocal = this.expandLocalComponentWithProps(localDef, evaluatedProps, localComponents, relativeImports);
@@ -1075,26 +1717,89 @@ export class ComponentScanner {
1075
1717
  }
1076
1718
 
1077
1719
  const importedFilePath = relativeImports.get(rawTagName);
1078
- const SKIP_EXPANSION_MAIN = ['Skeleton', 'SkeletonText'];
1720
+ const SKIP_EXPANSION_MAIN = ['Skeleton'];
1079
1721
  if (importedFilePath && !SKIP_EXPANSION_MAIN.includes(rawTagName)) {
1080
- const resolvedProps = new Map<string, any>();
1722
+ const resolvedProps = new Map<string, ResolvedExpressionValue>();
1081
1723
  for (const [key, value] of Object.entries(evaluatedProps)) {
1082
1724
  resolvedProps.set(key, value);
1083
1725
  }
1084
1726
  const expandedImported = this.expandImportedComponent(rawTagName, importedFilePath, relativeImports, resolvedProps);
1085
1727
  if (expandedImported) {
1086
1728
  if (this.isPortalRootedNode(expandedImported)) {
1087
- return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as any;
1729
+ // Unwrap portal: return the content node(s) inside the portal (skipping overlays).
1730
+ // This allows stories to use <SheetContent>, <DialogContent> etc. directly.
1731
+ // Nodes are marked __fromPortal so the plugin can filter them in trigger-only stories.
1732
+ const contentNodes = this.extractPortalContentNodes(expandedImported);
1733
+ if (contentNodes.length > 0) {
1734
+ const contentNode: JsxElement = contentNodes.length === 1
1735
+ ? contentNodes[0] as JsxElement
1736
+ : { type: 'element', tagName: 'div', isComponent: false, props: {}, children: contentNodes };
1737
+ // Strip portal-positioning classes before marking — fixed/translate/z-index/
1738
+ // animate etc. are meaningless in Figma and cause width/layout bugs.
1739
+ const cleanedClassName = this.stripPortalPositioningClasses(contentNode.props?.className || '');
1740
+ contentNode.props = Object.assign({}, contentNode.props || {}, {
1741
+ __fromPortal: true,
1742
+ className: cleanedClassName,
1743
+ });
1744
+ return contentNode as JsxNode;
1745
+ }
1746
+ return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as JsxElement & PortalSkipFlagged;
1088
1747
  }
1089
1748
  if (!this.isUnresolvedExpandedComponent(expandedImported, localComponents, relativeImports)) {
1749
+ this.attachRenderPropToExpanded(
1750
+ expandedImported,
1751
+ node.getOpeningElement(),
1752
+ localComponents,
1753
+ relativeImports,
1754
+ propsContext
1755
+ );
1090
1756
  return expandedImported;
1091
1757
  }
1758
+ // Expansion produced an unresolved wrapper (e.g. <Slot> from
1759
+ // @radix-ui — used internally by shadcn's <FormControl>). If the
1760
+ // user wrapped exactly one child and added no visual className,
1761
+ // the wrapper is a pass-through. Drop it and return the child so
1762
+ // the inner primitive (e.g. <Input>) renders normally.
1763
+ if (this.isPassThroughInvocation(props, children)) {
1764
+ return children[0];
1765
+ }
1766
+ // Otherwise: the expansion may still contain useful content
1767
+ // beneath an unresolved root wrapper (e.g. <Form><form>...).
1768
+ // Walk down through the unresolved single-child wrappers and
1769
+ // use the resolved inner node so the story tree gets the real
1770
+ // content instead of the bare `<Component/>` placeholder.
1771
+ const innerImported = this.unwrapUnresolvedSingleChildRoots(
1772
+ expandedImported,
1773
+ localComponents,
1774
+ relativeImports
1775
+ );
1776
+ if (innerImported && innerImported !== expandedImported) {
1777
+ this.attachRenderPropToExpanded(
1778
+ innerImported,
1779
+ node.getOpeningElement(),
1780
+ localComponents,
1781
+ relativeImports,
1782
+ propsContext
1783
+ );
1784
+ return innerImported;
1785
+ }
1092
1786
  }
1093
1787
  }
1094
1788
  }
1095
1789
 
1096
- const outputProps = { ...props } as Record<string, any>;
1790
+ if (children.length === 0) {
1791
+ const renderChild = this.extractRenderPropJsxNode(
1792
+ node.getOpeningElement(),
1793
+ localComponents,
1794
+ relativeImports,
1795
+ propsContext
1796
+ );
1797
+ if (renderChild) children.push(renderChild);
1798
+ }
1799
+
1800
+ const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
1097
1801
  delete outputProps.children;
1802
+ delete outputProps.render;
1098
1803
  return {
1099
1804
  type: 'element',
1100
1805
  tagName,
@@ -1108,11 +1813,37 @@ export class ComponentScanner {
1108
1813
  const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
1109
1814
  const props = this.extractPropsFromNode(node, rawTagName, propsContext).props;
1110
1815
  const evaluatedProps = this.resolveInvocationProps(props, node.getSourceFile(), propsContext);
1816
+ // Apply provider-cascade defaults: see the JsxElement branch above
1817
+ // for the full rationale. Self-closing elements are the common
1818
+ // shape for cascade-receiving items (e.g. `<ToggleGroupItem />`
1819
+ // inside a `<ToggleGroup variant="outline">`).
1820
+ if (providerStack.length > 0) {
1821
+ for (let i = 0; i < providerStack.length; i++) {
1822
+ const provider = providerStack[i];
1823
+ for (const key in provider) {
1824
+ if (!Object.prototype.hasOwnProperty.call(provider, key)) continue;
1825
+ if (evaluatedProps[key] === undefined) {
1826
+ evaluatedProps[key] = provider[key];
1827
+ }
1828
+ }
1829
+ }
1830
+ }
1111
1831
  const children: JsxNode[] = [];
1112
1832
  if (Object.prototype.hasOwnProperty.call(evaluatedProps, 'children')) {
1113
1833
  this.pushResolvedPropValue(children, evaluatedProps.children);
1114
1834
  }
1115
1835
 
1836
+ // Pre-expansion: see JsxElement branch above for rationale.
1837
+ const callbackContent = this.extractRenderPropCallbackJsxNode(
1838
+ node,
1839
+ localComponents,
1840
+ relativeImports,
1841
+ propsContext
1842
+ );
1843
+ if (callbackContent) {
1844
+ return callbackContent;
1845
+ }
1846
+
1116
1847
  // Check if this is a local component that should be expanded
1117
1848
  const localDef = localComponents.get(rawTagName);
1118
1849
  if (localDef) {
@@ -1125,10 +1856,10 @@ export class ComponentScanner {
1125
1856
  // Check if this is an imported component from a relative path
1126
1857
  // Skip expansion for simple components that the ui-builder handles directly
1127
1858
  // (e.g., Skeleton uses clsx with props that we can't statically evaluate)
1128
- const SKIP_EXPANSION_MAIN = ['Skeleton', 'SkeletonText'];
1859
+ const SKIP_EXPANSION_MAIN = ['Skeleton'];
1129
1860
  const importedFilePath = relativeImports.get(rawTagName);
1130
1861
  if (importedFilePath && !SKIP_EXPANSION_MAIN.includes(rawTagName)) {
1131
- const resolvedProps = new Map<string, any>();
1862
+ const resolvedProps = new Map<string, ResolvedExpressionValue>();
1132
1863
  for (const [key, value] of Object.entries(evaluatedProps)) {
1133
1864
  resolvedProps.set(key, value);
1134
1865
  }
@@ -1136,16 +1867,70 @@ export class ComponentScanner {
1136
1867
  const expandedJsx = this.expandImportedComponent(rawTagName, importedFilePath, relativeImports, resolvedProps);
1137
1868
  if (expandedJsx) {
1138
1869
  if (this.isPortalRootedNode(expandedJsx)) {
1139
- return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as any;
1870
+ const contentNodes = this.extractPortalContentNodes(expandedJsx);
1871
+ if (contentNodes.length > 0) {
1872
+ const contentNode: JsxElement = contentNodes.length === 1
1873
+ ? contentNodes[0] as JsxElement
1874
+ : { type: 'element', tagName: 'div', isComponent: false, props: {}, children: contentNodes };
1875
+ const cleanedClassName = this.stripPortalPositioningClasses(contentNode.props?.className || '');
1876
+ contentNode.props = Object.assign({}, contentNode.props || {}, {
1877
+ __fromPortal: true,
1878
+ className: cleanedClassName,
1879
+ });
1880
+ return contentNode as JsxNode;
1881
+ }
1882
+ return { type: 'element', tagName: '__portal_skip__', isComponent: false, props: {}, children: [], __portalSkip: true } as JsxElement & PortalSkipFlagged;
1140
1883
  }
1141
1884
  if (!this.isUnresolvedExpandedComponent(expandedJsx, localComponents, relativeImports)) {
1885
+ // Base UI asChild pattern: the outer JSX may have `render={<Button/>}`
1886
+ // which expandImportedComponent loses through the {...props} spread
1887
+ // (extractPropsFromNode captured `render` as a text string). Pull
1888
+ // the JSX value off the outer AST and inject it as the deepest
1889
+ // empty trigger's child so the trigger content shows in Figma.
1890
+ this.attachRenderPropToExpanded(
1891
+ expandedJsx,
1892
+ node,
1893
+ localComponents,
1894
+ relativeImports,
1895
+ propsContext
1896
+ );
1142
1897
  return expandedJsx;
1143
1898
  }
1899
+ // Pass-through wrapper unwrap: see JsxElement branch above.
1900
+ if (this.isPassThroughInvocation(props, children)) {
1901
+ return children[0];
1902
+ }
1903
+ const innerSelfClosing = this.unwrapUnresolvedSingleChildRoots(
1904
+ expandedJsx,
1905
+ localComponents,
1906
+ relativeImports
1907
+ );
1908
+ if (innerSelfClosing && innerSelfClosing !== expandedJsx) {
1909
+ this.attachRenderPropToExpanded(
1910
+ innerSelfClosing,
1911
+ node,
1912
+ localComponents,
1913
+ relativeImports,
1914
+ propsContext
1915
+ );
1916
+ return innerSelfClosing;
1917
+ }
1144
1918
  }
1145
1919
  }
1146
1920
 
1147
- const outputProps = { ...props } as Record<string, any>;
1921
+ if (children.length === 0) {
1922
+ const renderChild = this.extractRenderPropJsxNode(
1923
+ node,
1924
+ localComponents,
1925
+ relativeImports,
1926
+ propsContext
1927
+ );
1928
+ if (renderChild) children.push(renderChild);
1929
+ }
1930
+
1931
+ const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
1148
1932
  delete outputProps.children;
1933
+ delete outputProps.render;
1149
1934
  return {
1150
1935
  type: 'element',
1151
1936
  tagName,
@@ -1153,12 +1938,176 @@ export class ComponentScanner {
1153
1938
  props: outputProps,
1154
1939
  children,
1155
1940
  };
1941
+ } else if (Node.isJsxFragment(node)) {
1942
+ // `<>...</>` — no wrapper element. Emit an element with an empty tagName
1943
+ // so the NodeIR converter collapses it into a transparent fragment node.
1944
+ const children: JsxNode[] = [];
1945
+ for (const child of node.getJsxChildren()) {
1946
+ if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child) || Node.isJsxFragment(child)) {
1947
+ const childNode = this.buildJsxTree(child, localComponents, relativeImports, propsContext);
1948
+ if ((childNode as JsxNode & PortalSkipFlagged).__portalSkip) continue;
1949
+ children.push(childNode);
1950
+ } else if (Node.isJsxText(child)) {
1951
+ const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
1952
+ if (text) children.push({ type: 'text', content: text });
1953
+ } else if (Node.isJsxExpression(child)) {
1954
+ const expr = child.getExpression();
1955
+ if (expr && Node.isCallExpression(expr)) {
1956
+ const mapped = this.expandMapCall(expr, node.getSourceFile(), localComponents, relativeImports, propsContext);
1957
+ for (const m of mapped) children.push(m);
1958
+ } else if (expr) {
1959
+ const resolved = this.resolveExpressionValue(expr, propsContext);
1960
+ if (resolved !== undefined && resolved !== null) {
1961
+ this.pushResolvedPropValue(children, resolved);
1962
+ }
1963
+ }
1964
+ }
1965
+ }
1966
+ return {
1967
+ type: 'element',
1968
+ tagName: '',
1969
+ isComponent: false,
1970
+ props: {},
1971
+ children,
1972
+ };
1156
1973
  }
1157
1974
 
1158
1975
  // Fallback for unexpected node types
1159
1976
  return { type: 'text', content: '' };
1160
1977
  }
1161
1978
 
1979
+ /**
1980
+ * Walk an expanded JSX tree and inject the outer `render` prop's JSX
1981
+ * value into the first empty trigger-like descendant. Used when the outer
1982
+ * call site has `render={<Button/>}` but expansion flattens it away through
1983
+ * {...props} spread — extractPropsFromNode can only capture the raw source
1984
+ * text, so we parse the JSX off the original AST and graft it onto the
1985
+ * expansion result.
1986
+ */
1987
+ private attachRenderPropToExpanded(
1988
+ expanded: JsxNode,
1989
+ outerAstNode: Node,
1990
+ localComponents: Map<string, Node>,
1991
+ relativeImports: Map<string, string>,
1992
+ propsContext: Map<string, ResolvedExpressionValue>
1993
+ ): void {
1994
+ const renderChild = this.extractRenderPropJsxNode(
1995
+ outerAstNode,
1996
+ localComponents,
1997
+ relativeImports,
1998
+ propsContext
1999
+ );
2000
+ if (!renderChild) return;
2001
+ const target = this.findFirstEmptyTriggerNode(expanded);
2002
+ if (target) {
2003
+ target.children = [renderChild];
2004
+ if (target.props) {
2005
+ delete target.props.render;
2006
+ }
2007
+ }
2008
+ }
2009
+
2010
+ private findFirstEmptyTriggerNode(node: JsxNode): JsxElement | null {
2011
+ if (!node || node.type !== 'element') return null;
2012
+ const el = node;
2013
+ const tag = String(el.tagName || '').toLowerCase();
2014
+ if (tag.endsWith('trigger') && (!el.children || el.children.length === 0)) {
2015
+ return el;
2016
+ }
2017
+ if (Array.isArray(el.children)) {
2018
+ for (let i = 0; i < el.children.length; i++) {
2019
+ const found = this.findFirstEmptyTriggerNode(el.children[i]);
2020
+ if (found) return found;
2021
+ }
2022
+ }
2023
+ return null;
2024
+ }
2025
+
2026
+ /**
2027
+ * Like `extractRenderPropJsxNode` but only matches *callback*-style render
2028
+ * props (arrow functions / function expressions). Used as a pre-expansion
2029
+ * guard: if a component has a callback render prop, expansion of the
2030
+ * wrapper would discard the render content (see <FormField> from
2031
+ * react-hook-form: it expands to `<FormFieldContext.Provider><Controller/></...>`,
2032
+ * and `attachRenderPropToExpanded` only injects literals into Trigger leaves).
2033
+ * Returning the callback's JSX directly skips expansion and matches React's
2034
+ * actual visual output.
2035
+ */
2036
+ private extractRenderPropCallbackJsxNode(
2037
+ openingOrSelfClosing: Node,
2038
+ localComponents: Map<string, Node>,
2039
+ relativeImports: Map<string, string>,
2040
+ propsContext: Map<string, ResolvedExpressionValue>
2041
+ ): JsxNode | null {
2042
+ const attrs =
2043
+ Node.isJsxOpeningElement(openingOrSelfClosing) || Node.isJsxSelfClosingElement(openingOrSelfClosing)
2044
+ ? openingOrSelfClosing.getAttributes()
2045
+ : [];
2046
+ for (const attr of attrs) {
2047
+ if (!Node.isJsxAttribute(attr)) continue;
2048
+ const nameNode = attr.getNameNode();
2049
+ if (!nameNode || nameNode.getText() !== 'render') continue;
2050
+ const init = attr.getInitializer();
2051
+ if (!init || !Node.isJsxExpression(init)) continue;
2052
+ let expr = init.getExpression();
2053
+ if (!expr) continue;
2054
+ if (Node.isParenthesizedExpression(expr)) expr = expr.getExpression();
2055
+ if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) {
2056
+ const built = this.extractJsxTreeFromFunctionBody(expr, relativeImports, localComponents, propsContext);
2057
+ if (built && !(built as JsxNode & PortalSkipFlagged).__portalSkip) return built;
2058
+ }
2059
+ return null;
2060
+ }
2061
+ return null;
2062
+ }
2063
+
2064
+ /**
2065
+ * Components can expose a `render` prop in two shapes:
2066
+ *
2067
+ * 1. JSX literal (Base UI / Radix asChild pattern):
2068
+ * <PopoverPrimitive.Trigger render={<Button>Open</Button>} />
2069
+ *
2070
+ * 2. Render-prop callback (react-hook-form's <FormField>, headless UI patterns):
2071
+ * <FormField render={({ field }) => (<FormItem>...</FormItem>)} />
2072
+ *
2073
+ * Extract the inner JSX in either case so the plugin can render the wrapper's
2074
+ * actual content. For callbacks the inner-scope arguments (e.g. `field`) are
2075
+ * not resolvable, so the JSX is rendered with whatever literal props it has.
2076
+ */
2077
+ private extractRenderPropJsxNode(
2078
+ openingOrSelfClosing: Node,
2079
+ localComponents: Map<string, Node>,
2080
+ relativeImports: Map<string, string>,
2081
+ propsContext: Map<string, ResolvedExpressionValue>
2082
+ ): JsxNode | null {
2083
+ const attrs =
2084
+ Node.isJsxOpeningElement(openingOrSelfClosing) || Node.isJsxSelfClosingElement(openingOrSelfClosing)
2085
+ ? openingOrSelfClosing.getAttributes()
2086
+ : [];
2087
+ for (const attr of attrs) {
2088
+ if (!Node.isJsxAttribute(attr)) continue;
2089
+ const nameNode = attr.getNameNode();
2090
+ if (!nameNode || nameNode.getText() !== 'render') continue;
2091
+ const init = attr.getInitializer();
2092
+ if (!init || !Node.isJsxExpression(init)) continue;
2093
+ let expr = init.getExpression();
2094
+ if (!expr) continue;
2095
+ if (Node.isParenthesizedExpression(expr)) expr = expr.getExpression();
2096
+ if (Node.isJsxElement(expr) || Node.isJsxSelfClosingElement(expr) || Node.isJsxFragment(expr)) {
2097
+ const built = this.buildJsxTree(expr, localComponents, relativeImports, propsContext);
2098
+ if (built && !(built as JsxNode & PortalSkipFlagged).__portalSkip) return built;
2099
+ return null;
2100
+ }
2101
+ if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) {
2102
+ const built = this.extractJsxTreeFromFunctionBody(expr, relativeImports, localComponents, propsContext);
2103
+ if (built && !(built as JsxNode & PortalSkipFlagged).__portalSkip) return built;
2104
+ return null;
2105
+ }
2106
+ return null;
2107
+ }
2108
+ return null;
2109
+ }
2110
+
1162
2111
  private cloneJsxNode(node: JsxNode): JsxNode {
1163
2112
  if (node.type === 'text') {
1164
2113
  return { type: 'text', content: (node as JsxText).content };
@@ -1177,7 +2126,7 @@ export class ComponentScanner {
1177
2126
  };
1178
2127
  }
1179
2128
 
1180
- private pushResolvedPropValue(children: JsxNode[], value: any): void {
2129
+ private pushResolvedPropValue(children: JsxNode[], value: ResolvedExpressionValue): void {
1181
2130
  if (value == null || value === '') return;
1182
2131
  if (Array.isArray(value)) {
1183
2132
  for (let i = 0; i < value.length; i++) {
@@ -1201,9 +2150,9 @@ export class ComponentScanner {
1201
2150
  private resolveInvocationProps(
1202
2151
  props: Record<string, string>,
1203
2152
  sourceFile: SourceFile,
1204
- propsContext: Map<string, any>
1205
- ): Record<string, any> {
1206
- const resolved: Record<string, any> = {};
2153
+ propsContext: Map<string, ResolvedExpressionValue>
2154
+ ): Record<string, ResolvedExpressionValue> {
2155
+ const resolved: Record<string, ResolvedExpressionValue> = {};
1207
2156
  for (const [propName, propValue] of Object.entries(props)) {
1208
2157
  if (typeof propValue !== 'string') {
1209
2158
  resolved[propName] = propValue;
@@ -1231,8 +2180,18 @@ export class ComponentScanner {
1231
2180
  * For a normal param (`item`): adds `item → itemValue` so `item.selected` resolves.
1232
2181
  * For destructured params (`__destructured__`): adds each key directly.
1233
2182
  */
1234
- private buildItemPropsContext(itemParamName: string, itemValue: any): Map<string, any> {
1235
- const ctx = new Map<string, any>();
2183
+ private buildItemPropsContext(
2184
+ itemParamName: string,
2185
+ itemValue: ResolvedExpressionValue,
2186
+ extraContext?: Map<string, ResolvedExpressionValue>
2187
+ ): Map<string, ResolvedExpressionValue> {
2188
+ const ctx = new Map<string, ResolvedExpressionValue>();
2189
+ // Start with any outer context (propsContext from map callbacks, etc.) so
2190
+ // the callback body can resolve identifiers that aren't the item itself
2191
+ // (e.g. `widths`, `WIDTH_CLASSNAMES`, the iteration index).
2192
+ if (extraContext) {
2193
+ for (const [k, v] of extraContext) ctx.set(k, v);
2194
+ }
1236
2195
  if (!itemParamName || itemValue == null) return ctx;
1237
2196
  if (itemParamName === '__destructured__') {
1238
2197
  if (typeof itemValue === 'object') {
@@ -1245,13 +2204,13 @@ export class ComponentScanner {
1245
2204
  }
1246
2205
 
1247
2206
  private resolveSubstitutionProps(
1248
- props: Record<string, string>,
2207
+ props: Record<string, ResolvedExpressionValue>,
1249
2208
  itemParamName: string,
1250
- itemValue: any
1251
- ): Record<string, any> {
1252
- const resolved: Record<string, any> = {};
2209
+ itemValue: ResolvedExpressionValue
2210
+ ): Record<string, ResolvedExpressionValue> {
2211
+ const resolved: Record<string, ResolvedExpressionValue> = {};
1253
2212
  for (const [propName, propValue] of Object.entries(props)) {
1254
- if (propValue.startsWith(itemParamName + '.')) {
2213
+ if (typeof propValue === 'string' && propValue.startsWith(itemParamName + '.')) {
1255
2214
  const propPath = propValue.slice(itemParamName.length + 1);
1256
2215
  resolved[propName] = itemValue && typeof itemValue === 'object'
1257
2216
  ? itemValue[propPath]
@@ -1304,7 +2263,7 @@ export class ComponentScanner {
1304
2263
  sourceFile: SourceFile,
1305
2264
  localComponents: Map<string, Node>,
1306
2265
  relativeImports: Map<string, string>,
1307
- propsContext: Map<string, any> = new Map()
2266
+ propsContext: Map<string, ResolvedExpressionValue> = new Map()
1308
2267
  ): JsxNode[] {
1309
2268
  const results: JsxNode[] = [];
1310
2269
 
@@ -1315,9 +2274,55 @@ export class ComponentScanner {
1315
2274
  const propAccess = callExpr.getExpression();
1316
2275
  if (!Node.isPropertyAccessExpression(propAccess)) return results;
1317
2276
 
1318
- // Get the array expression (may be a variable name or an inline array literal)
1319
- const arrayExpr = propAccess.getExpression();
1320
- const arrayName = Node.isArrayLiteralExpression(arrayExpr) ? '__inline__' : arrayExpr.getText();
2277
+ // Get the array expression (may be a variable name or an inline array literal).
2278
+ // Unwrap ParenthesizedExpression and AsExpression so patterns like
2279
+ // `(["a", "b"] as const).map(...)` resolve to the inline array literal.
2280
+ let arrayExpr: Node = propAccess.getExpression();
2281
+ while (
2282
+ Node.isParenthesizedExpression(arrayExpr)
2283
+ || Node.isAsExpression(arrayExpr)
2284
+ || Node.isTypeAssertion(arrayExpr)
2285
+ || Node.isNonNullExpression(arrayExpr)
2286
+ ) {
2287
+ arrayExpr = arrayExpr.getExpression();
2288
+ }
2289
+
2290
+ // Detect `Array.from({ length: N })` as a synthetic array source. Used
2291
+ // by patterns like `Array.from({length: lines}).map((_, i) => <Row>)`.
2292
+ // We only care about the length; items are placeholder nulls and the
2293
+ // callback must derive values from `index` or surrounding propsContext.
2294
+ let syntheticArrayLength: number | null = null;
2295
+ if (Node.isCallExpression(arrayExpr)) {
2296
+ const arrayFromCallee = arrayExpr.getExpression();
2297
+ if (
2298
+ Node.isPropertyAccessExpression(arrayFromCallee)
2299
+ && arrayFromCallee.getExpression().getText() === 'Array'
2300
+ && arrayFromCallee.getName() === 'from'
2301
+ ) {
2302
+ const fromArgs = arrayExpr.getArguments();
2303
+ const firstArg = fromArgs[0];
2304
+ if (firstArg && Node.isObjectLiteralExpression(firstArg)) {
2305
+ for (const prop of firstArg.getProperties()) {
2306
+ if (!Node.isPropertyAssignment(prop)) continue;
2307
+ const key = prop.getName();
2308
+ if (key !== 'length') continue;
2309
+ const init = prop.getInitializer();
2310
+ if (!init) continue;
2311
+ const resolved = this.resolveExpressionValue(init, propsContext);
2312
+ const num = typeof resolved === 'number'
2313
+ ? resolved
2314
+ : (typeof resolved === 'string' && /^\d+$/.test(resolved) ? parseInt(resolved, 10) : NaN);
2315
+ if (Number.isFinite(num) && num >= 0) {
2316
+ syntheticArrayLength = Math.min(num as number, 7);
2317
+ }
2318
+ }
2319
+ }
2320
+ }
2321
+ }
2322
+
2323
+ const arrayName = Node.isArrayLiteralExpression(arrayExpr)
2324
+ ? '__inline__'
2325
+ : (syntheticArrayLength != null ? '__synthetic__' : arrayExpr.getText());
1321
2326
 
1322
2327
  // Get the map callback function
1323
2328
  const args = callExpr.getArguments();
@@ -1349,6 +2354,17 @@ export class ComponentScanner {
1349
2354
  itemParamName = params[0].getName();
1350
2355
  }
1351
2356
 
2357
+ // Second parameter is the iteration index (`(item, index)` or `(_, i)`).
2358
+ // Pattern is common for callbacks that derive per-iteration values from
2359
+ // the index — e.g. `widths[index % widths.length]` in SkeletonText.
2360
+ let indexParamName: string | null = null;
2361
+ if (params.length >= 2) {
2362
+ const secondNameNode = params[1].getNameNode();
2363
+ if (secondNameNode && Node.isIdentifier(secondNameNode)) {
2364
+ indexParamName = secondNameNode.getText();
2365
+ }
2366
+ }
2367
+
1352
2368
  // Find the JSX in the callback body
1353
2369
  const body = callback.getBody();
1354
2370
  if (!body) return results;
@@ -1380,11 +2396,36 @@ export class ComponentScanner {
1380
2396
 
1381
2397
  if (!callbackJsx) return results;
1382
2398
 
2399
+ // Collect `const X = <expr>;` declarations in the callback body so they
2400
+ // are available as additional context per iteration (e.g.
2401
+ // `const width = widths[index % widths.length];`). The initializers are
2402
+ // re-evaluated per iteration because they often depend on `index`.
2403
+ const callbackConstDecls: { name: string; initializer: Node }[] = [];
2404
+ if (Node.isBlock(body)) {
2405
+ const statements = body.getStatements();
2406
+ for (const stmt of statements) {
2407
+ if (!Node.isVariableStatement(stmt)) continue;
2408
+ const declList = stmt.getDeclarationList();
2409
+ for (const decl of declList.getDeclarations()) {
2410
+ const nameNode = decl.getNameNode();
2411
+ if (!Node.isIdentifier(nameNode)) continue;
2412
+ const init = decl.getInitializer();
2413
+ if (!init) continue;
2414
+ callbackConstDecls.push({ name: nameNode.getText(), initializer: init });
2415
+ }
2416
+ }
2417
+ }
2418
+
1383
2419
  // Find the array's value - inline literal first, then propsContext, then source file
1384
- let arrayValue: any[] | null = null;
2420
+ let arrayValue: ResolvedExpressionValue[] | null = null;
2421
+
2422
+ // Synthetic array from `Array.from({length: N})` — items are placeholders.
2423
+ if (arrayName === '__synthetic__' && syntheticArrayLength != null) {
2424
+ arrayValue = new Array(syntheticArrayLength).fill(null);
2425
+ }
1385
2426
 
1386
2427
  // Inline array literal: [{ ... }, ...].map(...)
1387
- if (arrayName === '__inline__' && Node.isArrayLiteralExpression(arrayExpr)) {
2428
+ if (!arrayValue && arrayName === '__inline__' && Node.isArrayLiteralExpression(arrayExpr)) {
1388
2429
  arrayValue = this.parseArrayLiteral(arrayExpr);
1389
2430
  }
1390
2431
 
@@ -1431,18 +2472,41 @@ export class ComponentScanner {
1431
2472
  let item = arrayValue[i];
1432
2473
  // When the callback uses destructuring, flatten the item to use local names as keys
1433
2474
  if (destructuringBindings && typeof item === 'object' && item !== null) {
1434
- const flatItem: Record<string, any> = {};
2475
+ const flatItem: Record<string, ResolvedExpressionValue> = {};
1435
2476
  for (const [localName, sourceKey] of destructuringBindings) {
1436
2477
  flatItem[localName] = item[sourceKey];
1437
2478
  }
1438
2479
  item = flatItem;
1439
2480
  }
2481
+ // Carry the outer propsContext plus the iteration index through the
2482
+ // recursion so dynamic per-line expressions (e.g. `widths[index % N]`,
2483
+ // `WIDTH_CLASSNAMES[width]`) can resolve against the caller's props.
2484
+ const iterationContext = new Map<string, ResolvedExpressionValue>(propsContext);
2485
+ if (indexParamName) {
2486
+ iterationContext.set(indexParamName, i);
2487
+ }
2488
+ // Include the callback's local `const` declarations (e.g.
2489
+ // `const width = widths[index % widths.length];`) so the JSX body
2490
+ // can reference them. Evaluated per-iteration because they usually
2491
+ // depend on `index` or the current item.
2492
+ if (itemParamName && itemParamName !== '__destructured__' && item != null) {
2493
+ iterationContext.set(itemParamName, item);
2494
+ } else if (itemParamName === '__destructured__' && typeof item === 'object' && item !== null) {
2495
+ for (const [k, v] of Object.entries(item)) iterationContext.set(k, v);
2496
+ }
2497
+ for (const decl of callbackConstDecls) {
2498
+ const resolved = this.resolveExpressionValue(decl.initializer, iterationContext);
2499
+ if (resolved !== undefined) {
2500
+ iterationContext.set(decl.name, resolved);
2501
+ }
2502
+ }
1440
2503
  const itemJsx = this.buildJsxTreeWithSubstitution(
1441
2504
  callbackJsx,
1442
2505
  itemParamName,
1443
2506
  item,
1444
2507
  mergedLocalComponents,
1445
- relativeImports
2508
+ relativeImports,
2509
+ iterationContext
1446
2510
  );
1447
2511
  if (itemJsx) {
1448
2512
  results.push(itemJsx);
@@ -1459,27 +2523,51 @@ export class ComponentScanner {
1459
2523
  * Find the value of an array variable in the source file.
1460
2524
  * Looks for const declarations and default prop values.
1461
2525
  */
1462
- private findArrayValue(arrayName: string, sourceFile: SourceFile): any[] | null {
2526
+ private findArrayValue(arrayName: string, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
1463
2527
  // Check cache first
1464
2528
  const cacheKey = `${sourceFile.getFilePath()}:${arrayName}`;
1465
2529
  if (this.arrayValueCache.has(cacheKey)) {
1466
2530
  return this.arrayValueCache.get(cacheKey)!;
1467
2531
  }
1468
2532
 
1469
- // Look for const declaration: const features = [...]
1470
- for (const varStmt of sourceFile.getVariableStatements()) {
2533
+ // Look for `const features = [...]` style declarations. Walks ALL
2534
+ // variable statements in the source file (top-level AND inside
2535
+ // function bodies) so locally-scoped arrays like
2536
+ // `function BlockTable() { const sortedBlocks = [...blocks].sort(...); ... }`
2537
+ // resolve too — the canonical case is greenhouse-app's block-table
2538
+ // where the .map source is a local sort over an imported JSON file.
2539
+ //
2540
+ // Initializer may be a direct array literal, a spread literal
2541
+ // (`[...other]`), a method chain on an existing array
2542
+ // (`[...blocks].sort(...)`, `data.filter(...).slice(0, N)`), or
2543
+ // simply an identifier that points at another array (re-export).
2544
+ // All of those routes funnel into `resolveArrayFromExpression`.
2545
+ const allVarStatements = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement);
2546
+ for (const varStmt of allVarStatements) {
1471
2547
  for (const decl of varStmt.getDeclarationList().getDeclarations()) {
1472
2548
  if (decl.getName() === arrayName) {
1473
- const init = this.unwrapStaticValueExpression(decl.getInitializer());
1474
- if (init && Node.isArrayLiteralExpression(init)) {
1475
- const value = this.parseArrayLiteral(init);
1476
- this.arrayValueCache.set(cacheKey, value);
1477
- return value;
2549
+ const init = this.unwrapStaticValueExpression(decl.getInitializer()) || decl.getInitializer();
2550
+ if (init) {
2551
+ const value = this.resolveArrayFromExpression(init, sourceFile);
2552
+ if (value) {
2553
+ this.arrayValueCache.set(cacheKey, value);
2554
+ return value;
2555
+ }
1478
2556
  }
1479
2557
  }
1480
2558
  }
1481
2559
  }
1482
2560
 
2561
+ // If the name is a default import of a `.json` file, load it from
2562
+ // disk. Covers patterns like `import data from '~/constants/blocks.json'`
2563
+ // followed by `.map(item => …)` over the imported array (block-table
2564
+ // is the canonical case in greenhouse-app).
2565
+ const jsonImported = this.resolveImportedJsonValue(arrayName, sourceFile);
2566
+ if (Array.isArray(jsonImported)) {
2567
+ this.arrayValueCache.set(cacheKey, jsonImported);
2568
+ return jsonImported;
2569
+ }
2570
+
1483
2571
  // Look for default parameter value in function: function Comp({ features = defaultFeatures })
1484
2572
  for (const func of sourceFile.getFunctions()) {
1485
2573
  const params = func.getParameters();
@@ -1542,13 +2630,107 @@ export class ComponentScanner {
1542
2630
  return null;
1543
2631
  }
1544
2632
 
2633
+ /**
2634
+ * Resolve an expression to an array of items. Handles:
2635
+ *
2636
+ * - `[a, b, c]` — direct array literal (parsed verbatim)
2637
+ * - `[...items]` — spread of another array (resolved
2638
+ * recursively against the source file)
2639
+ * - `items.sort(...)` — array-returning method chains
2640
+ * (`sort`, `filter`, `slice`, `reverse`,
2641
+ * `toSorted`, `toReversed`, `with`) are
2642
+ * unwrapped to the receiver. We do not
2643
+ * *apply* the operation — for design-
2644
+ * system rendering, any non-empty sample
2645
+ * of items is good enough.
2646
+ * - identifier — recurses through `findArrayValue`
2647
+ * (local var) and JSON-import resolution.
2648
+ *
2649
+ * Returns `null` when nothing resolvable is found; the caller then
2650
+ * falls back to its placeholder-iteration path.
2651
+ */
2652
+ private resolveArrayFromExpression(expr: Node, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
2653
+ const unwrapped = this.unwrapStaticValueExpression(expr) || expr;
2654
+
2655
+ if (Node.isArrayLiteralExpression(unwrapped)) {
2656
+ const elements = unwrapped.getElements();
2657
+ if (elements.length === 1 && Node.isSpreadElement(elements[0])) {
2658
+ // `[...items]` — unwrap to `items` and resolve that.
2659
+ const spreadInner = (elements[0] as import('ts-morph').SpreadElement).getExpression();
2660
+ return this.resolveArrayFromExpression(spreadInner, sourceFile);
2661
+ }
2662
+ return this.parseArrayLiteral(unwrapped);
2663
+ }
2664
+
2665
+ if (Node.isCallExpression(unwrapped)) {
2666
+ const callee = unwrapped.getExpression();
2667
+ if (Node.isPropertyAccessExpression(callee)) {
2668
+ const methodName = callee.getName();
2669
+ const PASS_THROUGH_METHODS = new Set([
2670
+ 'sort', 'filter', 'slice', 'reverse',
2671
+ 'toSorted', 'toReversed', 'with', 'concat', 'flat', 'flatMap',
2672
+ ]);
2673
+ if (PASS_THROUGH_METHODS.has(methodName)) {
2674
+ return this.resolveArrayFromExpression(callee.getExpression(), sourceFile);
2675
+ }
2676
+ }
2677
+ }
2678
+
2679
+ if (Node.isIdentifier(unwrapped)) {
2680
+ const name = unwrapped.getText();
2681
+ const fromCache = this.findArrayValue(name, sourceFile);
2682
+ if (fromCache) return fromCache;
2683
+ const fromJson = this.resolveImportedJsonValue(name, sourceFile);
2684
+ if (Array.isArray(fromJson)) return fromJson;
2685
+ }
2686
+
2687
+ return null;
2688
+ }
2689
+
2690
+ /**
2691
+ * If `name` is a default-imported JSON file in `sourceFile` (e.g.
2692
+ * `import blocks from '~/constants/blocks.json'`), load + parse the
2693
+ * file from disk and return the value. Returns `null` for any other
2694
+ * import shape (named imports, non-JSON files, unresolvable paths).
2695
+ *
2696
+ * Path-alias resolution piggybacks on the existing
2697
+ * `resolveImportedComponentPath`, plus a JSON-only branch that probes
2698
+ * the literal path (without `.tsx`/`.ts` candidates).
2699
+ */
2700
+ private resolveImportedJsonValue(name: string, sourceFile: SourceFile): unknown {
2701
+ const fileDir = path.dirname(sourceFile.getFilePath());
2702
+ for (const imp of sourceFile.getImportDeclarations()) {
2703
+ const defaultImport = imp.getDefaultImport();
2704
+ if (!defaultImport || defaultImport.getText() !== name) continue;
2705
+ const moduleSpec = imp.getModuleSpecifierValue();
2706
+ if (!moduleSpec.endsWith('.json')) return null;
2707
+
2708
+ let absPath: string | null = null;
2709
+ if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
2710
+ absPath = path.resolve(fileDir, moduleSpec);
2711
+ } else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
2712
+ absPath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
2713
+ } else {
2714
+ return null;
2715
+ }
2716
+
2717
+ if (!fs.existsSync(absPath)) return null;
2718
+ try {
2719
+ const raw = fs.readFileSync(absPath, 'utf-8');
2720
+ return JSON.parse(raw);
2721
+ } catch (_e) {
2722
+ return null;
2723
+ }
2724
+ }
2725
+ return null;
2726
+ }
2727
+
1545
2728
  /**
1546
2729
  * Unwrap syntax wrappers around static values so declarations like
1547
2730
  * `const FAQS = [...] as const` still resolve to the underlying literal.
1548
2731
  */
1549
2732
  private unwrapStaticValueExpression(expr: Node | undefined): Node | undefined {
1550
2733
  let current = expr;
1551
- const nodeApi = Node as any;
1552
2734
 
1553
2735
  while (current) {
1554
2736
  if (Node.isParenthesizedExpression(current)) {
@@ -1559,11 +2741,11 @@ export class ComponentScanner {
1559
2741
  current = current.getExpression();
1560
2742
  continue;
1561
2743
  }
1562
- if (typeof nodeApi.isTypeAssertion === 'function' && nodeApi.isTypeAssertion(current)) {
2744
+ if (Node.isTypeAssertion(current)) {
1563
2745
  current = current.getExpression();
1564
2746
  continue;
1565
2747
  }
1566
- if (typeof nodeApi.isSatisfiesExpression === 'function' && nodeApi.isSatisfiesExpression(current)) {
2748
+ if (Node.isSatisfiesExpression(current)) {
1567
2749
  current = current.getExpression();
1568
2750
  continue;
1569
2751
  }
@@ -1576,14 +2758,14 @@ export class ComponentScanner {
1576
2758
  /**
1577
2759
  * Parse an array literal expression into JavaScript values.
1578
2760
  */
1579
- private parseArrayLiteral(arrayLit: Node): any[] {
1580
- const result: any[] = [];
2761
+ private parseArrayLiteral(arrayLit: Node): ResolvedExpressionValue[] {
2762
+ const result: ResolvedExpressionValue[] = [];
1581
2763
 
1582
2764
  if (!Node.isArrayLiteralExpression(arrayLit)) return result;
1583
2765
 
1584
2766
  for (const element of arrayLit.getElements()) {
1585
2767
  if (Node.isObjectLiteralExpression(element)) {
1586
- const obj: Record<string, any> = {};
2768
+ const obj: Record<string, ResolvedExpressionValue> = {};
1587
2769
  for (const prop of element.getProperties()) {
1588
2770
  if (Node.isPropertyAssignment(prop)) {
1589
2771
  const name = prop.getName();
@@ -1621,40 +2803,59 @@ export class ComponentScanner {
1621
2803
  private buildJsxTreeWithSubstitution(
1622
2804
  node: Node,
1623
2805
  itemParamName: string,
1624
- itemValue: any,
2806
+ itemValue: ResolvedExpressionValue,
1625
2807
  localComponents: Map<string, Node>,
1626
- relativeImports: Map<string, string>
2808
+ relativeImports: Map<string, string>,
2809
+ extraContext?: Map<string, ResolvedExpressionValue>
1627
2810
  ): JsxNode | null {
1628
2811
  if (Node.isJsxElement(node)) {
1629
2812
  const rawTagName = node.getOpeningElement().getTagNameNode().getText();
1630
2813
  const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
1631
- const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue);
2814
+ const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue, extraContext);
1632
2815
  const props = this.extractPropsFromNode(node.getOpeningElement(), rawTagName, itemPropsContext).props;
1633
2816
  const children: JsxNode[] = [];
1634
2817
 
1635
2818
  for (const child of node.getJsxChildren()) {
1636
2819
  if (Node.isJsxElement(child) || Node.isJsxSelfClosingElement(child)) {
1637
- const childJsx = this.buildJsxTreeWithSubstitution(child, itemParamName, itemValue, localComponents, relativeImports);
2820
+ const childJsx = this.buildJsxTreeWithSubstitution(child, itemParamName, itemValue, localComponents, relativeImports, extraContext);
1638
2821
  if (childJsx) children.push(childJsx);
1639
2822
  } else if (Node.isJsxText(child)) {
1640
- const text = decodeHtmlEntities(child.getText().trim());
2823
+ const text = decodeHtmlEntities(normalizeJsxText(child.getFullText()));
1641
2824
  if (text) {
1642
2825
  children.push({ type: 'text', content: text });
1643
2826
  }
1644
2827
  } else if (Node.isJsxExpression(child)) {
1645
2828
  const expr = child.getExpression();
1646
2829
  if (expr) {
2830
+ if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '??') {
2831
+ let chosen: Node = expr.getLeft();
2832
+ const leftValue = this.substituteExpression(expr.getLeft(), itemParamName, itemValue, extraContext);
2833
+ if (leftValue === null || leftValue === undefined) {
2834
+ chosen = expr.getRight();
2835
+ }
2836
+ if (Node.isParenthesizedExpression(chosen)) {
2837
+ chosen = chosen.getExpression();
2838
+ }
2839
+ if (Node.isJsxElement(chosen) || Node.isJsxSelfClosingElement(chosen)) {
2840
+ const jsxChild = this.buildJsxTreeWithSubstitution(chosen, itemParamName, itemValue, localComponents, relativeImports, extraContext);
2841
+ if (jsxChild) children.push(jsxChild);
2842
+ } else {
2843
+ const substituted = this.substituteExpression(chosen, itemParamName, itemValue, extraContext);
2844
+ if (substituted !== null && substituted !== undefined && substituted !== '') {
2845
+ children.push({ type: 'text', content: String(substituted) });
2846
+ }
2847
+ }
1647
2848
  // Handle conditional JSX: {condition && <JSX>} or {condition && (<JSX>)}
1648
- if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '&&') {
2849
+ } else if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '&&') {
1649
2850
  const left = expr.getLeft();
1650
2851
  let right = expr.getRight();
1651
2852
  // Unwrap parenthesized expression
1652
2853
  if (Node.isParenthesizedExpression(right)) {
1653
2854
  right = right.getExpression();
1654
2855
  }
1655
- const condValue = this.substituteExpression(left, itemParamName, itemValue);
2856
+ const condValue = this.substituteExpression(left, itemParamName, itemValue, extraContext);
1656
2857
  if (condValue && (Node.isJsxElement(right) || Node.isJsxSelfClosingElement(right))) {
1657
- const jsxChild = this.buildJsxTreeWithSubstitution(right, itemParamName, itemValue, localComponents, relativeImports);
2858
+ const jsxChild = this.buildJsxTreeWithSubstitution(right, itemParamName, itemValue, localComponents, relativeImports, extraContext);
1658
2859
  if (jsxChild) children.push(jsxChild);
1659
2860
  }
1660
2861
  // Handle ternary JSX: {condition ? <JSX1> : <JSX2>}
@@ -1669,20 +2870,40 @@ export class ComponentScanner {
1669
2870
  if (Node.isParenthesizedExpression(whenFalse)) {
1670
2871
  whenFalse = whenFalse.getExpression();
1671
2872
  }
1672
- const condValue = this.substituteExpression(condition, itemParamName, itemValue);
2873
+ const condValue = this.substituteExpression(condition, itemParamName, itemValue, extraContext);
1673
2874
  const branch = condValue ? whenTrue : whenFalse;
1674
2875
  if (Node.isJsxElement(branch) || Node.isJsxSelfClosingElement(branch)) {
1675
- const jsxChild = this.buildJsxTreeWithSubstitution(branch, itemParamName, itemValue, localComponents, relativeImports);
2876
+ const jsxChild = this.buildJsxTreeWithSubstitution(branch, itemParamName, itemValue, localComponents, relativeImports, extraContext);
1676
2877
  if (jsxChild) children.push(jsxChild);
1677
2878
  } else {
1678
2879
  // Non-JSX ternary result - substitute as text
1679
- const substituted = this.substituteExpression(branch, itemParamName, itemValue);
2880
+ const substituted = this.substituteExpression(branch, itemParamName, itemValue, extraContext);
1680
2881
  if (substituted !== null && substituted !== undefined && substituted !== '') {
1681
2882
  children.push({ type: 'text', content: String(substituted) });
1682
2883
  }
1683
2884
  }
2885
+ } else if (Node.isCallExpression(expr)) {
2886
+ // Support nested `.map()` calls inside iteration callbacks so
2887
+ // patterns like Array.from({length}).map(...) expand through the
2888
+ // current propsContext (items/index) to resolve dynamic classes.
2889
+ const callee = expr.getExpression();
2890
+ const isMapCall = Node.isPropertyAccessExpression(callee)
2891
+ && callee.getName() === 'map';
2892
+ if (isMapCall) {
2893
+ const nestedPropsContext = new Map<string, ResolvedExpressionValue>(extraContext);
2894
+ if (itemParamName && itemParamName !== '__destructured__' && itemValue != null) {
2895
+ nestedPropsContext.set(itemParamName, itemValue);
2896
+ }
2897
+ const nestedChildren = this.expandMapCall(expr, node.getSourceFile(), localComponents, relativeImports, nestedPropsContext);
2898
+ for (const nested of nestedChildren) children.push(nested);
2899
+ continue;
2900
+ }
2901
+ const substituted = this.substituteExpression(expr, itemParamName, itemValue, extraContext);
2902
+ if (substituted !== null && substituted !== undefined && substituted !== '') {
2903
+ children.push({ type: 'text', content: String(substituted) });
2904
+ }
1684
2905
  } else {
1685
- const substituted = this.substituteExpression(expr, itemParamName, itemValue);
2906
+ const substituted = this.substituteExpression(expr, itemParamName, itemValue, extraContext);
1686
2907
  if (substituted !== null && substituted !== undefined && substituted !== '') {
1687
2908
  children.push({ type: 'text', content: String(substituted) });
1688
2909
  }
@@ -1711,9 +2932,9 @@ export class ComponentScanner {
1711
2932
  }
1712
2933
 
1713
2934
  const importedFilePath = relativeImports.get(rawTagName);
1714
- const SKIP_EXPANSION = ['Skeleton', 'SkeletonText'];
2935
+ const SKIP_EXPANSION = ['Skeleton'];
1715
2936
  if (importedFilePath && !SKIP_EXPANSION.includes(rawTagName)) {
1716
- const resolvedProps = new Map<string, any>();
2937
+ const resolvedProps = new Map<string, ResolvedExpressionValue>();
1717
2938
  for (const [key, value] of Object.entries(evaluatedProps)) {
1718
2939
  resolvedProps.set(key, value);
1719
2940
  }
@@ -1729,7 +2950,7 @@ export class ComponentScanner {
1729
2950
  }
1730
2951
  }
1731
2952
 
1732
- const outputProps = { ...props } as Record<string, any>;
2953
+ const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
1733
2954
  delete outputProps.children;
1734
2955
  return {
1735
2956
  type: 'element',
@@ -1748,7 +2969,7 @@ export class ComponentScanner {
1748
2969
  rawTagName = itemValue[rawTagName];
1749
2970
  }
1750
2971
  const tagName = COMPONENT_TO_HTML_MAP[rawTagName] || rawTagName;
1751
- const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue);
2972
+ const itemPropsContext = this.buildItemPropsContext(itemParamName, itemValue, extraContext);
1752
2973
  const props = this.extractPropsFromNode(node, rawTagName, itemPropsContext).props;
1753
2974
 
1754
2975
  // Check for local component expansion
@@ -1766,11 +2987,11 @@ export class ComponentScanner {
1766
2987
  // Check for imported component expansion
1767
2988
  // Skip expansion for simple components that the ui-builder handles directly
1768
2989
  // (e.g., Skeleton uses clsx with props that we can't statically evaluate)
1769
- const SKIP_EXPANSION = ['Skeleton', 'SkeletonText'];
2990
+ const SKIP_EXPANSION = ['Skeleton'];
1770
2991
  const importedFilePath = relativeImports.get(rawTagName);
1771
2992
  if (importedFilePath && !SKIP_EXPANSION.includes(rawTagName)) {
1772
2993
  const evaluatedProps = this.resolveSubstitutionProps(props, itemParamName, itemValue);
1773
- const resolvedProps = new Map<string, any>();
2994
+ const resolvedProps = new Map<string, ResolvedExpressionValue>();
1774
2995
  for (const [key, value] of Object.entries(evaluatedProps)) {
1775
2996
  resolvedProps.set(key, value);
1776
2997
  }
@@ -1780,7 +3001,7 @@ export class ComponentScanner {
1780
3001
  }
1781
3002
  }
1782
3003
 
1783
- const outputProps = { ...props } as Record<string, any>;
3004
+ const outputProps = { ...props } as Record<string, ResolvedExpressionValue>;
1784
3005
  delete outputProps.children;
1785
3006
  return {
1786
3007
  type: 'element',
@@ -1800,7 +3021,7 @@ export class ComponentScanner {
1800
3021
  */
1801
3022
  private expandLocalComponentWithProps(
1802
3023
  componentDef: Node,
1803
- evaluatedProps: Record<string, any>,
3024
+ evaluatedProps: Record<string, ResolvedExpressionValue>,
1804
3025
  localComponents: Map<string, Node>,
1805
3026
  relativeImports: Map<string, string>
1806
3027
  ): JsxNode | null {
@@ -1841,7 +3062,7 @@ export class ComponentScanner {
1841
3062
  if (Node.isParenthesizedExpression(jsxExpr)) {
1842
3063
  jsxExpr = jsxExpr.getExpression();
1843
3064
  }
1844
- if (Node.isJsxElement(jsxExpr) || Node.isJsxSelfClosingElement(jsxExpr)) {
3065
+ if (Node.isJsxElement(jsxExpr) || Node.isJsxSelfClosingElement(jsxExpr) || Node.isJsxFragment(jsxExpr)) {
1845
3066
  jsxToExpand = jsxExpr;
1846
3067
  break;
1847
3068
  }
@@ -1851,7 +3072,8 @@ export class ComponentScanner {
1851
3072
  if (!jsxToExpand) {
1852
3073
  const jsxElements = componentDef.getDescendantsOfKind(SyntaxKind.JsxElement);
1853
3074
  const selfClosing = componentDef.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement);
1854
- const allJsx = [...jsxElements, ...selfClosing];
3075
+ const fragments = componentDef.getDescendantsOfKind(SyntaxKind.JsxFragment);
3076
+ const allJsx = [...jsxElements, ...selfClosing, ...fragments];
1855
3077
  if (allJsx.length > 0) {
1856
3078
  jsxToExpand = allJsx[0];
1857
3079
  for (const el of allJsx) {
@@ -1864,7 +3086,7 @@ export class ComponentScanner {
1864
3086
 
1865
3087
  if (!jsxToExpand) return null;
1866
3088
 
1867
- const propsMap = new Map<string, any>();
3089
+ const propsMap = new Map<string, ResolvedExpressionValue>();
1868
3090
  for (const [key, value] of Object.entries(evaluatedProps)) {
1869
3091
  propsMap.set(key, value);
1870
3092
  }
@@ -1876,7 +3098,45 @@ export class ComponentScanner {
1876
3098
  const nameNode = firstParam.getNameNode?.();
1877
3099
  if (nameNode && Node.isIdentifier(nameNode)) {
1878
3100
  // Function signature uses a plain `props` identifier.
1879
- propsMap.set(nameNode.getText(), { ...evaluatedProps });
3101
+ const paramName = nameNode.getText();
3102
+ propsMap.set(paramName, { ...evaluatedProps });
3103
+
3104
+ // Many components destructure inside the body with defaults, e.g.
3105
+ // function X(props) { const { lines = 3, widths = DEFAULT_WIDTHS } = props; ... }
3106
+ // Treat these destructurings equivalently to in-signature destructuring
3107
+ // so defaults populate propsMap and are available to the JSX tree.
3108
+ const bodyBlock = Node.isFunctionDeclaration(componentDef)
3109
+ || Node.isFunctionExpression(componentDef)
3110
+ || Node.isArrowFunction(componentDef)
3111
+ || Node.isMethodDeclaration(componentDef)
3112
+ ? componentDef.getBody()
3113
+ : undefined;
3114
+ if (bodyBlock && Node.isBlock(bodyBlock)) {
3115
+ for (const stmt of bodyBlock.getStatements()) {
3116
+ if (!Node.isVariableStatement(stmt)) continue;
3117
+ for (const decl of stmt.getDeclarationList().getDeclarations()) {
3118
+ const bindingName = decl.getNameNode();
3119
+ if (!Node.isObjectBindingPattern(bindingName)) continue;
3120
+ const sourceIdent = decl.getInitializer();
3121
+ if (!sourceIdent || sourceIdent.getText() !== paramName) continue;
3122
+ for (const element of bindingName.getElements()) {
3123
+ if (element.getDotDotDotToken()) continue;
3124
+ const sourceKey = element.getPropertyNameNode?.()?.getText() || element.getName();
3125
+ const localName = element.getName();
3126
+ if (Object.prototype.hasOwnProperty.call(evaluatedProps, sourceKey)) {
3127
+ propsMap.set(localName, evaluatedProps[sourceKey]);
3128
+ continue;
3129
+ }
3130
+ const defaultInit = this.unwrapStaticValueExpression(element.getInitializer?.());
3131
+ if (!defaultInit) continue;
3132
+ const defaultValue = this.resolveExpressionValue(defaultInit, propsMap);
3133
+ if (defaultValue !== undefined) {
3134
+ propsMap.set(localName, defaultValue);
3135
+ }
3136
+ }
3137
+ }
3138
+ }
3139
+ }
1880
3140
  } else if (nameNode && Node.isObjectBindingPattern(nameNode)) {
1881
3141
  const consumedKeys = new Set<string>();
1882
3142
  const restBindings: string[] = [];
@@ -1915,7 +3175,7 @@ export class ComponentScanner {
1915
3175
  }
1916
3176
 
1917
3177
  if (restBindings.length > 0) {
1918
- const restObject: Record<string, any> = {};
3178
+ const restObject: Record<string, ResolvedExpressionValue> = {};
1919
3179
  for (const [key, value] of Object.entries(evaluatedProps)) {
1920
3180
  if (!consumedKeys.has(key)) {
1921
3181
  restObject[key] = value;
@@ -1931,9 +3191,14 @@ export class ComponentScanner {
1931
3191
  return this.buildJsxTree(jsxToExpand, localComponents, relativeImports, propsMap);
1932
3192
  }
1933
3193
 
1934
- private getComponentParameterNodes(componentDef: Node): any[] {
1935
- if (typeof (componentDef as any).getParameters === 'function') {
1936
- return (componentDef as any).getParameters();
3194
+ private getComponentParameterNodes(componentDef: Node): import('ts-morph').ParameterDeclaration[] {
3195
+ if (
3196
+ Node.isFunctionDeclaration(componentDef)
3197
+ || Node.isFunctionExpression(componentDef)
3198
+ || Node.isArrowFunction(componentDef)
3199
+ || Node.isMethodDeclaration(componentDef)
3200
+ ) {
3201
+ return componentDef.getParameters();
1937
3202
  }
1938
3203
 
1939
3204
  if (Node.isCallExpression(componentDef)) {
@@ -1949,13 +3214,296 @@ export class ComponentScanner {
1949
3214
 
1950
3215
  /**
1951
3216
  * Returns true if the expanded node's root is a portal wrapper (renders outside the DOM tree).
1952
- * Used to skip components like DialogContent, SelectContent, SheetContent, DropdownMenuContent.
3217
+ * Used to unwrap components like DialogContent, SelectContent, SheetContent, DropdownMenuContent.
1953
3218
  */
1954
3219
  private isPortalRootedNode(node: JsxNode): boolean {
1955
3220
  if (node.type !== 'element') return false;
1956
3221
  return node.tagName.toLowerCase().includes('portal');
1957
3222
  }
1958
3223
 
3224
+ /**
3225
+ * Extracts the renderable content nodes from inside a portal wrapper,
3226
+ * skipping overlay/backdrop elements that are irrelevant in Figma.
3227
+ */
3228
+ private extractPortalContentNodes(portalNode: JsxNode): JsxNode[] {
3229
+ if (portalNode.type !== 'element') return [];
3230
+ const el = portalNode as JsxElement;
3231
+ return (el.children || []).filter((child: JsxNode) => {
3232
+ if (child.type !== 'element') return true;
3233
+ const tag = (child as JsxElement).tagName.toLowerCase();
3234
+ return !tag.includes('overlay') && !tag.includes('backdrop');
3235
+ });
3236
+ }
3237
+
3238
+ /**
3239
+ * Strips portal-specific CSS classes that are meaningless or harmful in Figma:
3240
+ * fixed/absolute positioning, transforms, z-index, state-variant animations,
3241
+ * transition durations, complex calc() max-widths, and responsive max-w variants
3242
+ * (the story's own max-w prop is the authoritative width constraint).
3243
+ *
3244
+ * Called on the extracted portal content node so the renderer sees only the
3245
+ * visual layout classes (grid, gap, padding, border, shadow, bg, etc.).
3246
+ */
3247
+ private stripPortalPositioningClasses(className: string): string {
3248
+ if (!className) return className;
3249
+ return className
3250
+ .split(/\s+/)
3251
+ .filter(cls => {
3252
+ if (!cls) return false;
3253
+ // State-variant animations: data-[state=open]:animate-in etc.
3254
+ if (cls.includes('[state=')) return false;
3255
+ // Arbitrary selector variants: strip SVG-targeting and pointer-events selectors
3256
+ // (e.g. [&_svg]:pointer-events-none, [&_svg]:shrink-0, [&_svg:not([class*='size-'])]:size-4)
3257
+ // but KEEP direct-child layout selectors like [&>*]:w-full, [&>button]:px-4.
3258
+ if (cls.startsWith('[&')) {
3259
+ const selectorMatch = cls.match(/^\[([^\]]+)\]/);
3260
+ const selector = selectorMatch ? selectorMatch[1] : '';
3261
+ if (selector.includes('svg') || selector.includes('pointer-events')) return false;
3262
+ }
3263
+ // Positioning
3264
+ if (cls === 'fixed' || cls === 'absolute' || cls === 'sticky') return false;
3265
+ // Directional offsets
3266
+ if (/^-?(top|left|right|bottom)-/.test(cls)) return false;
3267
+ // CSS transforms
3268
+ if (/^-?translate-/.test(cls)) return false;
3269
+ // Z-index
3270
+ if (/^z-/.test(cls)) return false;
3271
+ // Inset utilities
3272
+ if (/^-?inset/.test(cls)) return false;
3273
+ // Transition duration/ease (irrelevant in Figma)
3274
+ if (/^duration-/.test(cls)) return false;
3275
+ if (/^ease-/.test(cls)) return false;
3276
+ // Complex calc/min/max max-w (can't be evaluated to a pixel value)
3277
+ if (/^max-w-\[(?:calc|min|max)\(/.test(cls)) return false;
3278
+ // Responsive max-w variants — the story's explicit max-w is authoritative
3279
+ if (/^(?:sm|md|lg|xl|2xl):max-w-/.test(cls)) return false;
3280
+ return true;
3281
+ })
3282
+ .join(' ');
3283
+ }
3284
+
3285
+ /**
3286
+ * JS-style truthiness for a statically resolved expression value.
3287
+ * Used by the `&&` / `||` / ternary handlers in JSX expressions to decide
3288
+ * which branch to render. `undefined` means unresolvable, not falsy — the
3289
+ * caller treats unresolvable values separately so the design-system render
3290
+ * doesn't speculate about runtime state.
3291
+ */
3292
+ private isExpressionTruthy(value: unknown): boolean {
3293
+ if (value === undefined || value === null) return false;
3294
+ if (typeof value === 'boolean') return value;
3295
+ if (typeof value === 'number') return value !== 0 && !Number.isNaN(value);
3296
+ if (typeof value === 'string') return value.length > 0;
3297
+ return true;
3298
+ }
3299
+
3300
+ /**
3301
+ * Materialize a chosen JSX-expression branch as scanner children.
3302
+ * If the branch is a JsxElement, build its subtree; otherwise resolve it
3303
+ * as a value and push as text/children. Shared by the `??`/`&&`/`||` and
3304
+ * ternary handlers.
3305
+ */
3306
+ private pushChosenJsxBranch(
3307
+ chosenIn: Node,
3308
+ children: JsxNode[],
3309
+ localComponents: Map<string, Node>,
3310
+ relativeImports: Map<string, string>,
3311
+ propsContext: Map<string, ResolvedExpressionValue>
3312
+ ): void {
3313
+ let chosen: Node = chosenIn;
3314
+ if (Node.isParenthesizedExpression(chosen)) {
3315
+ chosen = chosen.getExpression();
3316
+ }
3317
+ if (Node.isJsxElement(chosen) || Node.isJsxSelfClosingElement(chosen)) {
3318
+ const childNode = this.buildJsxTree(chosen, localComponents, relativeImports, propsContext);
3319
+ if (!(childNode as JsxNode & PortalSkipFlagged).__portalSkip) {
3320
+ children.push(childNode);
3321
+ }
3322
+ return;
3323
+ }
3324
+ const resolved = this.resolveExpressionValue(chosen, propsContext);
3325
+ if (resolved !== undefined && resolved !== null) {
3326
+ this.pushResolvedPropValue(children, resolved);
3327
+ }
3328
+ }
3329
+
3330
+ /**
3331
+ * Walk down through unresolved single-child wrappers at the root of an
3332
+ * expanded tree until we reach a resolved node. Returns `null` when no
3333
+ * resolved inner node is reachable (i.e. the whole tree is opaque).
3334
+ *
3335
+ * Why this exists: when a component imported into a story expands to
3336
+ * something like `<Form><form>...</form></Form>` where `<Form>` is an
3337
+ * alias to an external import (e.g. `const Form = FormProvider`),
3338
+ * `<Form>` looks "unresolved" relative to the story file's imports —
3339
+ * but the inner `<form>` and its descendants are perfectly fine. Without
3340
+ * this unwrap the whole expansion gets discarded and the story tree
3341
+ * keeps the `<SignupForm/>` placeholder with empty children.
3342
+ */
3343
+ private unwrapUnresolvedSingleChildRoots(
3344
+ node: JsxNode,
3345
+ localComponents: Map<string, Node>,
3346
+ relativeImports: Map<string, string>
3347
+ ): JsxNode | null {
3348
+ let current: JsxNode = node;
3349
+ let safety = 16;
3350
+ while (safety-- > 0 && this.isUnresolvedExpandedComponent(current, localComponents, relativeImports)) {
3351
+ if (current.type !== 'element') return null;
3352
+ const el = current as JsxElement;
3353
+ if (!Array.isArray(el.children) || el.children.length !== 1) return null;
3354
+ if (el.children[0].type !== 'element') return null;
3355
+ current = el.children[0];
3356
+ }
3357
+ return current;
3358
+ }
3359
+
3360
+ /**
3361
+ * Detect a pass-through wrapper invocation: the user wrote
3362
+ * `<Wrapper>{singleChild}</Wrapper>` and added no visual className.
3363
+ * Used after `isUnresolvedExpandedComponent` confirms the wrapper's
3364
+ * implementation expands to an unresolvable Slot-style root, so we
3365
+ * can drop the wrapper and render the inner primitive directly.
3366
+ */
3367
+ /**
3368
+ * Pre-analyse a component's body for `<*.Provider value={{ ... }}>`
3369
+ * wrappers and return the set of prop names that get cascaded through.
3370
+ *
3371
+ * Mirrors React's Context.Provider semantics statically — the canonical
3372
+ * shadcn pattern is `<ToggleGroup variant={x} size={y}><...item.../>...</ToggleGroup>`
3373
+ * where ToggleGroup wraps its children in `<ToggleGroupContext.Provider
3374
+ * value={{ variant, size }}>{children}</...>`. At runtime descendants
3375
+ * read those values via `useContext`. Statically we can't simulate
3376
+ * `useContext`, but we CAN detect the wrapping Provider's value-keys and
3377
+ * inject the invocation's matching props into descendant invocations.
3378
+ *
3379
+ * Cached per component name — the body walk runs once.
3380
+ *
3381
+ * Returns an empty set when the component has no Provider wrapper or
3382
+ * the value isn't an object literal we can statically analyse.
3383
+ */
3384
+ // Shared "no cascade" sentinel — avoids allocating a new empty Set when a
3385
+ // component isn't found (e.g. an imported component whose source file
3386
+ // doesn't load). Read-only by convention; callers only check `.size`.
3387
+ private static readonly EMPTY_PROVIDER_CASCADE_NAMES: Set<string> = new Set();
3388
+
3389
+ private detectProviderCascadeNamesForComponent(
3390
+ componentName: string,
3391
+ localComponents: Map<string, Node>,
3392
+ relativeImports: Map<string, string>
3393
+ ): Set<string> {
3394
+ const bodyNode = this.findComponentDefinitionNode(componentName, localComponents, relativeImports);
3395
+ if (!bodyNode) return ComponentScanner.EMPTY_PROVIDER_CASCADE_NAMES;
3396
+ if (this.providerCascadeNamesCache.has(bodyNode)) {
3397
+ return this.providerCascadeNamesCache.get(bodyNode)!;
3398
+ }
3399
+ const result = new Set<string>();
3400
+ // Cache by Node identity so different `Group` functions in unrelated
3401
+ // source files don't collide. Cache early to short-circuit recursion.
3402
+ this.providerCascadeNamesCache.set(bodyNode, result);
3403
+
3404
+ bodyNode.forEachDescendant((node) => {
3405
+ if (!Node.isJsxOpeningElement(node) && !Node.isJsxSelfClosingElement(node)) return;
3406
+ const tagText = node.getTagNameNode().getText();
3407
+ if (!tagText.endsWith('.Provider')) return;
3408
+ const attrs = node.getAttributes();
3409
+ for (const attr of attrs) {
3410
+ if (!Node.isJsxAttribute(attr)) continue;
3411
+ const nameNode = attr.getNameNode();
3412
+ if (nameNode.getText() !== 'value') continue;
3413
+ const init = attr.getInitializer();
3414
+ if (!init || !Node.isJsxExpression(init)) continue;
3415
+ const expr = init.getExpression();
3416
+ if (!expr || !Node.isObjectLiteralExpression(expr)) continue;
3417
+ for (const prop of expr.getProperties()) {
3418
+ if (Node.isShorthandPropertyAssignment(prop)) {
3419
+ result.add(prop.getName());
3420
+ } else if (Node.isPropertyAssignment(prop)) {
3421
+ const propNameNode = prop.getNameNode();
3422
+ const propName = propNameNode.getText();
3423
+ if (propName) result.add(propName);
3424
+ }
3425
+ }
3426
+ }
3427
+ });
3428
+ return result;
3429
+ }
3430
+
3431
+ /**
3432
+ * Locate a component's function-definition Node, whether the component
3433
+ * is defined locally in the same file (in `localComponents`) or imported
3434
+ * from another module (looked up via `relativeImports`).
3435
+ */
3436
+ private findComponentDefinitionNode(
3437
+ componentName: string,
3438
+ localComponents: Map<string, Node>,
3439
+ relativeImports: Map<string, string>
3440
+ ): Node | null {
3441
+ const localDef = localComponents.get(componentName);
3442
+ if (localDef) return localDef;
3443
+ const filePath = relativeImports.get(componentName);
3444
+ if (!filePath) return null;
3445
+ const cacheKey = `${filePath}:${componentName}`;
3446
+ let sourceFile: SourceFile | undefined;
3447
+ const cached = this.importedComponentCache.get(cacheKey);
3448
+ if (cached) {
3449
+ sourceFile = cached.sourceFile;
3450
+ } else {
3451
+ try {
3452
+ sourceFile = this.project.addSourceFileAtPath(filePath);
3453
+ this.importedComponentCache.set(cacheKey, { sourceFile, componentName });
3454
+ } catch (_err) {
3455
+ return null;
3456
+ }
3457
+ }
3458
+ if (!sourceFile) return null;
3459
+ for (const func of sourceFile.getFunctions()) {
3460
+ if (func.getName() === componentName) return func;
3461
+ }
3462
+ for (const variable of sourceFile.getVariableDeclarations()) {
3463
+ if (variable.getName() !== componentName) continue;
3464
+ const init = variable.getInitializer();
3465
+ if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) return init;
3466
+ }
3467
+ return null;
3468
+ }
3469
+
3470
+ /**
3471
+ * Recursively push `child` into `out`, expanding any transparent React
3472
+ * Context Providers (`*.Provider`) into their own children. Providers
3473
+ * exist only to inject context — they produce no DOM. Single-child
3474
+ * Providers are already stripped at the buildJsxTree return point;
3475
+ * this handles the multi-child case (e.g. a `ToggleGroup` that wraps
3476
+ * multiple items in a `*.Provider` to cascade `variant`/`size`). The
3477
+ * skipped wrapper would otherwise become an unsized inner frame that
3478
+ * collapses the parent's auto-layout — see toggle-group rendering bug.
3479
+ */
3480
+ private pushFlatteningTransparentProvider(out: JsxNode[], child: JsxNode): void {
3481
+ if (
3482
+ child.type === 'element'
3483
+ && child.tagName.endsWith('.Provider')
3484
+ && !(typeof child.props?.className === 'string' && child.props.className.trim().length > 0)
3485
+ ) {
3486
+ for (const grandchild of child.children) {
3487
+ this.pushFlatteningTransparentProvider(out, grandchild);
3488
+ }
3489
+ return;
3490
+ }
3491
+ out.push(child);
3492
+ }
3493
+
3494
+ private isPassThroughInvocation(
3495
+ props: Record<string, ResolvedExpressionValue>,
3496
+ children: JsxNode[]
3497
+ ): boolean {
3498
+ if (children.length !== 1) return false;
3499
+ if (children[0].type !== 'element') return false;
3500
+ const className = typeof props.className === 'string' ? props.className.trim() : '';
3501
+ if (className.length > 0) return false;
3502
+ const styleProp = typeof props.style === 'string' ? props.style.trim() : '';
3503
+ if (styleProp.length > 0) return false;
3504
+ return true;
3505
+ }
3506
+
1959
3507
  private isUnresolvedExpandedComponent(
1960
3508
  node: JsxNode,
1961
3509
  localComponents: Map<string, Node>,
@@ -1997,7 +3545,12 @@ export class ComponentScanner {
1997
3545
  * Substitute an expression with values from the map item.
1998
3546
  * Handles: feature.name, feature.description, feature.free, etc.
1999
3547
  */
2000
- private substituteExpression(expr: Node, itemParamName: string, itemValue: any): any {
3548
+ private substituteExpression(
3549
+ expr: Node,
3550
+ itemParamName: string,
3551
+ itemValue: ResolvedExpressionValue,
3552
+ extraContext?: Map<string, ResolvedExpressionValue>
3553
+ ): ResolvedExpressionValue {
2001
3554
  const text = expr.getText();
2002
3555
 
2003
3556
  // Handle property access: feature.name, feature.description
@@ -2008,6 +3561,13 @@ export class ComponentScanner {
2008
3561
  if (objExpr.getText() === itemParamName && typeof itemValue === 'object') {
2009
3562
  return itemValue[propName];
2010
3563
  }
3564
+ // Outer context lookup: `propsContext.someObject.prop`
3565
+ if (extraContext && Node.isIdentifier(objExpr)) {
3566
+ const baseValue = extraContext.get(objExpr.getText());
3567
+ if (baseValue != null && typeof baseValue === 'object' && propName in baseValue) {
3568
+ return baseValue[propName];
3569
+ }
3570
+ }
2011
3571
  }
2012
3572
 
2013
3573
  // Handle simple variable reference: feature (if it's the whole item)
@@ -2022,6 +3582,36 @@ export class ComponentScanner {
2022
3582
  if (val !== undefined) return val;
2023
3583
  }
2024
3584
 
3585
+ // Handle identifiers bound in outer propsContext (e.g. `index`, `widths`).
3586
+ if (extraContext && Node.isIdentifier(expr) && extraContext.has(text)) {
3587
+ return extraContext.get(text);
3588
+ }
3589
+
3590
+ // Fall back to the scanner's general expression resolver so complex
3591
+ // expressions — property/element access, arithmetic, ternaries, clsx —
3592
+ // can evaluate against the combined context.
3593
+ if (extraContext || itemValue != null) {
3594
+ const merged = new Map<string, ResolvedExpressionValue>();
3595
+ if (extraContext) for (const [k, v] of extraContext) merged.set(k, v);
3596
+ if (itemParamName && itemValue != null) {
3597
+ if (itemParamName === '__destructured__' && typeof itemValue === 'object') {
3598
+ for (const [k, v] of Object.entries(itemValue)) merged.set(k, v);
3599
+ } else {
3600
+ merged.set(itemParamName, itemValue);
3601
+ }
3602
+ }
3603
+ const resolved = this.resolveExpressionValue(expr, merged);
3604
+ if (resolved !== undefined) return resolved;
3605
+ }
3606
+
3607
+ if (Node.isBinaryExpression(expr) && expr.getOperatorToken().getText() === '??') {
3608
+ const leftValue = this.substituteExpression(expr.getLeft(), itemParamName, itemValue, extraContext);
3609
+ if (leftValue !== null && leftValue !== undefined) {
3610
+ return leftValue;
3611
+ }
3612
+ return this.substituteExpression(expr.getRight(), itemParamName, itemValue, extraContext);
3613
+ }
3614
+
2025
3615
  // Handle conditional: feature.description && <p>...</p>
2026
3616
  if (Node.isBinaryExpression(expr)) {
2027
3617
  const left = expr.getLeft();
@@ -2029,7 +3619,7 @@ export class ComponentScanner {
2029
3619
  const right = expr.getRight();
2030
3620
 
2031
3621
  if (operator === '&&') {
2032
- const leftValue = this.substituteExpression(left, itemParamName, itemValue);
3622
+ const leftValue = this.substituteExpression(left, itemParamName, itemValue, extraContext);
2033
3623
  if (leftValue) {
2034
3624
  // If right side is JSX, we'd need to render it - for now just return text
2035
3625
  if (Node.isJsxElement(right) || Node.isJsxSelfClosingElement(right)) {
@@ -2043,7 +3633,7 @@ export class ComponentScanner {
2043
3633
  for (const exprNode of exprNodes) {
2044
3634
  const innerExpr = exprNode.getExpression();
2045
3635
  if (innerExpr) {
2046
- const val = this.substituteExpression(innerExpr, itemParamName, itemValue);
3636
+ const val = this.substituteExpression(innerExpr, itemParamName, itemValue, extraContext);
2047
3637
  if (val !== null && val !== undefined) {
2048
3638
  textContent += String(val) + ' ';
2049
3639
  }
@@ -2051,7 +3641,7 @@ export class ComponentScanner {
2051
3641
  }
2052
3642
  return textContent.trim();
2053
3643
  }
2054
- return this.substituteExpression(right, itemParamName, itemValue);
3644
+ return this.substituteExpression(right, itemParamName, itemValue, extraContext);
2055
3645
  }
2056
3646
  return null;
2057
3647
  }
@@ -2060,7 +3650,7 @@ export class ComponentScanner {
2060
3650
  // Handle ternary: feature.free ? <Check /> : <Cross />
2061
3651
  if (Node.isConditionalExpression(expr)) {
2062
3652
  const condition = expr.getCondition();
2063
- const condValue = this.substituteExpression(condition, itemParamName, itemValue);
3653
+ const condValue = this.substituteExpression(condition, itemParamName, itemValue, extraContext);
2064
3654
  // Return indication of which branch (for components like Check/Cross)
2065
3655
  if (condValue) {
2066
3656
  return 'true';
@@ -2086,7 +3676,7 @@ export class ComponentScanner {
2086
3676
  componentName: string,
2087
3677
  filePath: string,
2088
3678
  relativeImports: Map<string, string>,
2089
- resolvedProps: Map<string, any> = new Map()
3679
+ resolvedProps: Map<string, ResolvedExpressionValue> = new Map()
2090
3680
  ): JsxNode | null {
2091
3681
  try {
2092
3682
  // Check cache first
@@ -2124,7 +3714,7 @@ export class ComponentScanner {
2124
3714
  sourceFile: SourceFile,
2125
3715
  componentName: string,
2126
3716
  relativeImports: Map<string, string>,
2127
- resolvedProps: Map<string, any> = new Map()
3717
+ resolvedProps: Map<string, ResolvedExpressionValue> = new Map()
2128
3718
  ): JsxNode | null {
2129
3719
  const localComponents = this.collectLocalComponentsFromFile(sourceFile);
2130
3720
  const mergedRelativeImports = new Map(relativeImports);
@@ -2248,15 +3838,35 @@ export class ComponentScanner {
2248
3838
  * Parse an ObjectLiteralExpression AST node into a plain JS object.
2249
3839
  * Handles string, number, boolean, nested object, and array values.
2250
3840
  */
2251
- private parseObjectLiteralExpression(expr: Node): Record<string, any> {
2252
- const result: Record<string, any> = {};
2253
- for (const prop of (expr as any).getProperties?.() ?? []) {
3841
+ private parseObjectLiteralExpression(
3842
+ expr: Node,
3843
+ propsContext: Map<string, ResolvedExpressionValue> = new Map()
3844
+ ): Record<string, ResolvedExpressionValue> {
3845
+ const result: Record<string, ResolvedExpressionValue> = {};
3846
+ if (!Node.isObjectLiteralExpression(expr)) return result;
3847
+ for (const prop of expr.getProperties()) {
3848
+ if (Node.isShorthandPropertyAssignment(prop)) {
3849
+ // `{ color }` shorthand: resolve the identifier against the callback's
3850
+ // propsContext (map iteration, etc.) so values from destructured items
3851
+ // are captured. Falls back to the literal identifier text if unresolved.
3852
+ const key = prop.getName();
3853
+ if (!key) continue;
3854
+ const nameNode = prop.getNameNode();
3855
+ const resolved = nameNode
3856
+ ? this.resolveExpressionValue(nameNode, propsContext)
3857
+ : undefined;
3858
+ if (resolved !== undefined) {
3859
+ result[key] = resolved;
3860
+ }
3861
+ continue;
3862
+ }
2254
3863
  if (!Node.isPropertyAssignment(prop)) continue;
2255
- const key = (prop as any).getName?.();
3864
+ const key = prop.getName();
2256
3865
  if (!key) continue;
2257
- const val = (prop as any).getInitializer?.();
3866
+ const val = prop.getInitializer();
2258
3867
  if (!val) continue;
2259
- result[key] = this.parseLiteralValue(val);
3868
+ const resolved = this.resolveExpressionValue(val, propsContext);
3869
+ result[key] = resolved !== undefined ? resolved : this.parseLiteralValue(val);
2260
3870
  }
2261
3871
  return result;
2262
3872
  }
@@ -2264,7 +3874,7 @@ export class ComponentScanner {
2264
3874
  /**
2265
3875
  * Parse a literal AST node into a plain JS value (string, number, boolean, array, object).
2266
3876
  */
2267
- private parseLiteralValue(val: Node): any {
3877
+ private parseLiteralValue(val: Node): ResolvedExpressionValue {
2268
3878
  const unwrapped = this.unwrapStaticValueExpression(val);
2269
3879
  if (unwrapped && unwrapped !== val) {
2270
3880
  return this.parseLiteralValue(unwrapped);
@@ -2275,7 +3885,7 @@ export class ComponentScanner {
2275
3885
  if (text === 'true') return true;
2276
3886
  if (text === 'false') return false;
2277
3887
  if (Node.isArrayLiteralExpression(val)) {
2278
- return (val as any).getElements().map((el: Node) => this.parseLiteralValue(el));
3888
+ return val.getElements().map((el) => this.parseLiteralValue(el));
2279
3889
  }
2280
3890
  if (Node.isObjectLiteralExpression(val)) {
2281
3891
  return this.parseObjectLiteralExpression(val);
@@ -2283,7 +3893,7 @@ export class ComponentScanner {
2283
3893
  return text;
2284
3894
  }
2285
3895
 
2286
- private coerceBoolean(value: any): boolean | null {
3896
+ private coerceBoolean(value: unknown): boolean | null {
2287
3897
  if (typeof value === 'boolean') return value;
2288
3898
  if (typeof value === 'number') return value !== 0;
2289
3899
  if (typeof value === 'string') {
@@ -2298,7 +3908,60 @@ export class ComponentScanner {
2298
3908
  return null;
2299
3909
  }
2300
3910
 
2301
- private resolveExpressionValue(expr: Node, propsContext: Map<string, any>): any {
3911
+ /**
3912
+ * Resolve an identifier against function-body / block-scoped `const`
3913
+ * declarations, walking ancestor scopes from the reference site outward.
3914
+ * Returns `undefined` if no scope-local declaration is found so the caller
3915
+ * can fall back to module-level lookup.
3916
+ *
3917
+ * Necessary because `SourceFile.getVariableDeclaration` only sees top-level
3918
+ * statements — anything declared inside a function body (the common
3919
+ * `const dotClass = sourceLabel === "db" ? "..." : "..."` pattern used to
3920
+ * derive Tailwind classes from a prop value) is invisible to it. Without
3921
+ * this helper such derived classes silently collapse to "no class" in the
3922
+ * scanner output even though the runtime evaluates them fine.
3923
+ *
3924
+ * Forward references (a `const` declared AFTER the reference site in the
3925
+ * same scope) are skipped to match real JavaScript TDZ semantics.
3926
+ *
3927
+ * Cycle protection via `localResolutionStack` — re-entering the same
3928
+ * `${filePath}:${name}` returns `undefined` instead of recursing forever.
3929
+ */
3930
+ private resolveLocalIdentifier(
3931
+ name: string,
3932
+ fromNode: Node,
3933
+ propsContext: Map<string, ResolvedExpressionValue>,
3934
+ ): ResolvedExpressionValue {
3935
+ const filePath = fromNode.getSourceFile().getFilePath();
3936
+ const stackKey = `${filePath}:${name}`;
3937
+ if (this.localResolutionStack.has(stackKey)) return undefined;
3938
+ const fromStart = fromNode.getStart();
3939
+ let cursor: Node | undefined = fromNode.getParent();
3940
+ while (cursor && !Node.isSourceFile(cursor)) {
3941
+ if (Node.isBlock(cursor) || Node.isCaseClause(cursor) || Node.isDefaultClause(cursor)) {
3942
+ for (const stmt of cursor.getStatements()) {
3943
+ if (!Node.isVariableStatement(stmt)) continue;
3944
+ // Skip forward references — match TDZ semantics.
3945
+ if (stmt.getEnd() > fromStart) continue;
3946
+ for (const decl of stmt.getDeclarationList().getDeclarations()) {
3947
+ if (decl.getName() !== name) continue;
3948
+ const init = decl.getInitializer();
3949
+ if (!init) return undefined;
3950
+ this.localResolutionStack.add(stackKey);
3951
+ try {
3952
+ return this.resolveExpressionValue(init, propsContext);
3953
+ } finally {
3954
+ this.localResolutionStack.delete(stackKey);
3955
+ }
3956
+ }
3957
+ }
3958
+ }
3959
+ cursor = cursor.getParent();
3960
+ }
3961
+ return undefined;
3962
+ }
3963
+
3964
+ private resolveExpressionValue(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): ResolvedExpressionValue {
2302
3965
  if (Node.isParenthesizedExpression(expr)) {
2303
3966
  return this.resolveExpressionValue(expr.getExpression(), propsContext);
2304
3967
  }
@@ -2313,22 +3976,64 @@ export class ComponentScanner {
2313
3976
  if (exprText === 'false') return false;
2314
3977
  if (Node.isIdentifier(expr)) {
2315
3978
  if (propsContext.has(exprText)) return propsContext.get(exprText);
2316
- // Resolve module-level const string (e.g. const navLinkCls = "...")
3979
+ // Resolve function-body-local consts FIRST so they shadow module-level
3980
+ // names — same rule JavaScript scoping enforces. Without this, a
3981
+ // pattern like `const dotClass = sourceLabel === "db" ? "bg-emerald-500"
3982
+ // : "bg-sky-400"` followed by `<span className={cn(base, dotClass)}/>`
3983
+ // collapses to just `base` and the conditional color is dropped.
3984
+ const localValue = this.resolveLocalIdentifier(exprText, expr, propsContext);
3985
+ if (localValue !== undefined) return localValue;
3986
+ // Resolve module-level consts: strings, object literals (e.g.
3987
+ // `const WIDTH_CLASSNAMES = {...} as const;`), and array literals
3988
+ // (e.g. `const DEFAULT_WIDTHS = [...]`). Returning the parsed value
3989
+ // lets downstream element-access / property-access resolve correctly.
2317
3990
  const varDecl = expr.getSourceFile().getVariableDeclaration(exprText);
2318
3991
  if (varDecl) {
2319
3992
  const init = varDecl.getInitializer();
2320
- if (init && Node.isStringLiteral(init)) return init.getLiteralValue();
3993
+ if (init) {
3994
+ const unwrapped = this.unwrapStaticValueExpression(init) || init;
3995
+ if (Node.isStringLiteral(unwrapped) || Node.isNoSubstitutionTemplateLiteral(unwrapped)) {
3996
+ return unwrapped.getLiteralValue();
3997
+ }
3998
+ if (Node.isNumericLiteral(unwrapped)) return unwrapped.getLiteralValue();
3999
+ if (Node.isArrayLiteralExpression(unwrapped)) {
4000
+ return this.parseArrayLiteral(unwrapped);
4001
+ }
4002
+ if (Node.isObjectLiteralExpression(unwrapped)) {
4003
+ return this.parseObjectLiteralExpression(unwrapped, propsContext);
4004
+ }
4005
+ }
2321
4006
  }
2322
4007
  return undefined;
2323
4008
  }
2324
4009
  if (Node.isPropertyAccessExpression(expr)) {
2325
4010
  const baseExpr = expr.getExpression();
2326
4011
  const propName = expr.getName();
2327
- if (Node.isIdentifier(baseExpr) && propsContext.has(baseExpr.getText())) {
2328
- const baseValue = propsContext.get(baseExpr.getText());
2329
- if (baseValue && typeof baseValue === 'object' && propName in baseValue) {
2330
- return (baseValue as any)[propName];
4012
+ const baseValue = this.resolveExpressionValue(baseExpr, propsContext);
4013
+ if (baseValue != null) {
4014
+ if (Array.isArray(baseValue) && propName === 'length') return baseValue.length;
4015
+ if (typeof baseValue === 'object' && propName in baseValue) {
4016
+ return baseValue[propName];
2331
4017
  }
4018
+ if (typeof baseValue === 'string') {
4019
+ if (propName === 'length') return baseValue.length;
4020
+ }
4021
+ }
4022
+ return undefined;
4023
+ }
4024
+ if (Node.isElementAccessExpression(expr)) {
4025
+ // `WIDTH_CLASSNAMES[width]`, `widths[index]`, `widths[index % N]`, etc.
4026
+ const baseExpr = expr.getExpression();
4027
+ const argExpr = expr.getArgumentExpression();
4028
+ if (!argExpr) return undefined;
4029
+ const baseValue = this.resolveExpressionValue(baseExpr, propsContext);
4030
+ const argValue = this.resolveExpressionValue(argExpr, propsContext);
4031
+ if (baseValue == null || argValue == null) return undefined;
4032
+ if (Array.isArray(baseValue) && typeof argValue === 'number') {
4033
+ return baseValue[argValue];
4034
+ }
4035
+ if (typeof baseValue === 'object' && (typeof argValue === 'string' || typeof argValue === 'number')) {
4036
+ return baseValue[argValue as string];
2332
4037
  }
2333
4038
  return undefined;
2334
4039
  }
@@ -2368,17 +4073,82 @@ export class ComponentScanner {
2368
4073
  if (leftBool === false) return this.resolveExpressionValue(expr.getRight(), propsContext);
2369
4074
  return undefined;
2370
4075
  }
4076
+ if (op === '??') {
4077
+ const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
4078
+ if (leftValue !== undefined && leftValue !== null) return leftValue;
4079
+ return this.resolveExpressionValue(expr.getRight(), propsContext);
4080
+ }
2371
4081
  if (op === '+') {
2372
4082
  const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
2373
4083
  const right = this.resolveExpressionValue(expr.getRight(), propsContext);
2374
4084
  if (left === undefined || right === undefined) return undefined;
4085
+ if (typeof left === 'number' && typeof right === 'number') return left + right;
2375
4086
  return String(left) + String(right);
2376
4087
  }
4088
+ if (op === '-' || op === '*' || op === '/' || op === '%') {
4089
+ const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
4090
+ const right = this.resolveExpressionValue(expr.getRight(), propsContext);
4091
+ if (typeof left !== 'number' || typeof right !== 'number') return undefined;
4092
+ if (op === '-') return left - right;
4093
+ if (op === '*') return left * right;
4094
+ if (op === '/') return right === 0 ? undefined : left / right;
4095
+ return right === 0 ? undefined : left % right;
4096
+ }
4097
+ }
4098
+ // Method-call expressions on resolvable bases — covers the common
4099
+ // story-args pattern `{rewardSol.toFixed(4)} SOL`. Without this the
4100
+ // expression returns undefined and the JSX walker drops it from
4101
+ // the captured tree, so a `<RewardDisplay rewardSol={0.1482}>` story
4102
+ // renders just " SOL" with the number missing. Limited to Number /
4103
+ // String built-in methods that have an obvious string output — we
4104
+ // are NOT a JS evaluator, just covering the high-traffic cases.
4105
+ if (Node.isCallExpression(expr)) {
4106
+ const calleeExpr = expr.getExpression();
4107
+ if (Node.isPropertyAccessExpression(calleeExpr)) {
4108
+ const methodName = calleeExpr.getName();
4109
+ const baseValue = this.resolveExpressionValue(calleeExpr.getExpression(), propsContext);
4110
+ if (baseValue == null) return undefined;
4111
+ const argValues = expr.getArguments().map(a => this.resolveExpressionValue(a, propsContext));
4112
+ const NUMBER_METHODS: Record<string, boolean> = { toFixed: true, toString: true, toPrecision: true };
4113
+ const STRING_METHODS: Record<string, boolean> = { toString: true, toUpperCase: true, toLowerCase: true, trim: true };
4114
+ if (typeof baseValue === 'number' && NUMBER_METHODS[methodName]) {
4115
+ const arg = argValues[0];
4116
+ try {
4117
+ if (methodName === 'toFixed' && typeof arg === 'number') return baseValue.toFixed(arg);
4118
+ if (methodName === 'toPrecision' && typeof arg === 'number') return baseValue.toPrecision(arg);
4119
+ if (methodName === 'toString') return String(baseValue);
4120
+ } catch (_e) { return undefined; }
4121
+ }
4122
+ if (typeof baseValue === 'string' && STRING_METHODS[methodName]) {
4123
+ if (methodName === 'toUpperCase') return baseValue.toUpperCase();
4124
+ if (methodName === 'toLowerCase') return baseValue.toLowerCase();
4125
+ if (methodName === 'trim') return baseValue.trim();
4126
+ if (methodName === 'toString') return baseValue;
4127
+ }
4128
+ } else if (Node.isIdentifier(calleeExpr)) {
4129
+ // User-defined function call (e.g. `truncateHash(block.blockHash)`,
4130
+ // `formatTimestamp(block.timestamp)`). We can't execute the
4131
+ // function — that'd require evaluating arbitrary JS — but we
4132
+ // CAN resolve the first argument and return it as a graceful
4133
+ // fallback. Rendering "DKjW9hX6dqBE6aDgqdX7Ytqa…" as-is is
4134
+ // better than rendering an empty cell. If the user's function
4135
+ // does important formatting (e.g. truncation) the value will
4136
+ // look longer than the runtime, but it's visible and obviously
4137
+ // points at the right field. Skip well-known *side-effect*
4138
+ // helpers like `console.*` so we don't surface noise.
4139
+ const fnName = calleeExpr.getText();
4140
+ if (fnName === 'console' || fnName.startsWith('use')) return undefined;
4141
+ const firstArg = expr.getArguments()[0];
4142
+ if (firstArg) {
4143
+ const resolved = this.resolveExpressionValue(firstArg, propsContext);
4144
+ if (resolved !== undefined && resolved !== null) return resolved;
4145
+ }
4146
+ }
2377
4147
  }
2378
4148
  return undefined;
2379
4149
  }
2380
4150
 
2381
- private resolveClassNameExpression(expr: Node, propsContext: Map<string, any>): string {
4151
+ private resolveClassNameExpression(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): string {
2382
4152
  if (Node.isParenthesizedExpression(expr)) {
2383
4153
  return this.resolveClassNameExpression(expr.getExpression(), propsContext);
2384
4154
  }
@@ -2386,15 +4156,15 @@ export class ComponentScanner {
2386
4156
  return expr.getLiteralValue();
2387
4157
  }
2388
4158
  if (Node.isTemplateExpression(expr)) {
2389
- // Use compilerNode.text to get the raw literal content of template head/middle/tail
2390
- const parts: string[] = [(expr.getHead().compilerNode as any).text ?? ''];
4159
+ // Use getLiteralText() to get the raw literal content of template head/middle/tail.
4160
+ const parts: string[] = [expr.getHead().getLiteralText() || ''];
2391
4161
  for (const span of expr.getTemplateSpans()) {
2392
4162
  parts.push(this.resolveClassNameExpression(span.getExpression(), propsContext));
2393
- parts.push((span.getLiteral().compilerNode as any).text ?? '');
4163
+ parts.push(span.getLiteral().getLiteralText() || '');
2394
4164
  }
2395
4165
  return parts.join(' ').trim().replace(/\s+/g, ' ');
2396
4166
  }
2397
- if (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) {
4167
+ if (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr) || Node.isElementAccessExpression(expr)) {
2398
4168
  const resolved = this.resolveExpressionValue(expr, propsContext);
2399
4169
  return typeof resolved === 'string' ? resolved : '';
2400
4170
  }
@@ -2403,9 +4173,11 @@ export class ComponentScanner {
2403
4173
  const conditionBool = this.coerceBoolean(conditionValue);
2404
4174
  if (conditionBool === true) return this.resolveClassNameExpression(expr.getWhenTrue(), propsContext);
2405
4175
  if (conditionBool === false) return this.resolveClassNameExpression(expr.getWhenFalse(), propsContext);
2406
- const fallbackClasses: string[] = [];
2407
- this.extractClassesFromExpression(expr, fallbackClasses);
2408
- return [...new Set(fallbackClasses)].join(' ');
4176
+ // Unresolvable condition: prefer the "else" branch so components render in their
4177
+ // neutral/rest state (e.g. inactive nav links without forced `bg-muted`). Active /
4178
+ // hover / focus / error styles are expected to live on the truthy branch and
4179
+ // surface through the dedicated state preview blocks, not the default render.
4180
+ return this.resolveClassNameExpression(expr.getWhenFalse(), propsContext);
2409
4181
  }
2410
4182
  if (Node.isBinaryExpression(expr)) {
2411
4183
  const op = expr.getOperatorToken().getText();
@@ -2420,6 +4192,13 @@ export class ComponentScanner {
2420
4192
  if (left.trim()) return left;
2421
4193
  return this.resolveClassNameExpression(expr.getRight(), propsContext);
2422
4194
  }
4195
+ if (op === '??') {
4196
+ const leftValue = this.resolveExpressionValue(expr.getLeft(), propsContext);
4197
+ if (leftValue !== undefined && leftValue !== null) {
4198
+ return this.resolveClassNameExpression(expr.getLeft(), propsContext);
4199
+ }
4200
+ return this.resolveClassNameExpression(expr.getRight(), propsContext);
4201
+ }
2423
4202
  if (op === '+') {
2424
4203
  const left = this.resolveClassNameExpression(expr.getLeft(), propsContext);
2425
4204
  const right = this.resolveClassNameExpression(expr.getRight(), propsContext);
@@ -2455,12 +4234,107 @@ export class ComponentScanner {
2455
4234
  const value = this.resolveClassNameExpression(arg, propsContext).trim();
2456
4235
  if (value) parts.push(value);
2457
4236
  }
2458
- return parts.join(' ').trim();
4237
+ return resolveClassConflicts(parts.join(' '));
2459
4238
  }
4239
+ // Try to resolve as a CVA variant function call (e.g., alertVariants({ variant }))
4240
+ const cvaResult = this.resolveCvaFunctionCall(expr, propsContext);
4241
+ if (cvaResult !== null) return cvaResult;
2460
4242
  }
2461
4243
  return '';
2462
4244
  }
2463
4245
 
4246
+ /**
4247
+ * Resolve a CVA variant function call like `alertVariants({ variant })` to its
4248
+ * combined class string. Looks up the function definition in the same source file,
4249
+ * then merges base classes with the appropriate variant classes.
4250
+ */
4251
+ private resolveCvaFunctionCall(callExpr: Node, propsContext: Map<string, ResolvedExpressionValue>): string | null {
4252
+ if (!Node.isCallExpression(callExpr)) return null;
4253
+ const funcName = callExpr.getExpression().getText();
4254
+ const sourceFile = callExpr.getSourceFile();
4255
+
4256
+ // Find `const funcName = cva(...)` in the source file
4257
+ for (const varStmt of sourceFile.getVariableStatements()) {
4258
+ for (const decl of varStmt.getDeclarationList().getDeclarations()) {
4259
+ if (decl.getName() !== funcName) continue;
4260
+ const init = decl.getInitializer();
4261
+ if (!init || !Node.isCallExpression(init)) continue;
4262
+ if (init.getExpression().getText() !== 'cva') continue;
4263
+
4264
+ const cvaArgs = init.getArguments();
4265
+ if (cvaArgs.length === 0) return '';
4266
+
4267
+ const baseClasses = this.extractStringValue(cvaArgs[0]);
4268
+ const classes: string[] = baseClasses ? [baseClasses] : [];
4269
+
4270
+ if (cvaArgs.length >= 2 && Node.isObjectLiteralExpression(cvaArgs[1])) {
4271
+ const configObj = cvaArgs[1];
4272
+
4273
+ // Parse the call argument object e.g. { variant } or { variant: "destructive" }
4274
+ const callArgs = callExpr.getArguments();
4275
+ const requestedVariants: Record<string, string> = {};
4276
+ if (callArgs.length > 0 && Node.isObjectLiteralExpression(callArgs[0])) {
4277
+ for (const prop of callArgs[0].getProperties()) {
4278
+ if (Node.isPropertyAssignment(prop)) {
4279
+ const key = prop.getName();
4280
+ const valInit = prop.getInitializer();
4281
+ if (!valInit) continue;
4282
+ const resolved = this.resolveExpressionValue(valInit, propsContext);
4283
+ if (typeof resolved === 'string') requestedVariants[key] = resolved;
4284
+ else if (Node.isStringLiteral(valInit)) requestedVariants[key] = valInit.getLiteralValue();
4285
+ } else if (Node.isShorthandPropertyAssignment(prop)) {
4286
+ // { variant } shorthand — look up value from propsContext
4287
+ const key = prop.getName();
4288
+ const resolved = propsContext.get(key);
4289
+ if (typeof resolved === 'string') requestedVariants[key] = resolved;
4290
+ }
4291
+ }
4292
+ }
4293
+
4294
+ // Extract defaultVariants
4295
+ const defaultVariants: Record<string, string> = {};
4296
+ const defaultVariantsProp = configObj.getProperty('defaultVariants');
4297
+ if (defaultVariantsProp && Node.isPropertyAssignment(defaultVariantsProp)) {
4298
+ const defaultObj = defaultVariantsProp.getInitializer();
4299
+ if (defaultObj && Node.isObjectLiteralExpression(defaultObj)) {
4300
+ for (const prop of defaultObj.getProperties()) {
4301
+ if (!Node.isPropertyAssignment(prop)) continue;
4302
+ const val = this.extractStringValue(prop.getInitializer()!);
4303
+ defaultVariants[prop.getName()] = val.replace(/['"]/g, '');
4304
+ }
4305
+ }
4306
+ }
4307
+
4308
+ // Look up each variant and add its classes
4309
+ const variantsProp = configObj.getProperty('variants');
4310
+ if (variantsProp && Node.isPropertyAssignment(variantsProp)) {
4311
+ const variantsObj = variantsProp.getInitializer();
4312
+ if (variantsObj && Node.isObjectLiteralExpression(variantsObj)) {
4313
+ for (const variantProp of variantsObj.getProperties()) {
4314
+ if (!Node.isPropertyAssignment(variantProp)) continue;
4315
+ const variantName = variantProp.getName();
4316
+ const selectedValue = requestedVariants[variantName] ?? defaultVariants[variantName];
4317
+ if (!selectedValue) continue;
4318
+
4319
+ const variantValuesObj = variantProp.getInitializer();
4320
+ if (!variantValuesObj || !Node.isObjectLiteralExpression(variantValuesObj)) continue;
4321
+
4322
+ const matchedProp = variantValuesObj.getProperty(selectedValue);
4323
+ if (matchedProp && Node.isPropertyAssignment(matchedProp)) {
4324
+ const variantClassStr = this.extractStringValue(matchedProp.getInitializer()!);
4325
+ if (variantClassStr) classes.push(variantClassStr);
4326
+ }
4327
+ }
4328
+ }
4329
+ }
4330
+ }
4331
+
4332
+ return resolveClassConflicts(classes.filter(Boolean).join(' '));
4333
+ }
4334
+ }
4335
+ return null;
4336
+ }
4337
+
2464
4338
  /**
2465
4339
  * Extract props from a JSX node (opening element or self-closing element).
2466
4340
  * Uses getDescendantsOfKind to safely get only JsxAttribute nodes,
@@ -2469,13 +4343,13 @@ export class ComponentScanner {
2469
4343
  private extractPropsFromNode(
2470
4344
  jsxNode: Node,
2471
4345
  componentName: string,
2472
- propsContext: Map<string, any> = new Map()
4346
+ propsContext: Map<string, ResolvedExpressionValue> = new Map()
2473
4347
  ): ComponentInstance {
2474
- const props: Record<string, any> = {};
4348
+ const props: Record<string, ResolvedExpressionValue> = {};
2475
4349
 
2476
4350
  const directAttributes =
2477
- typeof (jsxNode as any).getAttributes === 'function'
2478
- ? ((jsxNode as any).getAttributes() as Node[])
4351
+ Node.isJsxOpeningElement(jsxNode) || Node.isJsxSelfClosingElement(jsxNode)
4352
+ ? jsxNode.getAttributes()
2479
4353
  : [];
2480
4354
  const attributes = directAttributes.length > 0
2481
4355
  ? directAttributes
@@ -2498,7 +4372,8 @@ export class ComponentScanner {
2498
4372
  continue;
2499
4373
  }
2500
4374
 
2501
- const nameNode = (attr as any).getNameNode?.();
4375
+ if (!Node.isJsxAttribute(attr)) continue;
4376
+ const nameNode = attr.getNameNode();
2502
4377
  if (!nameNode) continue;
2503
4378
  const name = nameNode.getText();
2504
4379
  const init = attr.getInitializer();
@@ -2528,12 +4403,24 @@ export class ComponentScanner {
2528
4403
  if (expr && Node.isStringLiteral(expr)) {
2529
4404
  props[name] = expr.getLiteralValue();
2530
4405
  } else if (expr && Node.isObjectLiteralExpression(expr)) {
2531
- props[name] = this.parseObjectLiteralExpression(expr);
4406
+ props[name] = this.parseObjectLiteralExpression(expr, propsContext);
2532
4407
  } else if (expr && Node.isArrayLiteralExpression(expr)) {
2533
4408
  props[name] = this.parseLiteralValue(expr);
2534
4409
  } else if (expr) {
2535
4410
  const resolved = this.resolveExpressionValue(expr, propsContext);
2536
- props[name] = resolved !== undefined ? resolved : expr.getText();
4411
+ if (resolved !== undefined) {
4412
+ props[name] = resolved;
4413
+ }
4414
+ // Unresolved expression (typically an unresolved identifier like a
4415
+ // function parameter with no default — e.g. `data-inset={inset}`
4416
+ // where `inset` is undefined in the story). Falling back to
4417
+ // `expr.getText()` here used to serialise the identifier *name*
4418
+ // as the attribute's value (`"inset"`), which the variant engine
4419
+ // mistook for "data-inset is present" and activated every
4420
+ // `data-[inset]:` utility (e.g. DropdownMenuItem's
4421
+ // `data-[inset]:pl-8`, leaving every item indented 32px when no
4422
+ // story used `inset`). Omitting the prop matches React's runtime
4423
+ // behaviour: `data-x={undefined}` renders no attribute.
2537
4424
  }
2538
4425
  }
2539
4426
  }
@@ -2587,26 +4474,73 @@ export class ComponentScanner {
2587
4474
  // Helper Methods
2588
4475
  // ===========================================================================
2589
4476
 
4477
+ private shouldDeferCvaToCompound(sourceFile: SourceFile, filePath: string): boolean {
4478
+ const topLevelNames = this.getTopLevelComponentNames(sourceFile);
4479
+ if (topLevelNames.length < 2) return false;
4480
+
4481
+ const fileBaseName = path.basename(filePath, '.tsx');
4482
+ const fileComponentName = fileBaseName
4483
+ .split(/[-_]/g)
4484
+ .filter(Boolean)
4485
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
4486
+ .join('');
4487
+ if (!fileComponentName) return false;
4488
+
4489
+ // Prefer compound analysis when the file clearly defines a main component
4490
+ // plus subcomponents (e.g. pagination.tsx, dialog.tsx).
4491
+ return topLevelNames.indexOf(fileComponentName) !== -1;
4492
+ }
4493
+
4494
+ private getTopLevelComponentNames(sourceFile: SourceFile): string[] {
4495
+ const names = new Set<string>();
4496
+
4497
+ for (const statement of sourceFile.getVariableStatements()) {
4498
+ const declarations = statement.getDeclarationList().getDeclarations();
4499
+ for (const decl of declarations) {
4500
+ const name = decl.getName();
4501
+ if (/^[A-Z]/.test(name)) names.add(name);
4502
+ }
4503
+ }
4504
+
4505
+ for (const func of sourceFile.getFunctions()) {
4506
+ const name = func.getName();
4507
+ if (name && /^[A-Z]/.test(name)) names.add(name);
4508
+ }
4509
+
4510
+ return Array.from(names);
4511
+ }
4512
+
2590
4513
  /**
2591
4514
  * Find all component declarations in a file
2592
4515
  */
2593
4516
  private findComponentDeclarations(sourceFile: SourceFile): Array<{ name: string; node: Node }> {
2594
4517
  const components: Array<{ name: string; node: Node }> = [];
4518
+ const seen = new Set<string>();
2595
4519
 
2596
- // Find variable statements that export components
4520
+ // Find variable declarations that look like components.
2597
4521
  const variableStatements = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement);
2598
4522
 
2599
4523
  for (const statement of variableStatements) {
2600
4524
  const declarations = statement.getDeclarationList().getDeclarations();
2601
4525
  for (const decl of declarations) {
2602
4526
  const name = decl.getName();
2603
- // Component names start with uppercase
2604
- if (/^[A-Z]/.test(name)) {
4527
+ if (/^[A-Z]/.test(name) && !seen.has(name)) {
2605
4528
  components.push({ name, node: decl });
4529
+ seen.add(name);
2606
4530
  }
2607
4531
  }
2608
4532
  }
2609
4533
 
4534
+ // Also include function declarations (shadcn files often use function syntax).
4535
+ const functions = sourceFile.getFunctions();
4536
+ for (const func of functions) {
4537
+ const name = func.getName();
4538
+ if (name && /^[A-Z]/.test(name) && !seen.has(name)) {
4539
+ components.push({ name, node: func });
4540
+ seen.add(name);
4541
+ }
4542
+ }
4543
+
2610
4544
  return components;
2611
4545
  }
2612
4546