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,235 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ (globalThis as unknown as { figma: unknown }).figma = {
4
+ notify: () => undefined,
5
+ showUI: () => undefined,
6
+ createFrame: () => makeStubFrame(),
7
+ };
8
+
9
+ import { LayoutParser, setFrameCrossAlign, setFrameFromBlockFlow, setFrameInlineAlign } from '../src/layout/layout-parser';
10
+
11
+ // The recurring "pill renders full width" bug — see
12
+ // `tools/figma-plugin/.ai/troubleshooting.md`. The root cause is that
13
+ // `LayoutParser.applyChildProperties` runs after the per-class parser
14
+ // (which sets `inline-flex` → HUG) and re-stretches inline-flex children
15
+ // when the parent has cross-align STRETCH (which is the default for any
16
+ // flex parent). Past fixes patched the per-class parser only, so any
17
+ // refactor that didn't carry the exclusion through the implicit-stretch
18
+ // path would silently re-introduce the bug.
19
+ //
20
+ // This fixture asserts the END-TO-END behaviour: after calling
21
+ // `applyChildProperties`, an inline-flex child of a STRETCH-cross-align
22
+ // vertical parent must NOT have `layoutAlign === 'STRETCH'`.
23
+
24
+ type StubFrame = {
25
+ type: 'FRAME';
26
+ layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
27
+ primaryAxisSizingMode: 'AUTO' | 'FIXED';
28
+ counterAxisSizingMode: 'AUTO' | 'FIXED';
29
+ primaryAxisAlignItems: string;
30
+ counterAxisAlignItems: string;
31
+ layoutAlign: string;
32
+ layoutGrow: number;
33
+ layoutSizingHorizontal: 'HUG' | 'FIXED' | 'FILL';
34
+ layoutSizingVertical: 'HUG' | 'FIXED' | 'FILL';
35
+ counterAxisAlignSelf?: string;
36
+ width: number;
37
+ height: number;
38
+ paddingLeft: number;
39
+ paddingRight: number;
40
+ paddingTop: number;
41
+ paddingBottom: number;
42
+ fills: unknown[];
43
+ strokes: unknown[];
44
+ effects: unknown[];
45
+ children: never[];
46
+ appendChild(): void;
47
+ resize(w: number, h: number): void;
48
+ };
49
+
50
+ function makeStubFrame(): StubFrame {
51
+ return {
52
+ type: 'FRAME',
53
+ layoutMode: 'NONE',
54
+ primaryAxisSizingMode: 'AUTO',
55
+ counterAxisSizingMode: 'AUTO',
56
+ primaryAxisAlignItems: 'MIN',
57
+ counterAxisAlignItems: 'MIN',
58
+ layoutAlign: 'INHERIT',
59
+ layoutGrow: 0,
60
+ layoutSizingHorizontal: 'HUG',
61
+ layoutSizingVertical: 'HUG',
62
+ width: 0,
63
+ height: 0,
64
+ paddingLeft: 0,
65
+ paddingRight: 0,
66
+ paddingTop: 0,
67
+ paddingBottom: 0,
68
+ fills: [],
69
+ strokes: [],
70
+ effects: [],
71
+ children: [],
72
+ appendChild() { /* no-op */ },
73
+ resize(w, h) { this.width = w; this.height = h; },
74
+ };
75
+ }
76
+
77
+ function runRegression(): void {
78
+ // The pill / chip / badge bug applies to inline-flex children of a
79
+ // BLOCK-FLOW parent (a regular `<div>` promoted to VERTICAL by the plugin
80
+ // even though CSS-wise it's not a flex container). In that context inline
81
+ // children hug content per CSS spec — the plugin's "implicit stretch"
82
+ // pass would otherwise stretch them to full card width.
83
+ //
84
+ // Real flex parents (`<div class="flex flex-col">`) are different:
85
+ // CSS `align-items: stretch` (the flex default) applies to ALL items
86
+ // including inline-flex, so dialog footer buttons / cancel-save pairs
87
+ // stretch to full width on mobile.
88
+
89
+ // ---- Case 1: inline-flex pill in a BLOCK-FLOW vertical parent —
90
+ // the actual how-it-works / round-trip-section pill bug. Pill must NOT
91
+ // stretch. -------------------------------------------------------------
92
+ const blockFlowCard = makeStubFrame();
93
+ blockFlowCard.layoutMode = 'VERTICAL';
94
+ blockFlowCard.width = 600;
95
+ blockFlowCard.primaryAxisSizingMode = 'FIXED';
96
+ setFrameCrossAlign(blockFlowCard as unknown as FrameNode, 'MIN');
97
+ setFrameFromBlockFlow(blockFlowCard as unknown as FrameNode, true);
98
+
99
+ const pill = makeStubFrame();
100
+ pill.layoutMode = 'HORIZONTAL'; // inline-flex sets HORIZONTAL
101
+ pill.layoutAlign = 'INHERIT';
102
+
103
+ LayoutParser.applyChildProperties(
104
+ pill as unknown as FrameNode,
105
+ 'inline-flex items-center rounded-full px-3 py-1 text-sm font-medium'.split(' '),
106
+ blockFlowCard as unknown as FrameNode,
107
+ );
108
+
109
+ assert.notEqual(
110
+ pill.layoutAlign,
111
+ 'STRETCH',
112
+ 'inline-flex pill in a BLOCK-FLOW MIN parent must NOT be implicitly stretched',
113
+ );
114
+
115
+ // ---- Case 2: inline-block in BLOCK-FLOW parent — same exclusion. ----
116
+ const blockFlowCard2 = makeStubFrame();
117
+ blockFlowCard2.layoutMode = 'VERTICAL';
118
+ blockFlowCard2.width = 600;
119
+ blockFlowCard2.primaryAxisSizingMode = 'FIXED';
120
+ setFrameCrossAlign(blockFlowCard2 as unknown as FrameNode, 'MIN');
121
+ setFrameFromBlockFlow(blockFlowCard2 as unknown as FrameNode, true);
122
+
123
+ const inlineBlock = makeStubFrame();
124
+ inlineBlock.layoutAlign = 'INHERIT';
125
+ LayoutParser.applyChildProperties(
126
+ inlineBlock as unknown as FrameNode,
127
+ 'inline-block px-2 py-1'.split(' '),
128
+ blockFlowCard2 as unknown as FrameNode,
129
+ );
130
+
131
+ assert.notEqual(
132
+ inlineBlock.layoutAlign,
133
+ 'STRETCH',
134
+ 'inline-block child of BLOCK-FLOW parent must also opt out of implicit STRETCH',
135
+ );
136
+
137
+ // ---- Case 3: block-level child of BLOCK-FLOW parent SHOULD stretch
138
+ // (plugin emulates items-stretch for block children even in non-flex
139
+ // parents — preserves the "items fill card" expectation). -------------
140
+ const blockFlowCard3 = makeStubFrame();
141
+ blockFlowCard3.layoutMode = 'VERTICAL';
142
+ blockFlowCard3.width = 600;
143
+ blockFlowCard3.primaryAxisSizingMode = 'FIXED';
144
+ setFrameCrossAlign(blockFlowCard3 as unknown as FrameNode, 'MIN');
145
+ setFrameFromBlockFlow(blockFlowCard3 as unknown as FrameNode, true);
146
+
147
+ const blockChild = makeStubFrame();
148
+ blockChild.layoutMode = 'VERTICAL';
149
+ blockChild.layoutAlign = 'INHERIT';
150
+
151
+ LayoutParser.applyChildProperties(
152
+ blockChild as unknown as FrameNode,
153
+ 'flex flex-col gap-2'.split(' '),
154
+ blockFlowCard3 as unknown as FrameNode,
155
+ );
156
+
157
+ assert.equal(
158
+ blockChild.layoutAlign,
159
+ 'STRETCH',
160
+ 'block-level child SHOULD stretch in BLOCK-FLOW parent (plugin emulates items-stretch)',
161
+ );
162
+
163
+ // ---- Case 4: inline-flex BUTTON in a REAL FLEX parent SHOULD stretch
164
+ // (CSS default `align-items: stretch` applies to inline-flex flex items).
165
+ // The dialog footer at base breakpoint: `<DialogFooter className="flex
166
+ // flex-col-reverse">` with `<Button>` (inline-flex) — buttons must
167
+ // stretch to full footer width on mobile. ------------------------------
168
+ const flexFooter = makeStubFrame();
169
+ flexFooter.layoutMode = 'VERTICAL';
170
+ flexFooter.width = 400;
171
+ flexFooter.primaryAxisSizingMode = 'FIXED';
172
+ setFrameCrossAlign(flexFooter as unknown as FrameNode, 'STRETCH');
173
+ setFrameFromBlockFlow(flexFooter as unknown as FrameNode, false);
174
+
175
+ const button = makeStubFrame();
176
+ button.layoutMode = 'HORIZONTAL';
177
+ button.layoutAlign = 'INHERIT';
178
+
179
+ LayoutParser.applyChildProperties(
180
+ button as unknown as FrameNode,
181
+ 'inline-flex items-center justify-center h-9 px-4 py-2 rounded-md'.split(' '),
182
+ flexFooter as unknown as FrameNode,
183
+ );
184
+
185
+ assert.equal(
186
+ button.layoutAlign,
187
+ 'STRETCH',
188
+ 'inline-flex button in a REAL FLEX-col parent (CSS default items-stretch) SHOULD stretch — dialog footer base breakpoint',
189
+ );
190
+
191
+ // ---- Case 5: inline-flex pill in BLOCK-FLOW MIN parent WITH text-center
192
+ // CSS centers inline content via the parent's text-align — the round-trip
193
+ // section pill `<span class="inline-flex ...">Round-trip in motion</span>`
194
+ // inside `<div class="text-center">` should appear horizontally centered.
195
+ // The pill must NOT stretch (case 1 rule), but its
196
+ // counterAxisAlignSelf should be CENTER so it sits at parent's center.
197
+ const centeredBlockParent = makeStubFrame() as StubFrame & { counterAxisAlignSelf?: string };
198
+ centeredBlockParent.layoutMode = 'VERTICAL';
199
+ centeredBlockParent.width = 600;
200
+ centeredBlockParent.primaryAxisSizingMode = 'FIXED';
201
+ setFrameCrossAlign(centeredBlockParent as unknown as FrameNode, 'MIN');
202
+ setFrameFromBlockFlow(centeredBlockParent as unknown as FrameNode, true);
203
+ setFrameInlineAlign(centeredBlockParent as unknown as FrameNode, 'CENTER');
204
+
205
+ const centeredPill = makeStubFrame() as StubFrame & { counterAxisAlignSelf?: string };
206
+ centeredPill.layoutMode = 'HORIZONTAL';
207
+ centeredPill.layoutAlign = 'INHERIT';
208
+ centeredPill.counterAxisAlignSelf = 'AUTO';
209
+
210
+ LayoutParser.applyChildProperties(
211
+ centeredPill as unknown as FrameNode,
212
+ 'inline-flex items-center rounded-full px-3 py-1 text-sm font-medium'.split(' '),
213
+ centeredBlockParent as unknown as FrameNode,
214
+ );
215
+
216
+ assert.notEqual(
217
+ centeredPill.layoutAlign,
218
+ 'STRETCH',
219
+ 'pill in text-center block-flow parent must still NOT stretch',
220
+ );
221
+ assert.equal(
222
+ centeredPill.counterAxisAlignSelf,
223
+ 'CENTER',
224
+ 'pill in text-center block-flow parent SHOULD be horizontally centered via counterAxisAlignSelf',
225
+ );
226
+ }
227
+
228
+ try {
229
+ runRegression();
230
+ console.log('inline-flex-regression: PASS');
231
+ } catch (err) {
232
+ console.error('inline-flex-regression: FAIL');
233
+ console.error(err);
234
+ process.exit(1);
235
+ }
@@ -0,0 +1,217 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { transformInputNodes } from '../src/tailwind/node-ir';
4
+ import type { NodeIR } from '../src/tailwind/node-ir';
5
+
6
+ /**
7
+ * Regression: `<input type="range">` is rewritten by `transformInputNodes`
8
+ * in `src/tailwind/node-ir.ts` into a synthetic 3-child tree (track +
9
+ * filled indicator + thumb). Locks:
10
+ *
11
+ * 1. The rewrite fires only for `type="range"` (other input types pass
12
+ * through untouched).
13
+ * 2. Geometry is `((value - min) / (max - min)) * 100`.
14
+ * 3. `defaultValue` is honored when `value` is missing.
15
+ * 4. `disabled` adds `opacity-50` to the wrapper.
16
+ * 5. Sizing classes from the consumer (`w-full`, paddings) survive on
17
+ * the wrapper.
18
+ *
19
+ * Why this fixture exists: a previous version of the plugin had no
20
+ * rendering path for `type="range"`. The default input branch in
21
+ * `ui-builder.ts` reads `value/defaultValue/placeholder` as text — for
22
+ * range that meant either a tiny "5" text node or, more often, an
23
+ * infinite hang as downstream layout deferred-pass solvers stalled on a
24
+ * 0-content frame with `w-full accent-primary`. The transform produces
25
+ * a real visual structure the existing pipeline renders cleanly.
26
+ */
27
+
28
+ // Minimal helpers (props are always stringified by the scanner — mirror
29
+ // that here so assertions match real-world IR input).
30
+
31
+ function inputEl(props: Record<string, string>, classes: string[] = []): NodeIR {
32
+ return {
33
+ kind: 'element',
34
+ tagName: 'input',
35
+ tagLower: 'input',
36
+ props,
37
+ classes,
38
+ children: [],
39
+ };
40
+ }
41
+
42
+ function rewrite(node: NodeIR): NodeIR {
43
+ return transformInputNodes(node);
44
+ }
45
+
46
+ function expectElement(node: NodeIR): Extract<NodeIR, { kind: 'element' }> {
47
+ assert.equal(node.kind, 'element', 'expected element node');
48
+ return node as Extract<NodeIR, { kind: 'element' }>;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // (1) Bare `<input type="range">` with value/min/max → track + filled + thumb
53
+ // ---------------------------------------------------------------------------
54
+
55
+ {
56
+ const input = inputEl(
57
+ { type: 'range', min: '0', max: '100', value: '25' },
58
+ ['w-full', 'accent-primary'],
59
+ );
60
+ const out = rewrite(input);
61
+ const wrap = expectElement(out);
62
+
63
+ assert.equal(wrap.tagLower, 'div', 'rewrite produces a div wrapper');
64
+ // Structure mirrors shadcn Slider: track (flow child, contains filled),
65
+ // thumb (absolute sibling). Two top-level children, NOT three.
66
+ assert.equal(wrap.children.length, 2, 'wrapper has track + thumb');
67
+
68
+ // Consumer's sizing classes preserved on the wrapper; layout classes added.
69
+ assert.ok(wrap.classes.includes('w-full'), 'preserves consumer w-full');
70
+ assert.ok(wrap.classes.includes('relative'), 'wrapper is relative');
71
+ assert.ok(wrap.classes.includes('flex'), 'wrapper is flex');
72
+ assert.ok(wrap.classes.includes('items-center'), 'wrapper items-center');
73
+ assert.ok(wrap.classes.includes('h-4'), 'wrapper height set');
74
+
75
+ const [track, thumb] = wrap.children.map(expectElement);
76
+
77
+ // Track is a FLOW child (not absolute) — that's what gives the wrapper
78
+ // content to size against. Without this, all-absolute siblings collapse
79
+ // the auto-layout into a 0-content stall.
80
+ assert.ok(!track.classes.includes('absolute'), 'track is flow, not absolute');
81
+ assert.ok(track.classes.includes('w-full'), 'track full-width');
82
+ assert.ok(track.classes.includes('overflow-hidden'), 'track clips its fill');
83
+ assert.ok(track.classes.includes('bg-secondary'), 'track background neutral');
84
+
85
+ // Filled portion is a flow child of the track (not a sibling of it).
86
+ // No absolute positioning — sits at the left edge by natural flow.
87
+ assert.equal(track.children.length, 1, 'track has one filled child');
88
+ const filled = expectElement(track.children[0]);
89
+ assert.ok(!filled.classes.includes('absolute'), 'filled is flow inside track');
90
+ assert.ok(filled.classes.includes('w-[25%]'), 'filled width matches value');
91
+ assert.ok(filled.classes.includes('bg-primary'), 'filled paints accent');
92
+
93
+ // Thumb: absolute sibling positioned at 25% with -translate-x-1/2.
94
+ // No top-N — wrapper's h-4 + flex items-center centers via static position.
95
+ assert.ok(thumb.classes.includes('absolute'), 'thumb is absolute');
96
+ assert.ok(thumb.classes.includes('left-[25%]'), 'thumb at 25%');
97
+ assert.ok(thumb.classes.includes('-translate-x-1/2'), 'thumb centered on point');
98
+ assert.ok(thumb.classes.includes('rounded-full'), 'thumb circular');
99
+ assert.ok(!thumb.classes.some((c) => c.startsWith('top-')),
100
+ 'thumb has no top-N — flex items-center on wrapper handles vertical centering');
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // (2) Non-zero min — the case from greenhouse-app LeverageSlider
105
+ // ---------------------------------------------------------------------------
106
+
107
+ {
108
+ // ((5 - 1.1) / (100 - 1.1)) * 100 ≈ 3.9434...
109
+ const input = inputEl({ type: 'range', min: '1.1', max: '100', value: '5' });
110
+ const out = rewrite(input);
111
+ const wrap = expectElement(out);
112
+ const track = expectElement(wrap.children[0]);
113
+ const filled = expectElement(track.children[0]);
114
+ const thumb = expectElement(wrap.children[1]);
115
+
116
+ // Match the value the transform actually emits (two-decimal precision,
117
+ // trailing-zero strip). Locking exact percentage prevents silent
118
+ // rounding drift.
119
+ const widthClass = filled.classes.find((c) => c.startsWith('w-['));
120
+ assert.equal(widthClass, 'w-[3.94%]', `got ${widthClass}`);
121
+
122
+ const leftClass = thumb.classes.find((c) => c.startsWith('left-['));
123
+ assert.equal(leftClass, 'left-[3.94%]', `got ${leftClass}`);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // (3) `defaultValue` honored when `value` missing
128
+ // ---------------------------------------------------------------------------
129
+
130
+ {
131
+ const input = inputEl({ type: 'range', min: '0', max: '100', defaultValue: '75' });
132
+ const out = rewrite(input);
133
+ const wrap = expectElement(out);
134
+ const track = expectElement(wrap.children[0]);
135
+ const filled = expectElement(track.children[0]);
136
+
137
+ assert.ok(filled.classes.includes('w-[75%]'), 'defaultValue drives width');
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // (4) `disabled` adds opacity-50 to wrapper
142
+ // ---------------------------------------------------------------------------
143
+
144
+ {
145
+ const input = inputEl({ type: 'range', min: '0', max: '100', value: '50', disabled: 'true' });
146
+ const out = rewrite(input);
147
+ const wrap = expectElement(out);
148
+
149
+ assert.ok(wrap.classes.includes('opacity-50'), 'disabled adds opacity-50');
150
+ }
151
+
152
+ {
153
+ // disabled="false" should NOT trigger (some libs serialize false this way).
154
+ const input = inputEl({ type: 'range', min: '0', max: '100', value: '50', disabled: 'false' });
155
+ const out = rewrite(input);
156
+ const wrap = expectElement(out);
157
+
158
+ assert.ok(!wrap.classes.includes('opacity-50'), 'disabled=false does not dim');
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // (5) Non-range inputs pass through untouched
163
+ // ---------------------------------------------------------------------------
164
+
165
+ {
166
+ const before = inputEl({ type: 'text', placeholder: 'Name' }, ['h-10']);
167
+ const after = rewrite(before);
168
+ // Identity is preserved for non-range inputs (cache-friendly).
169
+ assert.equal(after, before, 'type=text returned by reference');
170
+ }
171
+
172
+ {
173
+ const before = inputEl({ type: 'number', value: '5' }, ['h-10']);
174
+ const after = rewrite(before);
175
+ assert.equal(after, before, 'type=number returned by reference');
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // (6) Missing value defaults to 0%
180
+ // ---------------------------------------------------------------------------
181
+
182
+ {
183
+ const input = inputEl({ type: 'range', min: '0', max: '100' });
184
+ const out = rewrite(input);
185
+ const wrap = expectElement(out);
186
+ const track = expectElement(wrap.children[0]);
187
+ const filled = expectElement(track.children[0]);
188
+
189
+ assert.ok(filled.classes.includes('w-[0%]'), 'missing value → 0% fill');
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // (7) Range inside a larger tree gets rewritten in place
194
+ // ---------------------------------------------------------------------------
195
+
196
+ {
197
+ const tree: NodeIR = {
198
+ kind: 'element',
199
+ tagName: 'div',
200
+ tagLower: 'div',
201
+ props: {},
202
+ classes: ['p-4'],
203
+ children: [
204
+ inputEl({ type: 'range', min: '0', max: '100', value: '40' }, ['w-full']),
205
+ ],
206
+ };
207
+ const out = rewrite(tree);
208
+ const outer = expectElement(out);
209
+ assert.equal(outer.children.length, 1);
210
+ const slider = expectElement(outer.children[0]);
211
+ assert.equal(slider.children.length, 2, 'nested range rewritten (track + thumb)');
212
+ const track = expectElement(slider.children[0]);
213
+ const filled = expectElement(track.children[0]);
214
+ assert.ok(filled.classes.includes('w-[40%]'), 'nested range geometry correct');
215
+ }
216
+
217
+ console.log('input-range-regression: ok');
@@ -0,0 +1,224 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ appendResolvedInstance,
4
+ renderGuardedPortalStoryInstances,
5
+ renderStoryInstances,
6
+ } from '../src/design-system/instance-rendering';
7
+ import type {
8
+ ComponentDef,
9
+ ComponentInstance,
10
+ ComponentStory,
11
+ CvaAnalysis,
12
+ LayoutInfo,
13
+ } from '../src/components';
14
+ import type { StoryBuilderContext } from '../src/design-system/story-builder-context';
15
+
16
+ /**
17
+ * Regression: instance-rendering is the Figma-API-heavy module that composes
18
+ * ComponentInstances into a Story Layout frame. Most of its surface needs a
19
+ * real Figma sandbox to exercise — but the early-return guards (no instances,
20
+ * no def name, missing analysis, unknown analysis type) are pure data and run
21
+ * before any Figma API call. Lock those.
22
+ *
23
+ * The full happy paths are exercised in real Figma during plugin runs.
24
+ *
25
+ * Extracted from `src/design-system/story-builder.ts` into
26
+ * `src/design-system/instance-rendering.ts`.
27
+ */
28
+
29
+ // Minimal LayoutInfo stub — just captures appendChild calls and reports the
30
+ // number of appended children. Used to assert that early-return paths do NOT
31
+ // touch the layout.
32
+ function makeLayoutStub(): { layout: LayoutInfo; appended: unknown[] } {
33
+ const appended: unknown[] = [];
34
+ const layout = {
35
+ appendChild: (node: unknown) => { appended.push(node); },
36
+ children: [],
37
+ } as unknown as LayoutInfo;
38
+ return { layout, appended };
39
+ }
40
+
41
+ function makeCtxStub(): StoryBuilderContext {
42
+ return {
43
+ getComponentDefByName: () => null,
44
+ normalizeComponentDef: (def: ComponentDef) => def,
45
+ } as unknown as StoryBuilderContext;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // appendResolvedInstance — early-return guards
50
+ // ---------------------------------------------------------------------------
51
+
52
+ // null analysis → false, no layout touched
53
+ {
54
+ const { layout, appended } = makeLayoutStub();
55
+ const result = appendResolvedInstance(
56
+ layout,
57
+ null as unknown as CvaAnalysis,
58
+ { componentName: 'X', props: {} } as ComponentInstance,
59
+ { name: 's', instances: [] } as unknown as ComponentStory,
60
+ 'primary',
61
+ {},
62
+ null,
63
+ makeCtxStub(),
64
+ );
65
+ assert.equal(result, false, 'null analysis returns false');
66
+ assert.equal(appended.length, 0, 'null analysis must not touch layout — no appendChild calls');
67
+ }
68
+
69
+ // Unknown analysis.type → false, no layout touched
70
+ {
71
+ const { layout, appended } = makeLayoutStub();
72
+ const result = appendResolvedInstance(
73
+ layout,
74
+ { type: 'mystery-type', name: 'X' } as unknown as CvaAnalysis,
75
+ { componentName: 'X', props: {} } as ComponentInstance,
76
+ { name: 's', instances: [] } as unknown as ComponentStory,
77
+ 'primary',
78
+ {},
79
+ null,
80
+ makeCtxStub(),
81
+ );
82
+ assert.equal(result, false, 'unknown analysis.type returns false');
83
+ assert.equal(appended.length, 0, 'unknown type must not touch layout');
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // renderStoryInstances — empty / missing data
88
+ // ---------------------------------------------------------------------------
89
+
90
+ // story.instances undefined → 0, no layout touched
91
+ {
92
+ const { layout, appended } = makeLayoutStub();
93
+ const added = renderStoryInstances(
94
+ layout,
95
+ { name: 's' } as unknown as ComponentStory,
96
+ 'primary',
97
+ {},
98
+ null,
99
+ makeCtxStub(),
100
+ );
101
+ assert.equal(added, 0, 'no instances → 0 added');
102
+ assert.equal(appended.length, 0, 'no instances must not touch layout');
103
+ }
104
+
105
+ // Empty instances array → 0
106
+ {
107
+ const { layout, appended } = makeLayoutStub();
108
+ const added = renderStoryInstances(
109
+ layout,
110
+ { name: 's', instances: [] } as unknown as ComponentStory,
111
+ 'primary',
112
+ {},
113
+ null,
114
+ makeCtxStub(),
115
+ );
116
+ assert.equal(added, 0, 'empty instances array → 0 added');
117
+ assert.equal(appended.length, 0, 'empty instances array must not touch layout');
118
+ }
119
+
120
+ // Instances with falsy / nameless entries → skipped
121
+ {
122
+ const { layout, appended } = makeLayoutStub();
123
+ const added = renderStoryInstances(
124
+ layout,
125
+ {
126
+ name: 's',
127
+ instances: [null, undefined, { componentName: '' }, { componentName: null }],
128
+ } as unknown as ComponentStory,
129
+ 'primary',
130
+ {},
131
+ null,
132
+ makeCtxStub(),
133
+ );
134
+ assert.equal(added, 0, 'falsy / nameless instance entries are skipped — no fallback frames created');
135
+ assert.equal(appended.length, 0, 'no Figma frame creation triggered');
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // renderGuardedPortalStoryInstances — early-return guards
140
+ // ---------------------------------------------------------------------------
141
+
142
+ // No instances → 0
143
+ {
144
+ const { layout, appended } = makeLayoutStub();
145
+ const added = renderGuardedPortalStoryInstances(
146
+ layout,
147
+ { name: 'Sheet' } as ComponentDef,
148
+ { name: 's' } as unknown as ComponentStory,
149
+ 'primary',
150
+ {},
151
+ null,
152
+ makeCtxStub(),
153
+ );
154
+ assert.equal(added, 0, 'no instances → 0 added');
155
+ assert.equal(appended.length, 0, 'no layout touch');
156
+ }
157
+
158
+ // Empty def.name → 0 (normalizeComponentName returns empty key)
159
+ {
160
+ const { layout, appended } = makeLayoutStub();
161
+ const added = renderGuardedPortalStoryInstances(
162
+ layout,
163
+ { name: '' } as ComponentDef,
164
+ {
165
+ name: 's',
166
+ instances: [{ componentName: 'SheetTrigger', props: {} }],
167
+ } as unknown as ComponentStory,
168
+ 'primary',
169
+ {},
170
+ null,
171
+ makeCtxStub(),
172
+ );
173
+ assert.equal(added, 0, 'empty def name → 0 added (cannot key portal slots)');
174
+ assert.equal(appended.length, 0, 'no layout touch');
175
+ }
176
+
177
+ // Null def → 0
178
+ {
179
+ const { layout, appended } = makeLayoutStub();
180
+ const added = renderGuardedPortalStoryInstances(
181
+ layout,
182
+ null as unknown as ComponentDef,
183
+ {
184
+ name: 's',
185
+ instances: [{ componentName: 'X', props: {} }],
186
+ } as unknown as ComponentStory,
187
+ 'primary',
188
+ {},
189
+ null,
190
+ makeCtxStub(),
191
+ );
192
+ assert.equal(added, 0, 'null def → 0 added defensively');
193
+ assert.equal(appended.length, 0, 'no layout touch');
194
+ }
195
+
196
+ // All instances are the root container only (no trigger/content) → 0
197
+ // (root instance is intentionally skipped — its visual content lives in slots)
198
+ {
199
+ const { layout, appended } = makeLayoutStub();
200
+ const added = renderGuardedPortalStoryInstances(
201
+ layout,
202
+ { name: 'Sheet' } as ComponentDef,
203
+ {
204
+ name: 's',
205
+ instances: [
206
+ { componentName: 'Sheet', props: { open: false } },
207
+ { componentName: 'SheetProvider', props: {} },
208
+ { componentName: 'SheetPositioner', props: {} },
209
+ ],
210
+ } as unknown as ComponentStory,
211
+ 'primary',
212
+ {},
213
+ null,
214
+ makeCtxStub(),
215
+ );
216
+ assert.equal(
217
+ added,
218
+ 0,
219
+ 'root + Provider + Positioner all skip — no trigger or content present',
220
+ );
221
+ assert.equal(appended.length, 0, 'no layout touch');
222
+ }
223
+
224
+ console.log('instance-rendering-regression: PASS');