inkbridge 0.1.0-beta.21 → 0.1.0-beta.23

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 (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -8,7 +8,23 @@
8
8
  * - Simple: Components with static classes
9
9
  */
10
10
 
11
- import { Project, SyntaxKind, Node, SourceFile, type JsxAttribute } from 'ts-morph';
11
+ import {
12
+ Project,
13
+ SyntaxKind,
14
+ Node,
15
+ SourceFile,
16
+ type ArrowFunction,
17
+ type FunctionDeclaration,
18
+ type FunctionExpression,
19
+ type JsxAttribute,
20
+ } from 'ts-morph';
21
+
22
+ // Sentinel for the user-function interpreter: distinguishes "this statement
23
+ // produced no return value, continue with the next" from "explicit `return
24
+ // undefined`" (which IS a valid evaluated result and should bubble up).
25
+ const EVAL_NEXT: unique symbol = Symbol('evalNext');
26
+ type EvalStatementResult = ResolvedExpressionValue | typeof EVAL_NEXT;
27
+ type UserFunctionNode = ArrowFunction | FunctionExpression | FunctionDeclaration;
12
28
  import * as path from 'path';
13
29
  import * as fs from 'fs';
14
30
  import type {
@@ -219,6 +235,7 @@ export class ComponentScanner {
219
235
  }
220
236
  }
221
237
 
238
+ this.applyTreeTransforms(analysis);
222
239
  analyses.push(analysis);
223
240
  }
224
241
  } catch (err) {
@@ -705,13 +722,20 @@ export class ComponentScanner {
705
722
  * `{rewardSol.toFixed(4)}` need a numeric receiver to evaluate. Try
706
723
  * to parse each value as a number first; fall back to the literal
707
724
  * string for non-numeric args (`label = "Click me"`). Booleans are
708
- * matched by exact "true" / "false".
725
+ * matched by exact "true" / "false". `null` / `undefined` keyword
726
+ * literals are dropped — they leave the prop unbound, which is what
727
+ * a conditional like `{viewingWalletLabel ? <Banner/> : null}`
728
+ * expects when the consumer wrote `viewingWalletLabel: null`.
729
+ * Without this drop, `extractStringValue("null")` returns the
730
+ * truthy string `"null"` and the wallet div renders with literal
731
+ * "null" text.
709
732
  */
710
733
  private buildArgsPropsContext(args: Record<string, string>): Map<string, ResolvedExpressionValue> {
711
734
  const out = new Map<string, ResolvedExpressionValue>();
712
735
  for (const key in args) {
713
736
  if (!Object.prototype.hasOwnProperty.call(args, key)) continue;
714
737
  const raw = args[key];
738
+ if (raw === 'null' || raw === 'undefined') continue;
715
739
  if (raw === 'true') { out.set(key, true); continue; }
716
740
  if (raw === 'false') { out.set(key, false); continue; }
717
741
  if (raw !== '' && !isNaN(Number(raw))) { out.set(key, Number(raw)); continue; }
@@ -886,6 +910,33 @@ export class ComponentScanner {
886
910
  init.getProperty('decorators')
887
911
  );
888
912
 
913
+ // Check for `parameters.inkbridge.responsive` opt-in/opt-out.
914
+ // Storybook idiom: { parameters: { inkbridge: { responsive: true } } }.
915
+ // The plugin gates responsive-breakpoint frames on this field —
916
+ // see preview-builder.ts and story-builder.ts. Unset = default
917
+ // policy (only the canonical Default story gets responsive).
918
+ const parametersProp = init.getProperty('parameters');
919
+ if (parametersProp && Node.isPropertyAssignment(parametersProp)) {
920
+ const paramsInit = parametersProp.getInitializer();
921
+ if (paramsInit && Node.isObjectLiteralExpression(paramsInit)) {
922
+ const inkbridgeProp = paramsInit.getProperty('inkbridge');
923
+ if (inkbridgeProp && Node.isPropertyAssignment(inkbridgeProp)) {
924
+ const inkbridgeInit = inkbridgeProp.getInitializer();
925
+ if (inkbridgeInit && Node.isObjectLiteralExpression(inkbridgeInit)) {
926
+ const responsiveProp = inkbridgeInit.getProperty('responsive');
927
+ if (responsiveProp && Node.isPropertyAssignment(responsiveProp)) {
928
+ const responsiveInit = responsiveProp.getInitializer();
929
+ if (responsiveInit) {
930
+ const kind = responsiveInit.getKind();
931
+ if (kind === SyntaxKind.TrueKeyword) story.responsive = true;
932
+ else if (kind === SyntaxKind.FalseKeyword) story.responsive = false;
933
+ }
934
+ }
935
+ }
936
+ }
937
+ }
938
+ }
939
+
889
940
  // Check for `args` property
890
941
  const argsProp = init.getProperty('args');
891
942
  if (argsProp && Node.isPropertyAssignment(argsProp)) {
@@ -934,13 +985,38 @@ export class ComponentScanner {
934
985
  // this an args-based BlockTable story would see
935
986
  // `blocks = "mockBlocks"` and the .map call inside the
936
987
  // component couldn't resolve the iteration source.
988
+ //
989
+ // Handles both literal property assignments AND spread
990
+ // assignments (`...BASE_ARGS`). Without spread handling,
991
+ // shared-args patterns like
992
+ // const BASE_ARGS = { marketSymbol: "SOL", ... };
993
+ // export const WithLegend = { args: { ...BASE_ARGS, markers: [...] } };
994
+ // would silently drop every base arg, so the component
995
+ // body sees `marketSymbol = undefined` and renders blank.
937
996
  for (const prop of argsInit.getProperties()) {
997
+ if (Node.isSpreadAssignment(prop)) {
998
+ const spreadResolved = this.resolveExpressionValue(prop.getExpression(), new Map());
999
+ if (spreadResolved && typeof spreadResolved === 'object' && !Array.isArray(spreadResolved)) {
1000
+ for (const [k, v] of Object.entries(spreadResolved as Record<string, ResolvedExpressionValue>)) {
1001
+ if (v !== undefined && v !== null) argsContext.set(k, v);
1002
+ }
1003
+ }
1004
+ continue;
1005
+ }
938
1006
  if (!Node.isPropertyAssignment(prop)) continue;
939
1007
  const propInit = prop.getInitializer();
940
1008
  if (!propInit) continue;
941
1009
  const resolved = this.resolveExpressionValue(propInit, new Map());
942
- if (resolved !== undefined && resolved !== null) {
943
- argsContext.set(prop.getName(), resolved);
1010
+ // Fallback to parseLiteralValue for shapes resolveExpressionValue
1011
+ // doesn't cover (array literals, in particular — needed for
1012
+ // `markers: [...]` and similar inline-array story args).
1013
+ // `null` / `undefined` are deliberately skipped — they have
1014
+ // no propsContext-bearing value to thread through.
1015
+ const finalValue = resolved !== undefined
1016
+ ? resolved
1017
+ : this.parseLiteralValue(propInit);
1018
+ if (finalValue !== undefined && finalValue !== null) {
1019
+ argsContext.set(prop.getName(), finalValue);
944
1020
  }
945
1021
  }
946
1022
  const localCompBody = localComponents.get(metaComponentName);
@@ -2339,6 +2415,14 @@ export class ComponentScanner {
2339
2415
 
2340
2416
  let itemParamName: string;
2341
2417
  let destructuringBindings: Map<string, string> | null = null;
2418
+ // Array-tuple destructuring (`([mint, meta]) =>`): ordered list of
2419
+ // local names. Common when iterating `Object.entries(x).map(...)`
2420
+ // where each item is a `[key, value]` tuple. Without this branch
2421
+ // the param's `.getName()` returns the raw bracket text and
2422
+ // references to `mint` / `meta` inside the callback don't resolve,
2423
+ // so JSX values stay unsubstituted (e.g. `<SelectItem value={mint}>`
2424
+ // renders with the raw string `mint`).
2425
+ let arrayDestructuringNames: string[] | null = null;
2342
2426
  const firstParamNameNode = params[0].getNameNode();
2343
2427
  if (Node.isObjectBindingPattern(firstParamNameNode)) {
2344
2428
  destructuringBindings = new Map();
@@ -2350,6 +2434,18 @@ export class ComponentScanner {
2350
2434
  destructuringBindings.set(localName, sourceKey);
2351
2435
  }
2352
2436
  itemParamName = '__destructured__';
2437
+ } else if (Node.isArrayBindingPattern(firstParamNameNode)) {
2438
+ arrayDestructuringNames = [];
2439
+ for (const element of firstParamNameNode.getElements()) {
2440
+ // OmittedExpression for holes like `[, b]` — push '' to keep
2441
+ // index alignment but skip binding.
2442
+ if (Node.isBindingElement(element)) {
2443
+ arrayDestructuringNames.push(element.getNameNode().getText());
2444
+ } else {
2445
+ arrayDestructuringNames.push('');
2446
+ }
2447
+ }
2448
+ itemParamName = '__destructured__';
2353
2449
  } else {
2354
2450
  itemParamName = params[0].getName();
2355
2451
  }
@@ -2446,6 +2542,15 @@ export class ComponentScanner {
2446
2542
  arrayValue = this.findArrayValue(arrayName, sourceFile);
2447
2543
  }
2448
2544
 
2545
+ // Last-resort: try the full expression-level resolver. Handles
2546
+ // call-shape iterators like `Object.entries(X).map(...)` where
2547
+ // `arrayName` is the raw expression text (e.g.
2548
+ // "Object.entries(TOKEN_METADATA)") and `findArrayValue`'s
2549
+ // name-based lookup never matches.
2550
+ if (!arrayValue) {
2551
+ arrayValue = this.resolveArrayFromExpression(arrayExpr, sourceFile);
2552
+ }
2553
+
2449
2554
  if (!arrayValue || arrayValue.length === 0) {
2450
2555
  // If we can't find the array value, generate one iteration as a template
2451
2556
  const templateJsx = this.buildJsxTreeWithSubstitution(
@@ -2491,6 +2596,15 @@ export class ComponentScanner {
2491
2596
  // depend on `index` or the current item.
2492
2597
  if (itemParamName && itemParamName !== '__destructured__' && item != null) {
2493
2598
  iterationContext.set(itemParamName, item);
2599
+ } else if (itemParamName === '__destructured__' && arrayDestructuringNames) {
2600
+ // Array-tuple destructuring: bind by index. Items shorter than
2601
+ // the binding list leave the trailing names undefined.
2602
+ if (Array.isArray(item)) {
2603
+ for (let bi = 0; bi < arrayDestructuringNames.length; bi++) {
2604
+ const name = arrayDestructuringNames[bi];
2605
+ if (name) iterationContext.set(name, item[bi]);
2606
+ }
2607
+ }
2494
2608
  } else if (itemParamName === '__destructured__' && typeof item === 'object' && item !== null) {
2495
2609
  for (const [k, v] of Object.entries(item)) iterationContext.set(k, v);
2496
2610
  }
@@ -2568,6 +2682,17 @@ export class ComponentScanner {
2568
2682
  return jsonImported;
2569
2683
  }
2570
2684
 
2685
+ // Named import from a TypeScript / JavaScript file. Covers the common
2686
+ // pattern `import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants"`
2687
+ // followed by `PRICE_CHART_TIMEFRAMES.map(...)` in the JSX body. Without
2688
+ // this branch, `.map()` falls through to the placeholder-iteration path
2689
+ // (one synthetic item) and the rendered tree shows zero buttons.
2690
+ const fromNamedImport = this.findArrayInNamedImports(arrayName, sourceFile);
2691
+ if (fromNamedImport) {
2692
+ this.arrayValueCache.set(cacheKey, fromNamedImport);
2693
+ return fromNamedImport;
2694
+ }
2695
+
2571
2696
  // Look for default parameter value in function: function Comp({ features = defaultFeatures })
2572
2697
  for (const func of sourceFile.getFunctions()) {
2573
2698
  const params = func.getParameters();
@@ -2630,6 +2755,92 @@ export class ComponentScanner {
2630
2755
  return null;
2631
2756
  }
2632
2757
 
2758
+ /**
2759
+ * Resolve a named-imported array constant by following the import
2760
+ * declaration to its source file and recursing into `findArrayValue`
2761
+ * against that file. Covers patterns like:
2762
+ *
2763
+ * import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants";
2764
+ * ...
2765
+ * {PRICE_CHART_TIMEFRAMES.map((option) => <Button>{option.label}</Button>)}
2766
+ *
2767
+ * Without this, `findArrayValue` only inspects the current source file's
2768
+ * declarations, returns `null` for cross-file named imports, and the
2769
+ * `.map()` falls through to the placeholder-iteration path (one synthetic
2770
+ * item) — so consumer JSX renders zero buttons instead of N.
2771
+ *
2772
+ * Returns `null` for default imports (the JSON branch handles `import x from
2773
+ * "./foo.json"` separately) and for any import whose target file can't be
2774
+ * resolved to disk via `resolveImportedComponentPath`.
2775
+ */
2776
+ private findArrayInNamedImports(name: string, sourceFile: SourceFile): ResolvedExpressionValue[] | null {
2777
+ const fileDir = path.dirname(sourceFile.getFilePath());
2778
+ for (const imp of sourceFile.getImportDeclarations()) {
2779
+ const named = imp.getNamedImports().find((n) => {
2780
+ const localName = n.getAliasNode()?.getText() || n.getName();
2781
+ return localName === name;
2782
+ });
2783
+ if (!named) continue;
2784
+ const moduleSpec = imp.getModuleSpecifierValue();
2785
+ const importedFile = this.openImportedSourceFile(fileDir, moduleSpec);
2786
+ if (!importedFile) return null;
2787
+ // Resolve under the IMPORTED source's name (the export may rename
2788
+ // via `import { Foo as Bar }` — we follow the exported name).
2789
+ const exportedName = named.getName();
2790
+ return this.findArrayValue(exportedName, importedFile);
2791
+ }
2792
+ return null;
2793
+ }
2794
+
2795
+ /**
2796
+ * Try to load the source file the import declaration points at, against
2797
+ * both the disk (production scan) and the in-memory ts-morph project
2798
+ * (regression fixtures that don't touch the filesystem). Returns `null`
2799
+ * for imports whose target can't be located in either place.
2800
+ *
2801
+ * Built on top of `resolveImportedComponentPath`'s extension-trying logic
2802
+ * — we re-implement the same candidate list and probe the project's
2803
+ * tracked files when `fs.existsSync` says "not on disk". Keeps the
2804
+ * regression fixtures self-contained (no temp-dir writes) while staying
2805
+ * correct for real consumer scans.
2806
+ */
2807
+ private openImportedSourceFile(baseDir: string, moduleSpec: string): SourceFile | null {
2808
+ // Fast path: real files on disk.
2809
+ const onDiskPath = this.resolveImportedComponentPath(baseDir, moduleSpec);
2810
+ if (onDiskPath) {
2811
+ try {
2812
+ const fromDisk = this.project.addSourceFileAtPathIfExists(onDiskPath);
2813
+ if (fromDisk) return fromDisk;
2814
+ } catch (_e) {
2815
+ /* fall through to in-memory probe */
2816
+ }
2817
+ }
2818
+
2819
+ // In-memory probe: build the same candidate list (with extension
2820
+ // variants) and check the project's tracked files. Lets fixtures call
2821
+ // `project.createSourceFile(path, content)` and have those files be
2822
+ // findable through normal import resolution.
2823
+ let basePath: string | null = null;
2824
+ if (moduleSpec.startsWith('./') || moduleSpec.startsWith('../')) {
2825
+ basePath = path.resolve(baseDir, moduleSpec);
2826
+ } else if (moduleSpec.startsWith('~/') || moduleSpec.startsWith('@/')) {
2827
+ basePath = path.resolve(process.cwd(), 'src', moduleSpec.slice(2));
2828
+ }
2829
+ if (!basePath) return null;
2830
+ const candidates = [
2831
+ basePath,
2832
+ `${basePath}.tsx`,
2833
+ `${basePath}.ts`,
2834
+ path.join(basePath, 'index.tsx'),
2835
+ path.join(basePath, 'index.ts'),
2836
+ ];
2837
+ for (const candidate of candidates) {
2838
+ const inProject = this.project.getSourceFile(candidate);
2839
+ if (inProject) return inProject;
2840
+ }
2841
+ return null;
2842
+ }
2843
+
2633
2844
  /**
2634
2845
  * Resolve an expression to an array of items. Handles:
2635
2846
  *
@@ -2673,6 +2884,18 @@ export class ComponentScanner {
2673
2884
  if (PASS_THROUGH_METHODS.has(methodName)) {
2674
2885
  return this.resolveArrayFromExpression(callee.getExpression(), sourceFile);
2675
2886
  }
2887
+ // `Object.entries(x)` / `Object.keys(x)` / `Object.values(x)` —
2888
+ // common shape transforms used inside `.map()` callbacks to
2889
+ // iterate POJO tables (e.g. shadcn dropdown built from
2890
+ // `Object.entries(TOKEN_METADATA).map(...)`). The general-purpose
2891
+ // evaluator handles these in `evaluateBuiltinCall`; resolve the
2892
+ // call expression through it and unwrap to an array.
2893
+ const baseExpr = callee.getExpression();
2894
+ if (Node.isIdentifier(baseExpr) && baseExpr.getText() === 'Object'
2895
+ && (methodName === 'entries' || methodName === 'keys' || methodName === 'values')) {
2896
+ const resolved = this.resolveExpressionValue(unwrapped, new Map());
2897
+ if (Array.isArray(resolved)) return resolved as ResolvedExpressionValue[];
2898
+ }
2676
2899
  }
2677
2900
  }
2678
2901
 
@@ -2763,33 +2986,26 @@ export class ComponentScanner {
2763
2986
 
2764
2987
  if (!Node.isArrayLiteralExpression(arrayLit)) return result;
2765
2988
 
2989
+ // Delegate each element to the full `resolveExpressionValue` resolver
2990
+ // so identifier references (`[USDC_MINT, USDT_MINT]` → the actual mint
2991
+ // strings), imports, casts, parens, template literals, and nested
2992
+ // arrays/objects all collapse to their static value. The previous
2993
+ // implementation only handled string / number / object-literal
2994
+ // elements directly and dropped everything else, so a story arg like
2995
+ // `allowedInputMints: [USDC_MINT, USDT_MINT]` came back as `[]` and a
2996
+ // downstream `.map()` over it expanded to a single placeholder
2997
+ // iteration with `value = { name: "Item", description: "Description" }`
2998
+ // instead of the two real mint values. Same class of bug we hit on
2999
+ // DecreasePositionModal with Object.entries — and on IncreasePosition
3000
+ // when the array was identifier-typed rather than literal-typed.
3001
+ //
3002
+ // The recursion is safe: each element is one syntactic level deeper
3003
+ // than the array literal, and `resolveExpressionValue` only re-enters
3004
+ // `parseArrayLiteral` for nested ArrayLiteralExpression elements.
2766
3005
  for (const element of arrayLit.getElements()) {
2767
- if (Node.isObjectLiteralExpression(element)) {
2768
- const obj: Record<string, ResolvedExpressionValue> = {};
2769
- for (const prop of element.getProperties()) {
2770
- if (Node.isPropertyAssignment(prop)) {
2771
- const name = prop.getName();
2772
- const init = prop.getInitializer();
2773
- if (init) {
2774
- if (Node.isStringLiteral(init)) {
2775
- obj[name] = init.getLiteralValue();
2776
- } else if (Node.isNumericLiteral(init)) {
2777
- obj[name] = Number(init.getLiteralText());
2778
- } else if (init.getKind() === SyntaxKind.TrueKeyword) {
2779
- obj[name] = true;
2780
- } else if (init.getKind() === SyntaxKind.FalseKeyword) {
2781
- obj[name] = false;
2782
- } else {
2783
- obj[name] = init.getText();
2784
- }
2785
- }
2786
- }
2787
- }
2788
- result.push(obj);
2789
- } else if (Node.isStringLiteral(element)) {
2790
- result.push(element.getLiteralValue());
2791
- } else if (Node.isNumericLiteral(element)) {
2792
- result.push(Number(element.getLiteralText()));
3006
+ const resolved = this.resolveExpressionValue(element, new Map());
3007
+ if (resolved !== undefined) {
3008
+ result.push(resolved);
2793
3009
  }
2794
3010
  }
2795
3011
 
@@ -3212,6 +3428,261 @@ export class ComponentScanner {
3212
3428
  return [];
3213
3429
  }
3214
3430
 
3431
+ /**
3432
+ * Apply scanner-level tree transforms to a JSX tree. Returns the
3433
+ * transformed tree (input is not mutated).
3434
+ *
3435
+ * Transforms run here mirror the `__fromPortal` pattern: the tree gets
3436
+ * annotated with data props that downstream render code reads, and
3437
+ * "logical-only" wrapper components (Radix `*.Root` aliases that render
3438
+ * `display: contents` in the browser) are flattened away so they don't
3439
+ * trap their children in a hugging frame at render time.
3440
+ *
3441
+ * Today: just `propagateSelectRootContext`. Extend here for Tabs,
3442
+ * RadioGroup, Accordion, etc.
3443
+ */
3444
+ private transformJsxTree(tree: JsxNode | null | undefined): JsxNode | null | undefined {
3445
+ if (!tree || tree.type !== 'element') return tree;
3446
+ let next = this.propagateSelectRootContext(tree);
3447
+ if (next.type === 'element') {
3448
+ next = this.propagateDialogRootContext(next);
3449
+ }
3450
+ return next;
3451
+ }
3452
+
3453
+ /**
3454
+ * Apply tree-level transforms to every JSX tree carried by an
3455
+ * analysis: the component's own `jsxTree` and each story's
3456
+ * `jsxTree`. Called once per analysis right before it lands in the
3457
+ * scanned components list, so the persisted JSON already reflects
3458
+ * the transformed trees and downstream consumers (ui-builder,
3459
+ * symbol policy, story-builder) see a single canonical shape.
3460
+ */
3461
+ private applyTreeTransforms(analysis: ComponentAnalysis): void {
3462
+ if ('jsxTree' in analysis && analysis.jsxTree) {
3463
+ const transformed = this.transformJsxTree(analysis.jsxTree);
3464
+ if (transformed && transformed.type === 'element') {
3465
+ analysis.jsxTree = transformed;
3466
+ }
3467
+ }
3468
+ const stories = ('stories' in analysis && Array.isArray(analysis.stories))
3469
+ ? analysis.stories
3470
+ : null;
3471
+ if (stories) {
3472
+ for (const story of stories) {
3473
+ if (story && story.jsxTree) {
3474
+ const transformed = this.transformJsxTree(story.jsxTree);
3475
+ if (transformed && transformed.type === 'element') {
3476
+ story.jsxTree = transformed;
3477
+ }
3478
+ }
3479
+ }
3480
+ }
3481
+ }
3482
+
3483
+ /**
3484
+ * Hoist children of transparent `<Select>` / `<SelectPrimitive.Root>`
3485
+ * wrappers up to their parent, annotating each hoisted child with
3486
+ * `__selectRoot*` data props that carry the Select's context
3487
+ * (`value`, `open`, the resolved selected label, the highlighted item).
3488
+ *
3489
+ * The render-time `isSelectRootTag` handler in `ui-builder.ts` was the
3490
+ * only place that set `selectIsOpen` / `selectSelectedLabel` etc. on
3491
+ * the render context. With the wrapper preserved, Figma would render
3492
+ * an empty hugging frame around `<SelectTrigger>` and the trigger's
3493
+ * `w-full` would only fill the wrapper — making the dropdown look
3494
+ * narrower than its sibling inputs in a form grid.
3495
+ *
3496
+ * Flattening here mirrors how CSS `display: contents` makes the
3497
+ * wrapper layout-transparent in the browser. Downstream renderers see
3498
+ * `<SelectTrigger>` and `<SelectContent>` as direct siblings of the
3499
+ * Label / form fields, so existing full-width / stretch logic applies
3500
+ * without special-casing the Radix wrapper.
3501
+ */
3502
+ private propagateSelectRootContext(node: JsxNode): JsxNode {
3503
+ if (node.type !== 'element') return node;
3504
+ const el = node;
3505
+ const newChildren: JsxNode[] = [];
3506
+ for (const child of el.children || []) {
3507
+ const transformed = this.propagateSelectRootContext(child);
3508
+ if (transformed.type === 'element' && this.isTransparentSelectRoot(transformed)) {
3509
+ for (const hoisted of this.hoistSelectRootChildren(transformed)) {
3510
+ newChildren.push(hoisted);
3511
+ }
3512
+ } else {
3513
+ newChildren.push(transformed);
3514
+ }
3515
+ }
3516
+ return Object.assign({}, el, { children: newChildren });
3517
+ }
3518
+
3519
+ /**
3520
+ * Annotate every `DialogContent` descendant with an
3521
+ * `__dialogRootIsOpen` data prop derived from the nearest enclosing
3522
+ * `<Dialog>` / `<DialogPrimitive.Root>`'s `open` / `defaultOpen`
3523
+ * props. The render-time gate in `ui-builder.ts` uses this signal
3524
+ * to suppress closed nested dialogs (the info-button Dialog inside
3525
+ * `DialogHeader`, the secondary-confirmation Dialog, etc.).
3526
+ *
3527
+ * Without this, every nested DialogContent rendered inline next to
3528
+ * its parent's content — visible as an "Open position / Overview"
3529
+ * sliver floating top-right of `IncreasePositionModal` in Figma,
3530
+ * and as the vertical-letter description text on mobile.
3531
+ *
3532
+ * Why scanner-level instead of render-context inheritance: the
3533
+ * outer DialogContent is portal-extracted at scan time (loses its
3534
+ * direct child-of-Root relationship). By the time ui-builder
3535
+ * processes a DialogContent the parent Dialog Root is no longer
3536
+ * on the recursion ancestor chain — annotating at scan time, when
3537
+ * we still see the wrapping Root, is the universal fix.
3538
+ */
3539
+ private propagateDialogRootContext(node: JsxNode): JsxNode {
3540
+ const annotate = (n: JsxNode, isOpen: boolean | null): JsxNode => {
3541
+ if (n.type !== 'element') return n;
3542
+ const el = n;
3543
+ const tag = el.tagName;
3544
+ // Entering a new Dialog Root — recompute isOpen from its props.
3545
+ const isDialogRoot = tag === 'Dialog' || tag === 'DialogPrimitive.Root';
3546
+ const nextIsOpen = isDialogRoot
3547
+ ? (this.isScannerTruthyStateProp(el.props && el.props.open)
3548
+ || this.isScannerTruthyStateProp(el.props && el.props.defaultOpen))
3549
+ : isOpen;
3550
+ const isDialogContent = tag === 'DialogContent' || tag === 'DialogPrimitive.Content';
3551
+ let nextProps = el.props;
3552
+ if (isDialogContent && nextIsOpen != null) {
3553
+ nextProps = Object.assign({}, el.props || {}, {
3554
+ __dialogRootIsOpen: nextIsOpen ? 'true' : 'false',
3555
+ });
3556
+ }
3557
+ const newChildren: JsxNode[] = [];
3558
+ for (const c of el.children || []) {
3559
+ newChildren.push(annotate(c, nextIsOpen));
3560
+ }
3561
+ return Object.assign({}, el, { props: nextProps, children: newChildren });
3562
+ };
3563
+ return annotate(node, null);
3564
+ }
3565
+
3566
+ private isTransparentSelectRoot(node: JsxElement): boolean {
3567
+ const tag = node.tagName;
3568
+ if (tag !== 'Select' && tag !== 'SelectPrimitive.Root') return false;
3569
+ const className = node.props && node.props.className;
3570
+ if (typeof className === 'string' && className.trim().length > 0) return false;
3571
+ return Array.isArray(node.children) && node.children.length > 0;
3572
+ }
3573
+
3574
+ private hoistSelectRootChildren(rootEl: JsxElement): JsxNode[] {
3575
+ const props = rootEl.props || {};
3576
+ const selectedValue = (props.value != null && String(props.value).length > 0)
3577
+ ? String(props.value)
3578
+ : (props.defaultValue != null && String(props.defaultValue).length > 0)
3579
+ ? String(props.defaultValue)
3580
+ : null;
3581
+ const isOpen = this.isScannerTruthyStateProp(props.open)
3582
+ || this.isScannerTruthyStateProp(props.defaultOpen);
3583
+ const selectedLabel = selectedValue != null
3584
+ ? this.findSelectItemLabel(rootEl, selectedValue)
3585
+ : null;
3586
+ const highlightedValue = (!selectedValue && isOpen)
3587
+ ? this.findFirstSelectItemValue(rootEl)
3588
+ : null;
3589
+ // Annotations are strings because `JsxElement.props` is
3590
+ // `Record<string, string>`. Render-time consumers re-parse via
3591
+ // `isTruthyStateProp` / `normalizeSelectableValue`.
3592
+ const annotations: Record<string, string> = {};
3593
+ if (selectedValue != null) annotations.__selectRootSelectedValue = selectedValue;
3594
+ if (selectedLabel != null) annotations.__selectRootSelectedLabel = selectedLabel;
3595
+ if (isOpen) annotations.__selectRootIsOpen = 'true';
3596
+ if (highlightedValue != null) annotations.__selectRootHighlightedValue = highlightedValue;
3597
+
3598
+ const out: JsxNode[] = [];
3599
+ for (const child of rootEl.children) {
3600
+ if (child.type !== 'element') {
3601
+ out.push(child);
3602
+ continue;
3603
+ }
3604
+ const childEl = child as JsxElement;
3605
+ const mergedProps = Object.assign({}, childEl.props || {}, annotations);
3606
+ out.push(Object.assign({}, childEl, { props: mergedProps }));
3607
+ }
3608
+ return out;
3609
+ }
3610
+
3611
+ private isScannerTruthyStateProp(value: string | undefined | null): boolean {
3612
+ if (value == null) return false;
3613
+ const s = String(value).trim().toLowerCase();
3614
+ return s === 'true' || s === '"true"' || s === "'true'" || s === '1';
3615
+ }
3616
+
3617
+ /**
3618
+ * Walk a Select Root's subtree to find the first `SelectItem` whose
3619
+ * `value` prop matches the requested value, and return the first text
3620
+ * descendant (the label). Mirrors `findSelectedSelectLabel` in
3621
+ * `src/design-system/render-context.ts` but operates on the static
3622
+ * scanner JSX tree at scan time, so the label is baked into the
3623
+ * `__selectRootSelectedLabel` annotation.
3624
+ *
3625
+ * The label is rarely a direct text child of `SelectItem`: shadcn's
3626
+ * `<SelectItem>` inlines into `<SelectPrimitive.Item>` containing a
3627
+ * decorative indicator span AND a `<SelectPrimitive.ItemText>` wrapper
3628
+ * around the actual label text. Walk the whole subtree of the matched
3629
+ * item rather than peeking only at direct children — anything else
3630
+ * silently drops the annotation for shadcn-shaped Selects and the
3631
+ * Trigger renders the raw value (e.g. a mint address) instead of the
3632
+ * symbol.
3633
+ */
3634
+ private findSelectItemLabel(rootEl: JsxElement, value: string): string | null {
3635
+ const findFirstText = (node: JsxNode): string | null => {
3636
+ if (node.type === 'text') {
3637
+ const text = (node as { content?: string }).content || '';
3638
+ const trimmed = text.trim();
3639
+ return trimmed.length > 0 ? trimmed : null;
3640
+ }
3641
+ if (node.type !== 'element') return null;
3642
+ for (const c of (node as JsxElement).children || []) {
3643
+ const t = findFirstText(c);
3644
+ if (t != null) return t;
3645
+ }
3646
+ return null;
3647
+ };
3648
+ const walk = (node: JsxNode): string | null => {
3649
+ if (node.type !== 'element') return null;
3650
+ const el = node as JsxElement;
3651
+ const tag = el.tagName;
3652
+ if (tag === 'SelectItem' || tag === 'SelectPrimitive.Item') {
3653
+ const itemValue = el.props && el.props.value;
3654
+ if (itemValue != null && String(itemValue) === value) {
3655
+ const labelText = findFirstText(el);
3656
+ if (labelText != null) return labelText;
3657
+ }
3658
+ }
3659
+ for (const c of el.children || []) {
3660
+ const found = walk(c);
3661
+ if (found != null) return found;
3662
+ }
3663
+ return null;
3664
+ };
3665
+ return walk(rootEl);
3666
+ }
3667
+
3668
+ private findFirstSelectItemValue(rootEl: JsxElement): string | null {
3669
+ const walk = (node: JsxNode): string | null => {
3670
+ if (node.type !== 'element') return null;
3671
+ const el = node as JsxElement;
3672
+ const tag = el.tagName;
3673
+ if (tag === 'SelectItem' || tag === 'SelectPrimitive.Item') {
3674
+ const v = el.props && el.props.value;
3675
+ if (v != null && String(v).length > 0) return String(v);
3676
+ }
3677
+ for (const c of el.children || []) {
3678
+ const found = walk(c);
3679
+ if (found != null) return found;
3680
+ }
3681
+ return null;
3682
+ };
3683
+ return walk(rootEl);
3684
+ }
3685
+
3215
3686
  /**
3216
3687
  * Returns true if the expanded node's root is a portal wrapper (renders outside the DOM tree).
3217
3688
  * Used to unwrap components like DialogContent, SelectContent, SheetContent, DropdownMenuContent.
@@ -3238,8 +3709,14 @@ export class ComponentScanner {
3238
3709
  /**
3239
3710
  * Strips portal-specific CSS classes that are meaningless or harmful in Figma:
3240
3711
  * 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).
3712
+ * transition durations, and complex calc() max-widths (can't be evaluated
3713
+ * to a pixel value).
3714
+ *
3715
+ * Responsive `max-w-*` variants (e.g. shadcn DialogContent's `sm:max-w-lg`)
3716
+ * are KEPT — they're the only signal that tells the renderer the dialog
3717
+ * intends to render at 512px, not the 900px Story Layout fallback. A
3718
+ * consumer-supplied unprefixed `max-w-*` still wins via `extractMaxWidth`
3719
+ * Pass 1.
3243
3720
  *
3244
3721
  * Called on the extracted portal content node so the renderer sees only the
3245
3722
  * visual layout classes (grid, gap, padding, border, shadow, bg, etc.).
@@ -3275,8 +3752,6 @@ export class ComponentScanner {
3275
3752
  if (/^ease-/.test(cls)) return false;
3276
3753
  // Complex calc/min/max max-w (can't be evaluated to a pixel value)
3277
3754
  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
3755
  return true;
3281
3756
  })
3282
3757
  .join(' ');
@@ -3321,6 +3796,73 @@ export class ComponentScanner {
3321
3796
  }
3322
3797
  return;
3323
3798
  }
3799
+ // Fragment branch `<>…</>` — flatten its children inline. React fragments
3800
+ // are layout-transparent (they have no DOM); the children participate
3801
+ // directly in the surrounding parent's layout. Canonical real-world
3802
+ // shape:
3803
+ // {isLong ? (<><svg/>Long / Buy</>) : (<><svg/>Short / Sell</>)}
3804
+ // — the submit Button's body. Without flattening, the chosen Fragment
3805
+ // fell through to `resolveExpressionValue` and the whole branch was
3806
+ // dropped, so the Button rendered empty (no icon, no label).
3807
+ if (Node.isJsxFragment(chosen)) {
3808
+ // `.getChildren()` on a JsxFragment returns syntax tokens (the
3809
+ // opening / closing fragment tags + a SyntaxList). `.getJsxChildren()`
3810
+ // is the one that returns the actual JSX children (elements, text,
3811
+ // expressions) — that's what we need to flatten into the parent.
3812
+ for (const fragChild of chosen.getJsxChildren()) {
3813
+ if (Node.isJsxElement(fragChild) || Node.isJsxSelfClosingElement(fragChild) || Node.isJsxFragment(fragChild)) {
3814
+ this.pushChosenJsxBranch(fragChild, children, localComponents, relativeImports, propsContext);
3815
+ } else if (Node.isJsxText(fragChild)) {
3816
+ const text = decodeHtmlEntities(normalizeJsxText(fragChild.getFullText()));
3817
+ if (text) children.push({ type: 'text', content: text });
3818
+ } else if (Node.isJsxExpression(fragChild)) {
3819
+ const inner = fragChild.getExpression();
3820
+ if (inner) this.pushChosenJsxBranch(inner, children, localComponents, relativeImports, propsContext);
3821
+ }
3822
+ }
3823
+ return;
3824
+ }
3825
+ // Nested ternary `a ? x : (b ? y : z)` — the outer branch is itself a
3826
+ // ConditionalExpression that the .map-context handler would unpack
3827
+ // recursively. Mirror that here so multi-level conditionals don't get
3828
+ // silently dropped. Canonical real-world shape (IncreasePositionModal
3829
+ // Position impact card):
3830
+ // {previewLoading ? <skel/> : previewMetrics ? <metrics/> : <empty/>}
3831
+ // — the outer-false branch is `previewMetrics ? metrics : empty`,
3832
+ // another ConditionalExpression. Without recursion it landed in
3833
+ // `resolveExpressionValue` which returned `undefined` for JSX
3834
+ // expressions, and the entire card body disappeared.
3835
+ if (Node.isConditionalExpression(chosen)) {
3836
+ const condValue = this.resolveExpressionValue(chosen.getCondition(), propsContext);
3837
+ if (condValue !== undefined) {
3838
+ const nested = this.isExpressionTruthy(condValue)
3839
+ ? chosen.getWhenTrue()
3840
+ : chosen.getWhenFalse();
3841
+ this.pushChosenJsxBranch(nested, children, localComponents, relativeImports, propsContext);
3842
+ }
3843
+ return;
3844
+ }
3845
+ // CallExpression `arr.map(...)` inside a ternary branch must expand the
3846
+ // same way it would as a direct JSX child. Without this case the
3847
+ // walker drops it silently because `resolveExpressionValue` doesn't
3848
+ // know how to iterate `.map`. Canonical repro: a table body shaped
3849
+ // {rows.length ? rows.map(row => <Row/>) : <EmptyRow/>}
3850
+ // — the truthy branch is the CallExpression, gets routed here, and
3851
+ // disappeared.
3852
+ if (Node.isCallExpression(chosen)) {
3853
+ const callee = chosen.getExpression();
3854
+ if (Node.isPropertyAccessExpression(callee) && callee.getName() === 'map') {
3855
+ const expanded = this.expandMapCall(
3856
+ chosen,
3857
+ chosen.getSourceFile(),
3858
+ localComponents,
3859
+ relativeImports,
3860
+ propsContext,
3861
+ );
3862
+ for (const c of expanded) children.push(c);
3863
+ return;
3864
+ }
3865
+ }
3324
3866
  const resolved = this.resolveExpressionValue(chosen, propsContext);
3325
3867
  if (resolved !== undefined && resolved !== null) {
3326
3868
  this.pushResolvedPropValue(children, resolved);
@@ -3861,7 +4403,33 @@ export class ComponentScanner {
3861
4403
  continue;
3862
4404
  }
3863
4405
  if (!Node.isPropertyAssignment(prop)) continue;
3864
- const key = prop.getName();
4406
+ // String-literal keys (`{ "So111...": value }`) — `prop.getName()`
4407
+ // returns the raw text INCLUDING the surrounding quotes, which would
4408
+ // store the entry under `"\"So111...\""` and break any
4409
+ // bracket-lookup with the unquoted string. Unwrap the literal so
4410
+ // the stored key matches what the consumer's `obj[mintString]`
4411
+ // access actually looks up.
4412
+ const nameNode = prop.getNameNode();
4413
+ let key: string | undefined;
4414
+ if (nameNode && (Node.isStringLiteral(nameNode) || Node.isNoSubstitutionTemplateLiteral(nameNode))) {
4415
+ key = nameNode.getLiteralValue();
4416
+ } else if (nameNode && Node.isNumericLiteral(nameNode)) {
4417
+ key = String(nameNode.getLiteralValue());
4418
+ } else if (nameNode && Node.isComputedPropertyName(nameNode)) {
4419
+ // Computed property names (`{ [USDC_MINT]: ... }`) — evaluate
4420
+ // the inner expression and use the resolved value as the key.
4421
+ // Common in token tables that key off an exported constant
4422
+ // (greenhouse-app's TOKEN_METADATA uses `[USDC_MINT]` so the
4423
+ // map is keyed by the actual mint string, not the identifier
4424
+ // text "USDC_MINT").
4425
+ const inner = nameNode.getExpression();
4426
+ const resolvedKey = this.resolveExpressionValue(inner, propsContext);
4427
+ if (resolvedKey != null) {
4428
+ key = String(resolvedKey);
4429
+ }
4430
+ } else {
4431
+ key = prop.getName();
4432
+ }
3865
4433
  if (!key) continue;
3866
4434
  const val = prop.getInitializer();
3867
4435
  if (!val) continue;
@@ -3884,6 +4452,14 @@ export class ComponentScanner {
3884
4452
  const text = val.getText();
3885
4453
  if (text === 'true') return true;
3886
4454
  if (text === 'false') return false;
4455
+ // `null` / `undefined` keywords resolve to their JS values, not the
4456
+ // strings "null" / "undefined". Previously they fell through to the
4457
+ // `return text` branch and (since "null" is a non-empty truthy string)
4458
+ // caused conditionals like `{error && <Banner>{error}</Banner>}` to
4459
+ // render the banner with literal "null" text when stories passed
4460
+ // `error: null`.
4461
+ if (text === 'null') return null;
4462
+ if (text === 'undefined') return undefined;
3887
4463
  if (Node.isArrayLiteralExpression(val)) {
3888
4464
  return val.getElements().map((el) => this.parseLiteralValue(el));
3889
4465
  }
@@ -3893,7 +4469,7 @@ export class ComponentScanner {
3893
4469
  return text;
3894
4470
  }
3895
4471
 
3896
- private coerceBoolean(value: unknown): boolean | null {
4472
+ private coerceBoolean(value: unknown): boolean {
3897
4473
  if (typeof value === 'boolean') return value;
3898
4474
  if (typeof value === 'number') return value !== 0;
3899
4475
  if (typeof value === 'string') {
@@ -3905,7 +4481,15 @@ export class ComponentScanner {
3905
4481
  if (normalized.length > 0) return true;
3906
4482
  return false;
3907
4483
  }
3908
- return null;
4484
+ // null / undefined match JS truthiness — falsy. Treating them as
4485
+ // "can't determine" (previous behaviour, returning null) made
4486
+ // optional-prop guards like `activeGroups && X` and
4487
+ // `activeGroups?.length ? a : b` evaluate to undefined and the
4488
+ // entire conditional branch dropped from the JSX tree — instead of
4489
+ // picking the "else" branch the way runtime React would. Match JS.
4490
+ if (value === null || value === undefined) return false;
4491
+ // Objects, arrays, functions, etc. are truthy in JS.
4492
+ return true;
3909
4493
  }
3910
4494
 
3911
4495
  /**
@@ -3965,15 +4549,36 @@ export class ComponentScanner {
3965
4549
  if (Node.isParenthesizedExpression(expr)) {
3966
4550
  return this.resolveExpressionValue(expr.getExpression(), propsContext);
3967
4551
  }
4552
+ // TypeScript type-only constructs that don't affect runtime value —
4553
+ // unwrap to the inner expression. Without this, common consumer
4554
+ // idioms like `(position as any).field` and `position!.field` cause
4555
+ // the entire chain to bail (the cast wrapper isn't one of the
4556
+ // recognised expression shapes).
4557
+ if (Node.isAsExpression(expr) || Node.isSatisfiesExpression(expr) || Node.isNonNullExpression(expr) || Node.isTypeAssertion(expr)) {
4558
+ return this.resolveExpressionValue(expr.getExpression(), propsContext);
4559
+ }
3968
4560
  if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
3969
4561
  return expr.getLiteralValue();
3970
4562
  }
3971
4563
  if (Node.isNumericLiteral(expr)) {
3972
4564
  return expr.getLiteralValue();
3973
4565
  }
4566
+ if (Node.isArrayLiteralExpression(expr)) {
4567
+ return this.parseArrayLiteral(expr);
4568
+ }
4569
+ if (Node.isObjectLiteralExpression(expr)) {
4570
+ // Inline object literals appearing as call/new arguments (e.g. the
4571
+ // options bag in `new Intl.NumberFormat("en-US", { style: "currency",
4572
+ // currency: "USD" })`). Without this case the evaluator returned
4573
+ // undefined for the options arg, so the Intl instance fell back to
4574
+ // its default formatting (no currency, no fraction digits).
4575
+ return this.parseObjectLiteralExpression(expr, propsContext);
4576
+ }
3974
4577
  const exprText = expr.getText();
3975
4578
  if (exprText === 'true') return true;
3976
4579
  if (exprText === 'false') return false;
4580
+ if (exprText === 'null') return null;
4581
+ if (exprText === 'undefined') return undefined;
3977
4582
  if (Node.isIdentifier(expr)) {
3978
4583
  if (propsContext.has(exprText)) return propsContext.get(exprText);
3979
4584
  // Resolve function-body-local consts FIRST so they shadow module-level
@@ -4002,7 +4607,51 @@ export class ComponentScanner {
4002
4607
  if (Node.isObjectLiteralExpression(unwrapped)) {
4003
4608
  return this.parseObjectLiteralExpression(unwrapped, propsContext);
4004
4609
  }
4610
+ // Module-level pure-constructor instances (e.g.
4611
+ // `const usdFormatter = new Intl.NumberFormat(...)`). Evaluating
4612
+ // the NewExpression returns the live JS object so subsequent
4613
+ // `usdFormatter.format(N)` calls dispatch against its prototype.
4614
+ if (Node.isNewExpression(unwrapped)) {
4615
+ return this.evaluateNewExpression(unwrapped, propsContext);
4616
+ }
4617
+ }
4618
+ }
4619
+ // Cross-module identifier: follow a named import to the source file
4620
+ // and try the same module-level resolution there. Enables
4621
+ // `import { usdFormatter } from './utils'` patterns to find the
4622
+ // remote Intl.NumberFormat instance.
4623
+ for (const importDecl of expr.getSourceFile().getImportDeclarations()) {
4624
+ const match = importDecl.getNamedImports().find(
4625
+ ni => (ni.getAliasNode()?.getText() ?? ni.getName()) === exprText
4626
+ );
4627
+ if (!match) continue;
4628
+ const sourceModule = importDecl.getModuleSpecifierSourceFile();
4629
+ if (!sourceModule) break;
4630
+ const importedName = match.getName();
4631
+ const importedVar = sourceModule.getVariableDeclaration(importedName);
4632
+ if (!importedVar) break;
4633
+ const init = importedVar.getInitializer();
4634
+ if (!init) break;
4635
+ const unwrapped = this.unwrapStaticValueExpression(init) || init;
4636
+ if (Node.isStringLiteral(unwrapped) || Node.isNoSubstitutionTemplateLiteral(unwrapped)) {
4637
+ return unwrapped.getLiteralValue();
4638
+ }
4639
+ if (Node.isNumericLiteral(unwrapped)) return unwrapped.getLiteralValue();
4640
+ if (Node.isNewExpression(unwrapped)) {
4641
+ return this.evaluateNewExpression(unwrapped, propsContext);
4005
4642
  }
4643
+ // Imported object / array literals — TOKEN_METADATA / DEFAULT_ACTIVE
4644
+ // / constant tables that consumers commonly import for lookups
4645
+ // (`TOKEN_METADATA[mint]?.symbol`). Without this, the element-access
4646
+ // returns undefined and falls through to fallbacks (e.g. `mint.slice(
4647
+ // 0, 4)` → "So11" instead of "SOL").
4648
+ if (Node.isArrayLiteralExpression(unwrapped)) {
4649
+ return this.parseArrayLiteral(unwrapped);
4650
+ }
4651
+ if (Node.isObjectLiteralExpression(unwrapped)) {
4652
+ return this.parseObjectLiteralExpression(unwrapped, propsContext);
4653
+ }
4654
+ break;
4006
4655
  }
4007
4656
  return undefined;
4008
4657
  }
@@ -4048,17 +4697,31 @@ export class ComponentScanner {
4048
4697
  }
4049
4698
  if (Node.isBinaryExpression(expr)) {
4050
4699
  const op = expr.getOperatorToken().getText();
4051
- if (op === '===' || op === '==') {
4052
- const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
4053
- const right = this.resolveExpressionValue(expr.getRight(), propsContext);
4700
+ // Equality with `undefined` / `null` literals must compare even when
4701
+ // the resolved operand IS undefined. Otherwise common nullish guards
4702
+ // (`if (value === undefined) return null;`) always evaluate to
4703
+ // undefined and the interpreter incorrectly falls through, which
4704
+ // breaks every formatter that does an early-return on missing input.
4705
+ if (op === '===' || op === '==' || op === '!==' || op === '!=') {
4706
+ const leftExpr = expr.getLeft();
4707
+ const rightExpr = expr.getRight();
4708
+ const leftText = leftExpr.getText();
4709
+ const rightText = rightExpr.getText();
4710
+ const leftLitUndef = leftText === 'undefined';
4711
+ const leftLitNull = leftText === 'null';
4712
+ const rightLitUndef = rightText === 'undefined';
4713
+ const rightLitNull = rightText === 'null';
4714
+ const left = this.resolveExpressionValue(leftExpr, propsContext);
4715
+ const right = this.resolveExpressionValue(rightExpr, propsContext);
4716
+ const strict = op === '===' || op === '!==';
4717
+ const negate = op === '!==' || op === '!=';
4718
+ const apply = (eq: boolean) => negate ? !eq : eq;
4719
+ if (rightLitUndef) return apply(strict ? left === undefined : left == null);
4720
+ if (leftLitUndef) return apply(strict ? right === undefined : right == null);
4721
+ if (rightLitNull) return apply(strict ? left === null : left == null);
4722
+ if (leftLitNull) return apply(strict ? right === null : right == null);
4054
4723
  if (left === undefined || right === undefined) return undefined;
4055
- return left === right;
4056
- }
4057
- if (op === '!==' || op === '!=') {
4058
- const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
4059
- const right = this.resolveExpressionValue(expr.getRight(), propsContext);
4060
- if (left === undefined || right === undefined) return undefined;
4061
- return left !== right;
4724
+ return apply(strict ? left === right : left == right);
4062
4725
  }
4063
4726
  if (op === '&&') {
4064
4727
  const left = this.coerceBoolean(this.resolveExpressionValue(expr.getLeft(), propsContext));
@@ -4085,15 +4748,25 @@ export class ComponentScanner {
4085
4748
  if (typeof left === 'number' && typeof right === 'number') return left + right;
4086
4749
  return String(left) + String(right);
4087
4750
  }
4088
- if (op === '-' || op === '*' || op === '/' || op === '%') {
4751
+ if (op === '-' || op === '*' || op === '/' || op === '%' || op === '**') {
4089
4752
  const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
4090
4753
  const right = this.resolveExpressionValue(expr.getRight(), propsContext);
4091
4754
  if (typeof left !== 'number' || typeof right !== 'number') return undefined;
4092
4755
  if (op === '-') return left - right;
4093
4756
  if (op === '*') return left * right;
4757
+ if (op === '**') return left ** right;
4094
4758
  if (op === '/') return right === 0 ? undefined : left / right;
4095
4759
  return right === 0 ? undefined : left % right;
4096
4760
  }
4761
+ if (op === '<' || op === '<=' || op === '>' || op === '>=') {
4762
+ const left = this.resolveExpressionValue(expr.getLeft(), propsContext);
4763
+ const right = this.resolveExpressionValue(expr.getRight(), propsContext);
4764
+ if (typeof left !== 'number' || typeof right !== 'number') return undefined;
4765
+ if (op === '<') return left < right;
4766
+ if (op === '<=') return left <= right;
4767
+ if (op === '>') return left > right;
4768
+ return left >= right;
4769
+ }
4097
4770
  }
4098
4771
  // Method-call expressions on resolvable bases — covers the common
4099
4772
  // story-args pattern `{rewardSol.toFixed(4)} SOL`. Without this the
@@ -4103,51 +4776,622 @@ export class ComponentScanner {
4103
4776
  // String built-in methods that have an obvious string output — we
4104
4777
  // are NOT a JS evaluator, just covering the high-traffic cases.
4105
4778
  if (Node.isCallExpression(expr)) {
4779
+ const builtinResult = this.evaluateBuiltinCall(expr, propsContext);
4780
+ if (builtinResult !== undefined) return builtinResult;
4781
+ // User-function evaluator handles both Identifier callees
4782
+ // (`formatUsd(price)`) and IIFE callees (`(() => { ... })()`).
4783
+ // Returns undefined for shapes it can't dispatch on.
4784
+ const interpreted = this.evaluateUserFunctionCall(expr, propsContext);
4785
+ if (interpreted !== undefined) return interpreted;
4106
4786
  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;
4787
+ if (Node.isIdentifier(calleeExpr)) {
4788
+ // Raw-arg fallback ONLY applies to identifier-named calls — when
4789
+ // the user-fn interpreter couldn't follow the function (unknown
4790
+ // helper, unsupported body construct, etc.), echo the first
4791
+ // argument so the cell renders the raw data instead of going
4792
+ // blank. Skip known side-effect helpers.
4793
+ const fnName = calleeExpr.getText();
4794
+ if (fnName === 'console' || fnName.startsWith('use')) return undefined;
4795
+ const firstArg = expr.getArguments()[0];
4796
+ if (firstArg) {
4797
+ const resolved = this.resolveExpressionValue(firstArg, propsContext);
4798
+ if (resolved !== undefined && resolved !== null) return resolved;
4799
+ }
4800
+ }
4801
+ }
4802
+ if (Node.isNewExpression(expr)) {
4803
+ return this.evaluateNewExpression(expr, propsContext);
4804
+ }
4805
+ if (Node.isPrefixUnaryExpression(expr)) {
4806
+ const op = expr.getOperatorToken();
4807
+ const operandValue = this.resolveExpressionValue(expr.getOperand(), propsContext);
4808
+ if (operandValue === undefined) return undefined;
4809
+ if (op === SyntaxKind.ExclamationToken) return !this.coerceBoolean(operandValue);
4810
+ if (op === SyntaxKind.MinusToken && typeof operandValue === 'number') return -operandValue;
4811
+ if (op === SyntaxKind.PlusToken && typeof operandValue === 'number') return +operandValue;
4812
+ if (op === SyntaxKind.TildeToken && typeof operandValue === 'number') return ~operandValue;
4813
+ return undefined;
4814
+ }
4815
+ if (Node.isTypeOfExpression(expr)) {
4816
+ const operandValue = this.resolveExpressionValue(expr.getExpression(), propsContext);
4817
+ if (operandValue === undefined) return undefined;
4818
+ if (operandValue === null) return 'object';
4819
+ return typeof operandValue;
4820
+ }
4821
+ if (Node.isTemplateExpression(expr)) {
4822
+ const parts: string[] = [expr.getHead().getLiteralText() || ''];
4823
+ for (const span of expr.getTemplateSpans()) {
4824
+ const resolved = this.resolveExpressionValue(span.getExpression(), propsContext);
4825
+ if (resolved === undefined) return undefined;
4826
+ parts.push(String(resolved));
4827
+ parts.push(span.getLiteral().getLiteralText() || '');
4828
+ }
4829
+ return parts.join('');
4830
+ }
4831
+ return undefined;
4832
+ }
4833
+
4834
+ /**
4835
+ * Built-in JS evaluation for CallExpression — string / number / Date /
4836
+ * Intl.NumberFormat / Boolean methods, plus global conversion functions
4837
+ * (`Number`, `String`, `Boolean`, `parseInt`, `parseFloat`, `isNaN`,
4838
+ * `isFinite`) and `Number.*` / `Math.*` namespace functions.
4839
+ *
4840
+ * Returns `undefined` when the call shape isn't recognised — that's the
4841
+ * signal for the caller to keep trying (user-function evaluator, raw-arg
4842
+ * fallback).
4843
+ *
4844
+ * Restricted to pure, side-effect-free operations so we can run them at
4845
+ * scan time without surprising the consumer.
4846
+ */
4847
+ private evaluateBuiltinCall(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): ResolvedExpressionValue {
4848
+ if (!Node.isCallExpression(expr)) return undefined;
4849
+ const calleeExpr = expr.getExpression();
4850
+ if (Node.isPropertyAccessExpression(calleeExpr)) {
4851
+ const methodName = calleeExpr.getName();
4852
+ const baseExpr = calleeExpr.getExpression();
4853
+ // `Number.isFinite(x)`, `Number.isInteger(x)`, `Number.isNaN(x)`,
4854
+ // `Math.abs(x)`, `Math.floor(x)`, etc. — namespace dispatch, not
4855
+ // instance method dispatch.
4856
+ if (Node.isIdentifier(baseExpr)) {
4857
+ const baseText = baseExpr.getText();
4111
4858
  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]) {
4859
+ if (baseText === 'Number') {
4115
4860
  const arg = argValues[0];
4861
+ if (methodName === 'isFinite') return typeof arg === 'number' && Number.isFinite(arg);
4862
+ if (methodName === 'isInteger') return typeof arg === 'number' && Number.isInteger(arg);
4863
+ if (methodName === 'isNaN') return typeof arg === 'number' && Number.isNaN(arg);
4864
+ if (methodName === 'isSafeInteger') return typeof arg === 'number' && Number.isSafeInteger(arg);
4865
+ }
4866
+ if (baseText === 'Math') {
4867
+ if (argValues.some(v => typeof v !== 'number')) return undefined;
4868
+ const nums = argValues as number[];
4116
4869
  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);
4870
+ if (methodName === 'abs') return Math.abs(nums[0]);
4871
+ if (methodName === 'floor') return Math.floor(nums[0]);
4872
+ if (methodName === 'ceil') return Math.ceil(nums[0]);
4873
+ if (methodName === 'round') return Math.round(nums[0]);
4874
+ if (methodName === 'trunc') return Math.trunc(nums[0]);
4875
+ if (methodName === 'sign') return Math.sign(nums[0]);
4876
+ if (methodName === 'sqrt') return Math.sqrt(nums[0]);
4877
+ if (methodName === 'pow') return Math.pow(nums[0], nums[1]);
4878
+ if (methodName === 'min') return Math.min(...nums);
4879
+ if (methodName === 'max') return Math.max(...nums);
4120
4880
  } catch (_e) { return undefined; }
4121
4881
  }
4122
- if (typeof baseValue === 'string' && STRING_METHODS[methodName]) {
4882
+ // `Object.entries(x)` / `Object.keys(x)` / `Object.values(x)` /
4883
+ // `Object.fromEntries(pairs)` — pure, predictable shape transforms.
4884
+ // Real-world trigger: shadcn Select dropdowns built via
4885
+ // `Object.entries(TOKEN_METADATA).map(([k, v]) => <SelectItem ...>)`
4886
+ // — without this the scanner can't expand the .map() into actual
4887
+ // SelectItem nodes, so `findSelectedSelectLabel` can't resolve the
4888
+ // selected value's label and the Figma render shows the raw value
4889
+ // (mint address) instead of the label ("USDC").
4890
+ if (baseText === 'Object') {
4891
+ const arg = argValues[0];
4892
+ try {
4893
+ if (methodName === 'keys') {
4894
+ if (arg && typeof arg === 'object') return Object.keys(arg as object);
4895
+ return undefined;
4896
+ }
4897
+ if (methodName === 'values') {
4898
+ if (arg && typeof arg === 'object') return Object.values(arg as object);
4899
+ return undefined;
4900
+ }
4901
+ if (methodName === 'entries') {
4902
+ if (arg && typeof arg === 'object') return Object.entries(arg as object);
4903
+ return undefined;
4904
+ }
4905
+ if (methodName === 'fromEntries') {
4906
+ if (Array.isArray(arg)) return Object.fromEntries(arg as Iterable<[PropertyKey, unknown]>);
4907
+ return undefined;
4908
+ }
4909
+ } catch (_e) { return undefined; }
4910
+ }
4911
+ }
4912
+ const baseValue = this.resolveExpressionValue(baseExpr, propsContext);
4913
+ if (baseValue == null) return undefined;
4914
+ const argValues = expr.getArguments().map(a => this.resolveExpressionValue(a, propsContext));
4915
+ // Intl.NumberFormat / Intl.DateTimeFormat / Date / Set / Map instances.
4916
+ // The resolver returns the live JS object for `new Intl.NumberFormat(...)`
4917
+ // / `new Date(...)` / `new Set(...)` / `new Map(...)` expressions so we
4918
+ // can dispatch on the prototype here.
4919
+ if (typeof baseValue === 'object') {
4920
+ // `.size` is a getter, not a method — handled by the
4921
+ // PropertyAccessExpression branch above. Only the method calls
4922
+ // (`.has`, `.get`) live here.
4923
+ if (baseValue instanceof Set && methodName === 'has') {
4924
+ try { return baseValue.has(argValues[0]); } catch (_e) { return undefined; }
4925
+ }
4926
+ if (baseValue instanceof Map) {
4927
+ try {
4928
+ if (methodName === 'has') return baseValue.has(argValues[0]);
4929
+ if (methodName === 'get') return baseValue.get(argValues[0]);
4930
+ } catch (_e) { return undefined; }
4931
+ }
4932
+ if (baseValue instanceof Intl.NumberFormat && methodName === 'format' && typeof argValues[0] === 'number') {
4933
+ try { return baseValue.format(argValues[0]); } catch (_e) { return undefined; }
4934
+ }
4935
+ if (baseValue instanceof Intl.DateTimeFormat && methodName === 'format' && argValues[0] instanceof Date) {
4936
+ try { return baseValue.format(argValues[0]); } catch (_e) { return undefined; }
4937
+ }
4938
+ if (baseValue instanceof Date) {
4939
+ try {
4940
+ if (methodName === 'toISOString') return baseValue.toISOString();
4941
+ if (methodName === 'toUTCString') return baseValue.toUTCString();
4942
+ if (methodName === 'toDateString') return baseValue.toDateString();
4943
+ if (methodName === 'toTimeString') return baseValue.toTimeString();
4944
+ if (methodName === 'toString') return baseValue.toString();
4945
+ if (methodName === 'toLocaleString') return baseValue.toLocaleString();
4946
+ if (methodName === 'toLocaleDateString') return baseValue.toLocaleDateString();
4947
+ if (methodName === 'toLocaleTimeString') return baseValue.toLocaleTimeString();
4948
+ if (methodName === 'getTime') return baseValue.getTime();
4949
+ if (methodName === 'valueOf') return baseValue.valueOf();
4950
+ if (methodName === 'getFullYear') return baseValue.getFullYear();
4951
+ if (methodName === 'getMonth') return baseValue.getMonth();
4952
+ if (methodName === 'getDate') return baseValue.getDate();
4953
+ } catch (_e) { return undefined; }
4954
+ }
4955
+ }
4956
+ // String instance methods.
4957
+ if (typeof baseValue === 'string') {
4958
+ try {
4959
+ if (methodName === 'toString' || methodName === 'valueOf') return baseValue;
4123
4960
  if (methodName === 'toUpperCase') return baseValue.toUpperCase();
4124
4961
  if (methodName === 'toLowerCase') return baseValue.toLowerCase();
4125
4962
  if (methodName === 'trim') return baseValue.trim();
4126
- if (methodName === 'toString') return baseValue;
4963
+ if (methodName === 'trimStart' || methodName === 'trimLeft') return baseValue.trimStart();
4964
+ if (methodName === 'trimEnd' || methodName === 'trimRight') return baseValue.trimEnd();
4965
+ if (methodName === 'slice' && typeof argValues[0] === 'number') {
4966
+ return argValues.length >= 2 && typeof argValues[1] === 'number'
4967
+ ? baseValue.slice(argValues[0] as number, argValues[1] as number)
4968
+ : baseValue.slice(argValues[0] as number);
4969
+ }
4970
+ if (methodName === 'substring' && typeof argValues[0] === 'number') {
4971
+ return argValues.length >= 2 && typeof argValues[1] === 'number'
4972
+ ? baseValue.substring(argValues[0] as number, argValues[1] as number)
4973
+ : baseValue.substring(argValues[0] as number);
4974
+ }
4975
+ if (methodName === 'substr' && typeof argValues[0] === 'number') {
4976
+ // `String.prototype.substr` is deprecated but still used in the
4977
+ // wild; map it onto `substring`-equivalent semantics (start +
4978
+ // length) so consumer code that uses it still resolves.
4979
+ const start = argValues[0] as number;
4980
+ const len = typeof argValues[1] === 'number' ? (argValues[1] as number) : baseValue.length;
4981
+ return baseValue.substring(start, start + len);
4982
+ }
4983
+ if (methodName === 'replace' && typeof argValues[0] === 'string' && typeof argValues[1] === 'string') {
4984
+ return baseValue.replace(argValues[0] as string, argValues[1] as string);
4985
+ }
4986
+ if (methodName === 'replaceAll' && typeof argValues[0] === 'string' && typeof argValues[1] === 'string') {
4987
+ // String.prototype.replaceAll requires es2021 in the lib target;
4988
+ // emulate via split/join so we keep the current tsconfig target.
4989
+ return baseValue.split(argValues[0] as string).join(argValues[1] as string);
4990
+ }
4991
+ if (methodName === 'concat') {
4992
+ const allStrings = argValues.every(v => typeof v === 'string');
4993
+ if (allStrings) return baseValue.concat(...(argValues as string[]));
4994
+ }
4995
+ if (methodName === 'repeat' && typeof argValues[0] === 'number') {
4996
+ return baseValue.repeat(argValues[0] as number);
4997
+ }
4998
+ if (methodName === 'padStart' && typeof argValues[0] === 'number') {
4999
+ const pad = typeof argValues[1] === 'string' ? (argValues[1] as string) : ' ';
5000
+ return baseValue.padStart(argValues[0] as number, pad);
5001
+ }
5002
+ if (methodName === 'padEnd' && typeof argValues[0] === 'number') {
5003
+ const pad = typeof argValues[1] === 'string' ? (argValues[1] as string) : ' ';
5004
+ return baseValue.padEnd(argValues[0] as number, pad);
5005
+ }
5006
+ if (methodName === 'startsWith' && typeof argValues[0] === 'string') {
5007
+ return baseValue.startsWith(argValues[0] as string);
5008
+ }
5009
+ if (methodName === 'endsWith' && typeof argValues[0] === 'string') {
5010
+ return baseValue.endsWith(argValues[0] as string);
5011
+ }
5012
+ if (methodName === 'includes' && typeof argValues[0] === 'string') {
5013
+ return baseValue.includes(argValues[0] as string);
5014
+ }
5015
+ if (methodName === 'indexOf' && typeof argValues[0] === 'string') {
5016
+ return baseValue.indexOf(argValues[0] as string);
5017
+ }
5018
+ if (methodName === 'lastIndexOf' && typeof argValues[0] === 'string') {
5019
+ return baseValue.lastIndexOf(argValues[0] as string);
5020
+ }
5021
+ if (methodName === 'charAt' && typeof argValues[0] === 'number') {
5022
+ return baseValue.charAt(argValues[0] as number);
5023
+ }
5024
+ if (methodName === 'at' && typeof argValues[0] === 'number') {
5025
+ return baseValue.at(argValues[0] as number);
5026
+ }
5027
+ if (methodName === 'split' && typeof argValues[0] === 'string') {
5028
+ return baseValue.split(argValues[0] as string);
5029
+ }
5030
+ if (methodName === 'normalize') return baseValue.normalize();
5031
+ } catch (_e) { return undefined; }
5032
+ }
5033
+ // Number instance methods.
5034
+ if (typeof baseValue === 'number') {
5035
+ try {
5036
+ if (methodName === 'toString') {
5037
+ const radix = argValues[0];
5038
+ return typeof radix === 'number' ? baseValue.toString(radix) : String(baseValue);
5039
+ }
5040
+ if (methodName === 'valueOf') return baseValue;
5041
+ if (methodName === 'toFixed' && typeof argValues[0] === 'number') return baseValue.toFixed(argValues[0] as number);
5042
+ if (methodName === 'toPrecision' && typeof argValues[0] === 'number') return baseValue.toPrecision(argValues[0] as number);
5043
+ if (methodName === 'toExponential' && typeof argValues[0] === 'number') return baseValue.toExponential(argValues[0] as number);
5044
+ if (methodName === 'toLocaleString') return baseValue.toLocaleString();
5045
+ } catch (_e) { return undefined; }
5046
+ }
5047
+ // Boolean instance methods.
5048
+ if (typeof baseValue === 'boolean') {
5049
+ if (methodName === 'toString') return String(baseValue);
5050
+ if (methodName === 'valueOf') return baseValue;
5051
+ }
5052
+ // Array instance methods (limited subset — read-only / pure transforms).
5053
+ if (Array.isArray(baseValue)) {
5054
+ try {
5055
+ if (methodName === 'join') {
5056
+ const sep = typeof argValues[0] === 'string' ? (argValues[0] as string) : ',';
5057
+ return baseValue.join(sep);
5058
+ }
5059
+ if (methodName === 'slice') {
5060
+ const start = typeof argValues[0] === 'number' ? (argValues[0] as number) : undefined;
5061
+ const end = typeof argValues[1] === 'number' ? (argValues[1] as number) : undefined;
5062
+ return baseValue.slice(start, end);
5063
+ }
5064
+ if (methodName === 'concat') {
5065
+ return baseValue.concat(...(argValues.filter(v => v !== undefined) as ResolvedExpressionValue[]));
5066
+ }
5067
+ if (methodName === 'includes') return baseValue.includes(argValues[0]);
5068
+ if (methodName === 'indexOf') return baseValue.indexOf(argValues[0]);
5069
+ if (methodName === 'lastIndexOf') return baseValue.lastIndexOf(argValues[0]);
5070
+ if (methodName === 'at' && typeof argValues[0] === 'number') return baseValue.at(argValues[0] as number);
5071
+ } catch (_e) { return undefined; }
5072
+ }
5073
+ return undefined;
5074
+ }
5075
+ // Global identifier calls: `Number(x)`, `String(x)`, `Boolean(x)`,
5076
+ // `parseInt(x, 10)`, `parseFloat(x)`, `isNaN(x)`, `isFinite(x)`.
5077
+ if (Node.isIdentifier(calleeExpr)) {
5078
+ const fnName = calleeExpr.getText();
5079
+ const argValues = expr.getArguments().map(a => this.resolveExpressionValue(a, propsContext));
5080
+ const first = argValues[0];
5081
+ try {
5082
+ if (fnName === 'Number') {
5083
+ if (typeof first === 'number') return first;
5084
+ if (typeof first === 'string' || typeof first === 'boolean') return Number(first);
5085
+ if (first === null) return 0;
5086
+ return undefined;
4127
5087
  }
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;
5088
+ if (fnName === 'String') return String(first);
5089
+ if (fnName === 'Boolean') return Boolean(first);
5090
+ if (fnName === 'parseInt') {
5091
+ if (typeof first !== 'string' && typeof first !== 'number') return undefined;
5092
+ const radix = typeof argValues[1] === 'number' ? (argValues[1] as number) : 10;
5093
+ return parseInt(String(first), radix);
5094
+ }
5095
+ if (fnName === 'parseFloat') {
5096
+ if (typeof first !== 'string' && typeof first !== 'number') return undefined;
5097
+ return parseFloat(String(first));
4145
5098
  }
5099
+ if (fnName === 'isNaN') return typeof first === 'number' ? isNaN(first) : undefined;
5100
+ if (fnName === 'isFinite') return typeof first === 'number' ? isFinite(first) : undefined;
5101
+ } catch (_e) { return undefined; }
5102
+ }
5103
+ return undefined;
5104
+ }
5105
+
5106
+ /**
5107
+ * P2: handle `new Intl.NumberFormat(...)`, `new Intl.DateTimeFormat(...)`,
5108
+ * `new Date(...)`. Returns the live JS instance so subsequent method calls
5109
+ * (`fmt.format(123)`, `date.toLocaleString()`) can dispatch on its
5110
+ * prototype via `evaluateBuiltinCall`.
5111
+ *
5112
+ * All three constructors are pure (no IO, no DOM). Safe to run at scan
5113
+ * time. We swallow construction errors and return undefined to keep the
5114
+ * scanner robust to oddly-shaped args.
5115
+ */
5116
+ private evaluateNewExpression(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): ResolvedExpressionValue {
5117
+ if (!Node.isNewExpression(expr)) return undefined;
5118
+ const calleeExpr = expr.getExpression();
5119
+ const args = (expr.getArguments() || []).map(a => this.resolveExpressionValue(a, propsContext));
5120
+ // `new Intl.NumberFormat(...)` / `new Intl.DateTimeFormat(...)`
5121
+ if (Node.isPropertyAccessExpression(calleeExpr)) {
5122
+ const ns = calleeExpr.getExpression().getText();
5123
+ const ctor = calleeExpr.getName();
5124
+ if (ns === 'Intl') {
5125
+ try {
5126
+ if (ctor === 'NumberFormat') {
5127
+ const locale = typeof args[0] === 'string' ? (args[0] as string) : (Array.isArray(args[0]) ? (args[0] as string[]) : undefined);
5128
+ const opts = args[1] && typeof args[1] === 'object' ? (args[1] as Intl.NumberFormatOptions) : undefined;
5129
+ return new Intl.NumberFormat(locale, opts);
5130
+ }
5131
+ if (ctor === 'DateTimeFormat') {
5132
+ const locale = typeof args[0] === 'string' ? (args[0] as string) : undefined;
5133
+ const opts = args[1] && typeof args[1] === 'object' ? (args[1] as Intl.DateTimeFormatOptions) : undefined;
5134
+ return new Intl.DateTimeFormat(locale, opts);
5135
+ }
5136
+ } catch (_e) { return undefined; }
5137
+ }
5138
+ }
5139
+ if (Node.isIdentifier(calleeExpr) && calleeExpr.getText() === 'Date') {
5140
+ try {
5141
+ if (args.length === 0) return new Date();
5142
+ const a = args[0];
5143
+ if (typeof a === 'string' || typeof a === 'number') return new Date(a);
5144
+ } catch (_e) { return undefined; }
5145
+ }
5146
+ // `new Set([...])` / `new Set()` and `new Map([[k, v], ...])` / `new Map()`.
5147
+ // Pure constructors with no IO, safe at scan time. Enables the common
5148
+ // visibility-gate idiom `const active = new Set(activeGroups ?? DEFAULTS);
5149
+ // active.has("price") ? <Section/> : null` — Set/Map methods then dispatch
5150
+ // via `evaluateBuiltinCall`.
5151
+ if (Node.isIdentifier(calleeExpr)) {
5152
+ const ctorName = calleeExpr.getText();
5153
+ if (ctorName === 'Set') {
5154
+ try {
5155
+ if (args.length === 0) return new Set();
5156
+ if (Array.isArray(args[0])) return new Set(args[0] as Iterable<unknown>);
5157
+ return undefined;
5158
+ } catch (_e) { return undefined; }
5159
+ }
5160
+ if (ctorName === 'Map') {
5161
+ try {
5162
+ if (args.length === 0) return new Map();
5163
+ if (Array.isArray(args[0])) {
5164
+ // Validate every entry is a 2-tuple — silently drop malformed
5165
+ // inputs rather than throwing, so the scanner remains robust.
5166
+ const entries = args[0] as unknown[];
5167
+ const valid: Array<[unknown, unknown]> = [];
5168
+ for (const e of entries) {
5169
+ if (Array.isArray(e) && e.length === 2) valid.push([e[0], e[1]]);
5170
+ }
5171
+ return new Map(valid);
5172
+ }
5173
+ return undefined;
5174
+ } catch (_e) { return undefined; }
4146
5175
  }
4147
5176
  }
4148
5177
  return undefined;
4149
5178
  }
4150
5179
 
5180
+ /**
5181
+ * P3: evaluate a user-defined function call by walking the function body
5182
+ * AST. Supports the common shapes used by formatter helpers in consumer
5183
+ * code (`formatUsd`, `formatUsdFromBase`, `resolveUsdNumber`, etc.):
5184
+ *
5185
+ * - Arrow functions and `function` declarations
5186
+ * - Single-expression bodies (`(x) => x.toFixed(2)`)
5187
+ * - Block bodies with `if` / `return` / `const` statements
5188
+ * - Default parameter values
5189
+ * - Recursive calls into other user functions (handled implicitly
5190
+ * because the interpreter calls back into `resolveExpressionValue`)
5191
+ *
5192
+ * Falls back to `undefined` (caller will use the raw-arg fallback) if
5193
+ * the function isn't found, the body uses unsupported constructs, or
5194
+ * any sub-expression resolves to undefined.
5195
+ *
5196
+ * Recursion is depth-limited to guard against pathological code or
5197
+ * accidental infinite chains.
5198
+ */
5199
+ private evaluateUserFunctionCall(
5200
+ callExpr: Node,
5201
+ propsContext: Map<string, ResolvedExpressionValue>
5202
+ ): ResolvedExpressionValue {
5203
+ if (!Node.isCallExpression(callExpr)) return undefined;
5204
+ const calleeExpr = callExpr.getExpression();
5205
+
5206
+ // IIFE form: `(() => { ... })()` or `(function() { ... })()`. The
5207
+ // callee is an inline arrow / function expression wrapped in
5208
+ // parentheses, with the call's args supplied (often zero — the
5209
+ // common pattern is a no-arg IIFE used to scope local state inside
5210
+ // a single JSX expression). We can interpret it the same way as a
5211
+ // named user function — bind params, walk body — without needing
5212
+ // the import / declaration lookup.
5213
+ let inlineFn: UserFunctionNode | undefined;
5214
+ if (Node.isParenthesizedExpression(calleeExpr)) {
5215
+ const inner = calleeExpr.getExpression();
5216
+ if (Node.isArrowFunction(inner) || Node.isFunctionExpression(inner)) inlineFn = inner;
5217
+ } else if (Node.isArrowFunction(calleeExpr) || Node.isFunctionExpression(calleeExpr)) {
5218
+ inlineFn = calleeExpr;
5219
+ }
5220
+ if (inlineFn) {
5221
+ return this.evaluateFunctionLike(inlineFn, callExpr.getArguments(), propsContext, '__iife__');
5222
+ }
5223
+
5224
+ if (!Node.isIdentifier(calleeExpr)) return undefined;
5225
+ const fnName = calleeExpr.getText();
5226
+ const fnNode = this.resolveFunctionDeclaration(fnName, callExpr);
5227
+ if (!fnNode) return undefined;
5228
+ return this.evaluateFunctionLike(fnNode, callExpr.getArguments(), propsContext, fnName);
5229
+ }
5230
+
5231
+ /**
5232
+ * Shared param-binding + body-interpretation entry point for both named
5233
+ * user functions (resolved via import / declaration lookup) and inline
5234
+ * IIFE-style invocations `(() => { ... })()`. The recursion guard keys
5235
+ * on the function's source-file path so each call site participates in
5236
+ * the same depth limit.
5237
+ */
5238
+ private evaluateFunctionLike(
5239
+ fnNode: UserFunctionNode,
5240
+ argExprs: Node[],
5241
+ propsContext: Map<string, ResolvedExpressionValue>,
5242
+ debugKey: string,
5243
+ ): ResolvedExpressionValue {
5244
+ if (!this.userFunctionStack) this.userFunctionStack = new Set();
5245
+ const stackKey = fnNode.getSourceFile().getFilePath() + ':' + debugKey;
5246
+ if (this.userFunctionStack.has(stackKey)) return undefined;
5247
+ if (this.userFunctionStack.size > 16) return undefined;
5248
+ this.userFunctionStack.add(stackKey);
5249
+ try {
5250
+ const params = fnNode.getParameters();
5251
+ const localContext = new Map(propsContext);
5252
+ for (let i = 0; i < params.length; i++) {
5253
+ const paramName = params[i].getName();
5254
+ let value: ResolvedExpressionValue = undefined;
5255
+ if (i < argExprs.length) {
5256
+ value = this.resolveExpressionValue(argExprs[i], propsContext);
5257
+ }
5258
+ if (value === undefined) {
5259
+ const defaultInit = params[i].getInitializer();
5260
+ if (defaultInit) value = this.resolveExpressionValue(defaultInit, propsContext);
5261
+ }
5262
+ localContext.set(paramName, value);
5263
+ }
5264
+ const body = fnNode.getBody();
5265
+ if (!body) return undefined;
5266
+ if (Node.isBlock(body)) {
5267
+ return this.evaluateFunctionBlock(body, localContext);
5268
+ }
5269
+ // Arrow function with expression body.
5270
+ return this.resolveExpressionValue(body, localContext);
5271
+ } finally {
5272
+ this.userFunctionStack.delete(stackKey);
5273
+ }
5274
+ }
5275
+
5276
+ /**
5277
+ * Find the function declaration for a given identifier, following one
5278
+ * level of `import { name } from '...'` indirection.
5279
+ *
5280
+ * Resolution order:
5281
+ * 1. Variable declaration in the same source file whose initializer is
5282
+ * an arrow function or function expression.
5283
+ * 2. Top-level `function` declaration in the same file.
5284
+ * 3. Single-named import — follow the module specifier to the source
5285
+ * file and repeat the search there.
5286
+ *
5287
+ * Returns the function-or-arrow node, or undefined when not resolvable.
5288
+ */
5289
+ private resolveFunctionDeclaration(name: string, originExpr: Node): UserFunctionNode | undefined {
5290
+ const sourceFile = originExpr.getSourceFile();
5291
+ const localVar = sourceFile.getVariableDeclaration(name);
5292
+ if (localVar) {
5293
+ const init = localVar.getInitializer();
5294
+ if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
5295
+ return init;
5296
+ }
5297
+ }
5298
+ const localFn = sourceFile.getFunction(name);
5299
+ if (localFn) return localFn;
5300
+ for (const importDecl of sourceFile.getImportDeclarations()) {
5301
+ const namedImports = importDecl.getNamedImports();
5302
+ const match = namedImports.find(ni => (ni.getAliasNode()?.getText() ?? ni.getName()) === name);
5303
+ if (!match) continue;
5304
+ const sourceModule = importDecl.getModuleSpecifierSourceFile();
5305
+ if (!sourceModule) return undefined;
5306
+ const importedName = match.getName();
5307
+ const importedVar = sourceModule.getVariableDeclaration(importedName);
5308
+ if (importedVar) {
5309
+ const init = importedVar.getInitializer();
5310
+ if (init && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
5311
+ return init;
5312
+ }
5313
+ }
5314
+ const importedFn = sourceModule.getFunction(importedName);
5315
+ if (importedFn) return importedFn;
5316
+ return undefined;
5317
+ }
5318
+ return undefined;
5319
+ }
5320
+
5321
+ /**
5322
+ * Mini-interpreter for the body of a user function. Supports the
5323
+ * narrow set of statements that appear in pure formatter helpers:
5324
+ *
5325
+ * - `if (cond) { ... } else { ... }` — early returns work because we
5326
+ * surface the inner block's return value.
5327
+ * - `if (cond) return ...;` — single-statement consequent without block.
5328
+ * - `const x = expr;` / `let x = expr;` — variable bindings shadow
5329
+ * the caller's context for subsequent statements.
5330
+ * - `return expr;` — terminates evaluation with the resolved value.
5331
+ *
5332
+ * Returns the value of the executed `return`, or `undefined` if the
5333
+ * body uses an unsupported construct (which the caller treats as
5334
+ * "couldn't interpret, fall through").
5335
+ *
5336
+ * Uses a sentinel symbol to distinguish "block fell through with no
5337
+ * return" (continue interpreting outer statements) from "explicit
5338
+ * return undefined" — only the latter is a successful evaluation.
5339
+ */
5340
+ private evaluateFunctionBlock(
5341
+ block: Node,
5342
+ context: Map<string, ResolvedExpressionValue>
5343
+ ): ResolvedExpressionValue {
5344
+ if (!Node.isBlock(block)) return undefined;
5345
+ for (const stmt of block.getStatements()) {
5346
+ const result = this.evaluateStatement(stmt, context);
5347
+ if (result === EVAL_NEXT) continue;
5348
+ return result;
5349
+ }
5350
+ return undefined;
5351
+ }
5352
+
5353
+ private evaluateStatement(
5354
+ stmt: Node,
5355
+ context: Map<string, ResolvedExpressionValue>
5356
+ ): EvalStatementResult {
5357
+ if (Node.isReturnStatement(stmt)) {
5358
+ const expr = stmt.getExpression();
5359
+ if (!expr) return undefined;
5360
+ return this.resolveExpressionValue(expr, context);
5361
+ }
5362
+ if (Node.isVariableStatement(stmt)) {
5363
+ for (const decl of stmt.getDeclarationList().getDeclarations()) {
5364
+ const init = decl.getInitializer();
5365
+ if (!init) return EVAL_NEXT;
5366
+ const value = this.resolveExpressionValue(init, context);
5367
+ context.set(decl.getName(), value);
5368
+ }
5369
+ return EVAL_NEXT;
5370
+ }
5371
+ if (Node.isIfStatement(stmt)) {
5372
+ const condValue = this.resolveExpressionValue(stmt.getExpression(), context);
5373
+ const condBool = this.coerceBoolean(condValue);
5374
+ if (condBool == null) return undefined;
5375
+ const branch = condBool ? stmt.getThenStatement() : stmt.getElseStatement();
5376
+ if (!branch) return EVAL_NEXT;
5377
+ if (Node.isBlock(branch)) {
5378
+ return this.evaluateFunctionBlock(branch, context);
5379
+ }
5380
+ return this.evaluateStatement(branch, context);
5381
+ }
5382
+ if (Node.isBlock(stmt)) {
5383
+ return this.evaluateFunctionBlock(stmt, context);
5384
+ }
5385
+ if (Node.isExpressionStatement(stmt)) {
5386
+ // No side effects to model; just evaluate for any assignments we
5387
+ // might add in the future. For now, treat as a fall-through.
5388
+ return EVAL_NEXT;
5389
+ }
5390
+ return undefined;
5391
+ }
5392
+
5393
+ private userFunctionStack?: Set<string>;
5394
+
4151
5395
  private resolveClassNameExpression(expr: Node, propsContext: Map<string, ResolvedExpressionValue>): string {
4152
5396
  if (Node.isParenthesizedExpression(expr)) {
4153
5397
  return this.resolveClassNameExpression(expr.getExpression(), propsContext);
@@ -4327,6 +5571,18 @@ export class ComponentScanner {
4327
5571
  }
4328
5572
  }
4329
5573
  }
5574
+
5575
+ // cva()'s special pass-through args: `className` and `class` are
5576
+ // merged into the output verbatim. Without this, shadcn's
5577
+ // `cn(buttonVariants({ variant, size, className }))` lost the
5578
+ // user's className override entirely — the `+` / `−` buttons in
5579
+ // greenhouse-app's LeverageSlider rendered as rounded-md ghost
5580
+ // buttons instead of the rounded-full bordered circles the
5581
+ // consumer asked for. cva treats className/class as the final
5582
+ // merge step on top of base + variant classes, so append them
5583
+ // at the end of our resolved class list.
5584
+ const passThrough = requestedVariants.className || requestedVariants.class;
5585
+ if (passThrough) classes.push(passThrough);
4330
5586
  }
4331
5587
 
4332
5588
  return resolveClassConflicts(classes.filter(Boolean).join(' '));