inkbridge 0.1.0-beta.2 → 0.1.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -0,0 +1,161 @@
1
+ import assert from 'node:assert/strict';
2
+ import { parseRenderPropToSyntheticInstance } from '../src/design-system/render-prop-parser';
3
+
4
+ /**
5
+ * Regression: parseRenderPropToSyntheticInstance turns a Storybook
6
+ * `render="<Foo bar=\"x\">y</Foo>"` string literal into a synthetic
7
+ * ComponentInstance the renderer can treat like a real scanner output.
8
+ * Stories that declare their JSX as a literal string instead of a render
9
+ * function depend on this — if the parser drifts, those stories silently
10
+ * render as nothing.
11
+ *
12
+ * Extracted from `src/design-system/story-builder.ts` into
13
+ * `src/design-system/render-prop-parser.ts`.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Defensive: non-string / empty / falsy inputs return null without throwing
18
+ // ---------------------------------------------------------------------------
19
+
20
+ assert.equal(parseRenderPropToSyntheticInstance(null), null, 'null returns null');
21
+ assert.equal(parseRenderPropToSyntheticInstance(undefined), null, 'undefined returns null');
22
+ assert.equal(parseRenderPropToSyntheticInstance(42), null, 'number returns null');
23
+ assert.equal(parseRenderPropToSyntheticInstance({}), null, 'object returns null');
24
+ assert.equal(parseRenderPropToSyntheticInstance(''), null, 'empty string returns null');
25
+ assert.equal(parseRenderPropToSyntheticInstance(' '), null, 'whitespace-only string returns null');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Tagname extraction
29
+ // ---------------------------------------------------------------------------
30
+
31
+ {
32
+ const out = parseRenderPropToSyntheticInstance('<Button>Click me</Button>');
33
+ assert.ok(out, 'simple tag must parse');
34
+ assert.equal(out.componentName, 'Button', 'componentName captures the opening tag');
35
+ assert.equal(out.children, 'Click me', 'inner text becomes children');
36
+ assert.deepEqual(out.props, {}, 'no attrs → empty props');
37
+ }
38
+
39
+ // Component naming preserved (PascalCase, hyphens, dots — namespaced
40
+ // components like `Form.Item` are captured as written).
41
+ for (const tag of ['Button', 'PrimaryButton', 'form-item', 'Form.Item', 'My_Custom_123']) {
42
+ const out = parseRenderPropToSyntheticInstance(`<${tag}>x</${tag}>`);
43
+ assert.ok(out, `tag "${tag}" must parse`);
44
+ assert.equal(out.componentName, tag, `tag "${tag}" preserved verbatim`);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Attribute extraction
49
+ // ---------------------------------------------------------------------------
50
+
51
+ // Single attr
52
+ {
53
+ const out = parseRenderPropToSyntheticInstance('<Button variant="primary">Go</Button>');
54
+ assert.ok(out);
55
+ assert.deepEqual(out.props, { variant: 'primary' }, 'single attr extracted');
56
+ }
57
+
58
+ // Multiple attrs
59
+ {
60
+ const out = parseRenderPropToSyntheticInstance(
61
+ '<Button variant="primary" size="lg" disabled="true">Submit</Button>',
62
+ );
63
+ assert.ok(out);
64
+ assert.deepEqual(
65
+ out.props,
66
+ { variant: 'primary', size: 'lg', disabled: 'true' },
67
+ 'all attrs extracted',
68
+ );
69
+ }
70
+
71
+ // Attr names support letters, digits, underscore, hyphen, colon
72
+ {
73
+ const out = parseRenderPropToSyntheticInstance(
74
+ '<Foo aria-label="x" data-test="y" my_attr="z" ns:attr="w">x</Foo>',
75
+ );
76
+ assert.ok(out);
77
+ assert.deepEqual(out.props, {
78
+ 'aria-label': 'x',
79
+ 'data-test': 'y',
80
+ my_attr: 'z',
81
+ 'ns:attr': 'w',
82
+ });
83
+ }
84
+
85
+ // Boolean shorthand (no =) is ignored — non-goal documented in the module.
86
+ {
87
+ const out = parseRenderPropToSyntheticInstance('<Button disabled>x</Button>');
88
+ assert.ok(out);
89
+ assert.deepEqual(out.props, {}, 'boolean shorthand attrs are deliberately ignored');
90
+ }
91
+
92
+ // Empty attr values are kept (designers may want to detect their presence).
93
+ {
94
+ const out = parseRenderPropToSyntheticInstance('<Foo bar="">x</Foo>');
95
+ assert.ok(out);
96
+ assert.deepEqual(out.props, { bar: '' }, 'empty-string attr value preserved');
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Children handling
101
+ // ---------------------------------------------------------------------------
102
+
103
+ // Empty children → undefined (not empty string) so the renderer can treat
104
+ // it as "no content" rather than "explicit empty text".
105
+ {
106
+ const out = parseRenderPropToSyntheticInstance('<Button></Button>');
107
+ assert.ok(out);
108
+ assert.equal(out.children, undefined, 'empty children become undefined, not ""');
109
+ }
110
+
111
+ // Whitespace-only children → undefined (trimmed before falsy check).
112
+ {
113
+ const out = parseRenderPropToSyntheticInstance('<Button> </Button>');
114
+ assert.ok(out);
115
+ assert.equal(out.children, undefined, 'whitespace-only children become undefined');
116
+ }
117
+
118
+ // Inner content can contain markup (regex is non-greedy) — first matching
119
+ // closing tag wins.
120
+ {
121
+ const out = parseRenderPropToSyntheticInstance(
122
+ '<Card><span>Inner</span></Card>',
123
+ );
124
+ assert.ok(out);
125
+ assert.equal(out.componentName, 'Card');
126
+ assert.equal(out.children, '<span>Inner</span>', 'nested markup kept as raw text');
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Invalid inputs return null (no partial matches)
131
+ // ---------------------------------------------------------------------------
132
+
133
+ // Self-closing tag isn't matched — non-goal documented in module.
134
+ assert.equal(
135
+ parseRenderPropToSyntheticInstance('<Button />'),
136
+ null,
137
+ 'self-closing tag not supported — parser stays strict',
138
+ );
139
+
140
+ // Mismatched tags return null (backref \1 enforces equality).
141
+ assert.equal(
142
+ parseRenderPropToSyntheticInstance('<Button>x</Span>'),
143
+ null,
144
+ 'mismatched closing tag returns null',
145
+ );
146
+
147
+ // Just a string without tags returns null.
148
+ assert.equal(
149
+ parseRenderPropToSyntheticInstance('hello world'),
150
+ null,
151
+ 'plain text (no tags) returns null',
152
+ );
153
+
154
+ // Truncated / unclosed tag returns null.
155
+ assert.equal(
156
+ parseRenderPropToSyntheticInstance('<Button>missing close'),
157
+ null,
158
+ 'unclosed tag returns null',
159
+ );
160
+
161
+ console.log('render-prop-parser-regression: PASS');
@@ -0,0 +1,153 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { parseRingWidth, parseRingColor, getRingInfoFromClasses } from '../src/layout/ring-utils';
4
+
5
+ /**
6
+ * Regression: `applyTailwindStylesToFrame` in tailwind.ts now delegates
7
+ * ring rendering to `applyRingEffect` (overlay-frame approach in
8
+ * `layout/ring-utils.ts`) instead of pushing a Figma DROP_SHADOW with
9
+ * `spread > 0`.
10
+ *
11
+ * Why this matters: Figma surfaces
12
+ * "The 'spread' parameter is not supported when frames or components
13
+ * have no visible fills"
14
+ * for every transparent frame that gets a spread-based ring — i.e. every
15
+ * structural layout frame in the design system. The console fills with
16
+ * the warning for the duration of a build, and the workaround
17
+ * `clipsContent = true` from a prior round (for a different Figma spread
18
+ * constraint about clipping) is now redundant: the overlay path doesn't
19
+ * touch effects or fills.
20
+ *
21
+ * This file locks the ring-utils parser inputs that `applyRingEffect`
22
+ * relies on. If a future refactor re-introduces an inline DROP_SHADOW
23
+ * path or weakens the parser, these assertions catch it.
24
+ */
25
+
26
+ const colorGroup: Record<string, string> = {
27
+ ring: '#3b82f6',
28
+ primary: '#1d4ed8',
29
+ destructive: '#ef4444',
30
+ };
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // parseRingWidth — width-only utilities.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ assert.equal(parseRingWidth('ring'), 3, 'bare `ring` → default 3px');
37
+ assert.equal(parseRingWidth('ring-2'), 2, '`ring-2` → 2px');
38
+ assert.equal(parseRingWidth('ring-0'), 0, '`ring-0` → 0px (caller must filter zero)');
39
+ assert.equal(parseRingWidth('ring-[3px]'), 3, '`ring-[3px]` → 3px arbitrary');
40
+ assert.equal(parseRingWidth('ring-[1rem]'), 16, '`ring-[1rem]` → 16px (1rem = 16px)');
41
+ assert.equal(parseRingWidth('ring-inset'), null, '`ring-inset` is a modifier, not a width');
42
+ assert.equal(parseRingWidth('ring-offset-2'), null, '`ring-offset-N` is a separate utility');
43
+ assert.equal(parseRingWidth('ring-primary'), null, 'color utilities do not parse as width');
44
+ assert.equal(parseRingWidth('hover:ring-2'), null, 'variant-prefixed classes return null');
45
+ assert.equal(parseRingWidth('border-2'), null, 'unrelated utilities return null');
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // parseRingColor — color and color/opacity utilities.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ {
52
+ const color = parseRingColor('ring-primary', colorGroup);
53
+ assert.ok(color, '`ring-primary` resolves to a color');
54
+ assert.equal(color!.r, parseInt('1d', 16) / 255, 'primary R');
55
+ assert.equal(color!.g, parseInt('4e', 16) / 255, 'primary G');
56
+ assert.equal(color!.b, parseInt('d8', 16) / 255, 'primary B');
57
+ }
58
+
59
+ {
60
+ const color = parseRingColor('ring-primary/50', colorGroup);
61
+ assert.ok(color, '`ring-primary/50` resolves with opacity');
62
+ assert.equal(color!.a, 0.5, 'opacity 50 → 0.5');
63
+ }
64
+
65
+ {
66
+ const color = parseRingColor('ring-destructive', colorGroup);
67
+ assert.ok(color, '`ring-destructive` resolves');
68
+ assert.equal(color!.r, parseInt('ef', 16) / 255, 'destructive R');
69
+ }
70
+
71
+ assert.equal(parseRingColor('ring-2', colorGroup), null, 'width is not a color');
72
+ assert.equal(parseRingColor('ring', colorGroup), null, 'bare `ring` is width-only, not a color');
73
+ assert.equal(parseRingColor('ring-inset', colorGroup), null, '`ring-inset` is a modifier');
74
+ assert.equal(parseRingColor('ring-offset-primary', colorGroup), null, '`ring-offset-*` is a separate utility');
75
+ assert.equal(parseRingColor('ring-unknown', colorGroup), null, 'unknown token returns null');
76
+ assert.equal(parseRingColor('ring-[3px]', colorGroup), null, 'arbitrary widths are not colors');
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // getRingInfoFromClasses — the helper applyRingEffect calls.
80
+ // ---------------------------------------------------------------------------
81
+
82
+ {
83
+ const ring = getRingInfoFromClasses(['ring-2', 'ring-primary'], colorGroup);
84
+ assert.ok(ring, 'width + color → ring');
85
+ assert.equal(ring!.width, 2, 'width 2');
86
+ assert.equal(ring!.color.r, parseInt('1d', 16) / 255, 'primary R');
87
+ }
88
+
89
+ {
90
+ // Bare `ring` defaults to 3px and uses the `ring` token color when no
91
+ // explicit color class is present.
92
+ const ring = getRingInfoFromClasses(['ring'], colorGroup);
93
+ assert.ok(ring, 'bare ring resolves with token fallback');
94
+ assert.equal(ring!.width, 3, 'default 3px');
95
+ assert.equal(ring!.color.r, parseInt('3b', 16) / 255, 'ring token R');
96
+ }
97
+
98
+ {
99
+ // Color WITHOUT an explicit width is a CSS no-op — Tailwind's
100
+ // `ring-COLOR` sets `--tw-ring-color` but the ring stays
101
+ // invisible until a width utility is added. shadcn's
102
+ // invalid-input pattern relies on this:
103
+ // `aria-invalid:ring-destructive/20 focus-visible:ring-[3px]`
104
+ // → tinted ring when invalid, only visible (3px) when focused.
105
+ // Previously this helper defaulted to width 3 when ANY ring color
106
+ // was present — so the State Matrix `error` variant rendered with
107
+ // a doubled red ring outside the destructive border instead of
108
+ // just the border. Now: color-only → null (no ring).
109
+ const ring = getRingInfoFromClasses(['ring-primary'], colorGroup);
110
+ assert.equal(ring, null, 'color without width → null (no ring)');
111
+ }
112
+
113
+ {
114
+ // Same contract for the shadcn invalid-input pattern: tinted color,
115
+ // no width, no visible ring.
116
+ const ring = getRingInfoFromClasses(['ring-destructive', 'border-destructive'], colorGroup);
117
+ assert.equal(ring, null, 'color + non-ring class without width → null');
118
+ }
119
+
120
+ {
121
+ // Color + explicit width DOES render the ring.
122
+ const ring = getRingInfoFromClasses(['ring-destructive', 'ring-[3px]'], colorGroup);
123
+ assert.ok(ring, 'color + arbitrary width → ring renders');
124
+ assert.equal(ring!.width, 3, '3px from `ring-[3px]`');
125
+ }
126
+
127
+ {
128
+ // No ring classes → null.
129
+ const ring = getRingInfoFromClasses(['border-2', 'rounded-md'], colorGroup);
130
+ assert.equal(ring, null, 'no ring classes → null');
131
+ }
132
+
133
+ {
134
+ // ring-0 explicitly disables — width <= 0 returns null.
135
+ const ring = getRingInfoFromClasses(['ring-0'], colorGroup);
136
+ assert.equal(ring, null, '`ring-0` → null (no effect)');
137
+ }
138
+
139
+ {
140
+ // Color group without `ring` falls back to `primary`.
141
+ const minimalGroup: Record<string, string> = { primary: '#000000' };
142
+ const ring = getRingInfoFromClasses(['ring'], minimalGroup);
143
+ assert.ok(ring, 'falls back to primary when ring token is absent');
144
+ assert.equal(ring!.color.r, 0, 'fallback color R');
145
+ }
146
+
147
+ {
148
+ // Empty color group → null (no fallback available).
149
+ const ring = getRingInfoFromClasses(['ring'], {});
150
+ assert.equal(ring, null, 'no color group → null');
151
+ }
152
+
153
+ console.log('ring-utility-regression: PASS (parseRingWidth + parseRingColor + getRingInfoFromClasses)');
@@ -0,0 +1,125 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { Project, SyntaxKind, type Node } from 'ts-morph';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ // Regression: the Figma plugin sandbox (where code.js runs) chokes on
11
+ // value-side **spread** in function calls and array literals — that is
12
+ // the form documented in .ai/behaviour.md and the one that bit Slider
13
+ // (Math.min(...arr) silently threw and the whole render bailed). Object
14
+ // spread (esbuild down-compiles to Object.assign at target=es2017) and
15
+ // function rest parameters / destructuring rest are well-supported by
16
+ // the sandbox's engine and are NOT flagged here.
17
+ //
18
+ // We walk the AST and flag the dangerous form only:
19
+ //
20
+ // DANGEROUS (flagged):
21
+ // - SpreadElement (call args / array literal: fn(...x), [...x])
22
+ //
23
+ // SAFE (not flagged):
24
+ // - SpreadAssignment (object literal spread: { ...obj, k: v })
25
+ // - Parameter+rest (function rest param: function(...args))
26
+ // - BindingElement+rest (destructuring rest: const [a, ...rest] = arr)
27
+ // - Type-position rest (TS types are erased at compile time)
28
+ //
29
+ // Replacements when a flag fires (mirroring patches already shipped in
30
+ // the codebase):
31
+ // Math.min(...arr) / Math.max(...arr) → manual for-loop computing min/max
32
+ // arr.push(...other) → for (let i = 0; i < other.length; i++) arr.push(other[i])
33
+ // [...set] / [...arr, item] → Array.from(set) / arr.concat([item])
34
+ // const [a, ...rest] = arr → arr.slice(1)
35
+ // function (...args) → explicit array param
36
+
37
+ const SRC_DIR = path.resolve(__dirname, '..', 'src');
38
+
39
+ function gatherSrcFiles(dir: string, out: string[]): void {
40
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
41
+ const full = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ gatherSrcFiles(full, out);
44
+ } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
45
+ out.push(full);
46
+ }
47
+ }
48
+ }
49
+
50
+ const files: string[] = [];
51
+ gatherSrcFiles(SRC_DIR, files);
52
+
53
+ assert.ok(files.length > 0, 'expected to find .ts files under tools/figma-plugin/src/');
54
+
55
+ const project = new Project({
56
+ tsConfigFilePath: path.resolve(__dirname, '..', 'tsconfig.json'),
57
+ skipAddingFilesFromTsConfig: true,
58
+ });
59
+ for (const f of files) project.addSourceFileAtPath(f);
60
+
61
+ type Violation = { file: string; line: number; col: number; kind: string; snippet: string };
62
+ const violations: Violation[] = [];
63
+
64
+ function isInTypeContext(node: Node): boolean {
65
+ // Walk up; if we cross into a TypeNode / TypeReference / TypeLiteral
66
+ // (anything erased at compile time), the spread is type-only and safe.
67
+ let parent = node.getParent();
68
+ while (parent) {
69
+ const k = parent.getKind();
70
+ if (
71
+ k === SyntaxKind.TypeReference
72
+ || k === SyntaxKind.TypeLiteral
73
+ || k === SyntaxKind.IntersectionType
74
+ || k === SyntaxKind.UnionType
75
+ || k === SyntaxKind.TupleType
76
+ || k === SyntaxKind.FunctionType
77
+ || k === SyntaxKind.ConstructorType
78
+ || k === SyntaxKind.TypeAliasDeclaration
79
+ || k === SyntaxKind.InterfaceDeclaration
80
+ || k === SyntaxKind.RestType
81
+ || k === SyntaxKind.NamedTupleMember
82
+ ) {
83
+ return true;
84
+ }
85
+ parent = parent.getParent();
86
+ }
87
+ return false;
88
+ }
89
+
90
+ for (const sf of project.getSourceFiles()) {
91
+ const relative = path.relative(SRC_DIR, sf.getFilePath());
92
+
93
+ sf.forEachDescendant((node) => {
94
+ const k = node.getKind();
95
+ // DANGEROUS: SpreadElement in a call or array literal.
96
+ if (k === SyntaxKind.SpreadElement) {
97
+ if (isInTypeContext(node)) return;
98
+ const { line, column } = sf.getLineAndColumnAtPos(node.getStart());
99
+ violations.push({
100
+ file: relative,
101
+ line,
102
+ col: column,
103
+ kind: 'SpreadElement',
104
+ snippet: node.getText().slice(0, 80),
105
+ });
106
+ return;
107
+ }
108
+ // SpreadAssignment (object-literal spread), Parameter(rest), and
109
+ // BindingElement(rest) are INTENTIONALLY NOT flagged — they all
110
+ // compile to forms the sandbox engine supports. Keep an eye on
111
+ // this if the build target ever changes.
112
+ });
113
+ }
114
+
115
+ if (violations.length > 0) {
116
+ console.error('sandbox-spread-regression: FAIL — dangerous spread/rest leaked into src/');
117
+ for (const v of violations) {
118
+ console.error(` ${v.file}:${v.line}:${v.col} ${v.kind} ${v.snippet}`);
119
+ }
120
+ console.error('');
121
+ console.error('Replace each with Array.from / .concat / .slice / manual loop. See header of this file.');
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log(`sandbox-spread-regression: PASS (scanned ${files.length} files, 0 dangerous spread/rest in src/)`);
@@ -0,0 +1,241 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: a group element with a static `defaultValue` (or `value`)
7
+ * array/string must inject `defaultPressed="true"` on each child whose
8
+ * own `value` prop matches one of the group's values.
9
+ *
10
+ * The variant engine's `data-pressed` alias then activates
11
+ * `data-[pressed]:*` classes at render time, so e.g. ToggleGroup's
12
+ * selected item visibly toggles its `data-[pressed]:bg-accent` style.
13
+ *
14
+ * Statically models base-ui's ToggleGroup/RadioGroup runtime behaviour,
15
+ * which compares the group's value(s) against each item's value and
16
+ * pushes `data-pressed`/`data-checked` to the matching items. The Context
17
+ * cascade only carries variant/size — selection state lives on the group
18
+ * primitive's own `defaultValue` prop.
19
+ */
20
+
21
+ interface JsxTextNode { type: 'text'; content: string }
22
+ interface JsxElementNode {
23
+ type: 'element';
24
+ tagName?: string;
25
+ props?: Record<string, string>;
26
+ children?: JsxNodeLike[];
27
+ }
28
+ type JsxNodeLike = JsxTextNode | JsxElementNode;
29
+
30
+ interface TestScannerView {
31
+ project: import('ts-morph').Project;
32
+ extractComponentJsxTree: (
33
+ sourceFile: import('ts-morph').SourceFile,
34
+ componentName: string
35
+ ) => JsxNodeLike | null;
36
+ }
37
+
38
+ function makeScanner(): TestScannerView {
39
+ const scanner = new ComponentScanner({
40
+ componentPaths: [],
41
+ filePattern: '*.tsx',
42
+ exclude: [],
43
+ });
44
+ return scanner as unknown as TestScannerView;
45
+ }
46
+
47
+ function fixturePath(relative: string): string {
48
+ return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
49
+ }
50
+
51
+ function findElementsByTag(
52
+ node: JsxNodeLike | null | undefined,
53
+ tagName: string,
54
+ out: JsxElementNode[] = []
55
+ ): JsxElementNode[] {
56
+ if (!node || node.type !== 'element') return out;
57
+ if (node.tagName === tagName) out.push(node);
58
+ if (node.children) for (const c of node.children) findElementsByTag(c, tagName, out);
59
+ return out;
60
+ }
61
+
62
+ const scanner = makeScanner();
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Case 1: array `defaultValue` — match injects defaultPressed on the
66
+ // matching item, leaves others untouched.
67
+ // ---------------------------------------------------------------------------
68
+ {
69
+ const file = scanner.project.createSourceFile(
70
+ fixturePath('toggle-group-array-default.tsx'),
71
+ `
72
+ function Story() {
73
+ return (
74
+ <div defaultValue={["center"]}>
75
+ <button value="left" />
76
+ <button value="center" />
77
+ <button value="right" />
78
+ </div>
79
+ );
80
+ }
81
+ export { Story };
82
+ `,
83
+ { overwrite: true }
84
+ );
85
+
86
+ const tree = scanner.extractComponentJsxTree(file, 'Story');
87
+ assert.ok(tree && tree.type === 'element', 'Story tree must build');
88
+ const buttons = findElementsByTag(tree, 'button');
89
+ assert.equal(buttons.length, 3, `expected 3 buttons, got ${buttons.length}`);
90
+ assert.equal(buttons[0].props?.defaultPressed, undefined, 'left: not selected, no injection');
91
+ assert.equal(buttons[1].props?.defaultPressed, 'true', 'center: matches, defaultPressed=true');
92
+ assert.equal(buttons[2].props?.defaultPressed, undefined, 'right: not selected, no injection');
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Case 2: multi-value `defaultValue` array — all matching children pressed.
97
+ // ---------------------------------------------------------------------------
98
+ {
99
+ const file = scanner.project.createSourceFile(
100
+ fixturePath('toggle-group-multi-default.tsx'),
101
+ `
102
+ function Story() {
103
+ return (
104
+ <div defaultValue={["bold", "italic"]}>
105
+ <span value="bold" />
106
+ <span value="italic" />
107
+ <span value="underline" />
108
+ </div>
109
+ );
110
+ }
111
+ export { Story };
112
+ `,
113
+ { overwrite: true }
114
+ );
115
+
116
+ const tree = scanner.extractComponentJsxTree(file, 'Story');
117
+ assert.ok(tree && tree.type === 'element', 'Story tree must build');
118
+ const spans = findElementsByTag(tree, 'span');
119
+ assert.equal(spans.length, 3, `expected 3 spans, got ${spans.length}`);
120
+ assert.equal(spans[0].props?.defaultPressed, 'true', 'bold: matches');
121
+ assert.equal(spans[1].props?.defaultPressed, 'true', 'italic: matches');
122
+ assert.equal(spans[2].props?.defaultPressed, undefined, 'underline: no match');
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Case 3: scalar `defaultValue` string — single match works.
127
+ // ---------------------------------------------------------------------------
128
+ {
129
+ const file = scanner.project.createSourceFile(
130
+ fixturePath('toggle-group-scalar-default.tsx'),
131
+ `
132
+ function Story() {
133
+ return (
134
+ <ul defaultValue="b">
135
+ <li value="a" />
136
+ <li value="b" />
137
+ <li value="c" />
138
+ </ul>
139
+ );
140
+ }
141
+ export { Story };
142
+ `,
143
+ { overwrite: true }
144
+ );
145
+
146
+ const tree = scanner.extractComponentJsxTree(file, 'Story');
147
+ assert.ok(tree && tree.type === 'element', 'Story tree must build');
148
+ const items = findElementsByTag(tree, 'li');
149
+ assert.equal(items[0].props?.defaultPressed, undefined, 'a: no match');
150
+ assert.equal(items[1].props?.defaultPressed, 'true', 'b: scalar string match');
151
+ assert.equal(items[2].props?.defaultPressed, undefined, 'c: no match');
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Case 4: `value` (not `defaultValue`) also works — controlled-mode in
156
+ // the runtime maps to the same selection-match logic statically.
157
+ // ---------------------------------------------------------------------------
158
+ {
159
+ const file = scanner.project.createSourceFile(
160
+ fixturePath('toggle-group-value-default.tsx'),
161
+ `
162
+ function Story() {
163
+ return (
164
+ <section value={["x"]}>
165
+ <article value="x" />
166
+ <article value="y" />
167
+ </section>
168
+ );
169
+ }
170
+ export { Story };
171
+ `,
172
+ { overwrite: true }
173
+ );
174
+
175
+ const tree = scanner.extractComponentJsxTree(file, 'Story');
176
+ assert.ok(tree && tree.type === 'element', 'Story tree must build');
177
+ const articles = findElementsByTag(tree, 'article');
178
+ assert.equal(articles[0].props?.defaultPressed, 'true', 'first: matches "value" prop');
179
+ assert.equal(articles[1].props?.defaultPressed, undefined, 'second: no match');
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Case 5: explicit `defaultPressed` on the child wins — selection-match
184
+ // MUST NOT override a deliberate consumer setting.
185
+ // ---------------------------------------------------------------------------
186
+ {
187
+ const file = scanner.project.createSourceFile(
188
+ fixturePath('toggle-group-explicit-pressed.tsx'),
189
+ `
190
+ function Story() {
191
+ return (
192
+ <div defaultValue={["a"]}>
193
+ <button value="a" defaultPressed={false} />
194
+ <button value="b" />
195
+ </div>
196
+ );
197
+ }
198
+ export { Story };
199
+ `,
200
+ { overwrite: true }
201
+ );
202
+
203
+ const tree = scanner.extractComponentJsxTree(file, 'Story');
204
+ assert.ok(tree && tree.type === 'element', 'Story tree must build');
205
+ const buttons = findElementsByTag(tree, 'button');
206
+ assert.equal(
207
+ String(buttons[0].props?.defaultPressed),
208
+ 'false',
209
+ 'explicit defaultPressed=false stays — selection-match must not override',
210
+ );
211
+ assert.equal(buttons[1].props?.defaultPressed, undefined, 'second: no match, no injection');
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Case 6: no defaultValue/value on parent — no injection on any child.
216
+ // ---------------------------------------------------------------------------
217
+ {
218
+ const file = scanner.project.createSourceFile(
219
+ fixturePath('no-default-value.tsx'),
220
+ `
221
+ function Story() {
222
+ return (
223
+ <div>
224
+ <button value="a" />
225
+ <button value="b" />
226
+ </div>
227
+ );
228
+ }
229
+ export { Story };
230
+ `,
231
+ { overwrite: true }
232
+ );
233
+
234
+ const tree = scanner.extractComponentJsxTree(file, 'Story');
235
+ assert.ok(tree && tree.type === 'element', 'Story tree must build');
236
+ const buttons = findElementsByTag(tree, 'button');
237
+ assert.equal(buttons[0].props?.defaultPressed, undefined);
238
+ assert.equal(buttons[1].props?.defaultPressed, undefined);
239
+ }
240
+
241
+ console.log('selection-pressed-regression: PASS (6 cases)');