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,541 @@
1
+ /**
2
+ * shadcn adapter — translates shadcn / base-ui runtime-CSS conventions
3
+ * into class-list patches the plugin can see.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * shadcn primitives lean on stylesheet rules that live outside the
8
+ * consumer's JSX — most notably `position: absolute; inset: 0` on
9
+ * `[data-slot="avatar-image"]` so the image overlays the fallback when it
10
+ * loads. The plugin scans className strings, not CSS files, so a
11
+ * `<Avatar><AvatarImage /><AvatarFallback /></Avatar>` tree renders with
12
+ * Image + Fallback as flow siblings, fighting over 40px in a horizontal
13
+ * flex parent, and both collapse to invisible.
14
+ *
15
+ * The adapter recognises a small set of shadcn `data-slot` markers at the
16
+ * IR-transform stage and injects the missing Tailwind classes. The
17
+ * renderer's existing pipeline (absolute positioning, full-width fills,
18
+ * ...) then Just Works — no parallel code path in the renderer.
19
+ *
20
+ * Adding a new entry
21
+ * ------------------
22
+ * If a shadcn primitive renders wrong in Figma AND the fix is a small,
23
+ * static set of Tailwind classes the consumer's JSX would have had
24
+ * if runtime CSS weren't doing the job, add a `SLOT_CLASS_INJECTIONS`
25
+ * entry, a regression case in `shadcn-quirks-regression.ts`, and a note
26
+ * in `.ai/framework-adapters.md`. If the fix needs layout-time decisions
27
+ * (parent shape, sibling count, theme, ...) it belongs in the renderer
28
+ * instead — this file stays focused on "class-list patches the
29
+ * consumer's JSX should have had."
30
+ *
31
+ * Sibling adapters
32
+ * ----------------
33
+ * The `framework-adapters/` folder is the home for any future ports of
34
+ * this idea (MUI, Headless UI, Chakra, ...). Each lives in its own file
35
+ * and is composed via the dispatcher in `index.ts`.
36
+ */
37
+
38
+ // Type-only import: avoid a runtime cycle with `tailwind/node-ir.ts`,
39
+ // which imports this file's `applyShadcnAdapter`.
40
+ import type { NodeIR } from '../tailwind/node-ir';
41
+ import { isClassedElement, mergeMissing, resolveValuePercents } from '../tailwind/adapter-utils';
42
+
43
+ /**
44
+ * `data-slot` → extra Tailwind classes injected before layout solving.
45
+ *
46
+ * - `avatar-image`: base-ui's stylesheet pins it `position: absolute;
47
+ * inset: 0` so the image overlays the fallback when it loads. Without
48
+ * the CSS, both children compete for flow space and collapse. Injecting
49
+ * `absolute inset-0` reproduces the overlay shape via the renderer's
50
+ * existing absolute-positioning pipeline.
51
+ */
52
+ const SLOT_CLASS_INJECTIONS: Record<string, readonly string[]> = {
53
+ 'avatar-image': ['absolute', 'inset-0'],
54
+ };
55
+
56
+ function getDataSlot(node: NodeIR): string | null {
57
+ if (!isClassedElement(node)) return null;
58
+ const raw = node.props ? node.props['data-slot'] : undefined;
59
+ return typeof raw === 'string' && raw.length > 0 ? raw : null;
60
+ }
61
+
62
+ /**
63
+ * Coerce a `kind: 'component'` node to a `kind: 'element'` div, optionally
64
+ * with a fresh class list. Used by the Slider adapter to bypass the
65
+ * state-instance master lookup — Slider's def is `type='state'` (scanner
66
+ * heuristic mis-classifies it because of `data-[disabled]:opacity-50`),
67
+ * so every `<SliderPrimitive.*>` node would otherwise route through
68
+ * `tryRenderSharedSymbolInstance` and get an empty state-instance.
69
+ *
70
+ * Element nodes carry `tagLower` and `tagName`; we keep the original
71
+ * tagName so layer names still read "SliderPrimitive.Thumb" etc., but
72
+ * render via the plain-element path.
73
+ */
74
+ function toElementWithClasses(node: NodeIR, classes: string[]): NodeIR {
75
+ if (!isClassedElement(node)) return node;
76
+ const sameKind = node.kind === 'element';
77
+ const sameClasses = classes === node.classes;
78
+ if (sameKind && sameClasses) return node;
79
+ const patch: Record<string, unknown> = { kind: 'element', classes };
80
+ if (!('tagLower' in node) || !(node as unknown as { tagLower?: string }).tagLower) {
81
+ patch.tagLower = 'div';
82
+ }
83
+ return Object.assign({}, node, patch) as NodeIR;
84
+ }
85
+
86
+ export function getShadcnSlotInjections(slot: string): readonly string[] | null {
87
+ return SLOT_CLASS_INJECTIONS[slot] ?? null;
88
+ }
89
+
90
+ /**
91
+ * Tree transforms applied to an `[data-slot="avatar"]` subtree to mirror
92
+ * shadcn's runtime behaviour:
93
+ *
94
+ * - **Drop the fallback when the image has a real src.** In shadcn,
95
+ * `<AvatarFallback>` only renders if `<AvatarImage>` fails to load.
96
+ * We can't observe load state, but we CAN observe "is there a
97
+ * real-looking src on the sibling image?" at scan time. If yes, the
98
+ * fallback would never visibly render in the browser — drop it so
99
+ * Figma doesn't show a misleading initials disc behind the
100
+ * successfully-rendered image.
101
+ *
102
+ * If the rules grow, split this out into its own helper / module — keep
103
+ * the registry pattern dominant.
104
+ */
105
+ function looksLikeAvatarImageSrc(src: unknown): boolean {
106
+ if (typeof src !== 'string' || src.length === 0) return false;
107
+ return src.startsWith('/')
108
+ || src.startsWith('http://')
109
+ || src.startsWith('https://')
110
+ || src.startsWith('./')
111
+ || src.startsWith('../');
112
+ }
113
+
114
+ function dropFallbackWhenAvatarImageHasSrc(
115
+ parent: Extract<NodeIR, { kind: 'element' | 'component' }>,
116
+ ): NodeIR[] | null {
117
+ if (getDataSlot(parent) !== 'avatar') return null;
118
+ const children = parent.children;
119
+ if (!Array.isArray(children) || children.length === 0) return null;
120
+
121
+ let hasImageWithSrc = false;
122
+ for (const child of children) {
123
+ if (!isClassedElement(child)) continue;
124
+ if (getDataSlot(child) !== 'avatar-image') continue;
125
+ const src = child.props ? child.props.src : undefined;
126
+ if (looksLikeAvatarImageSrc(src)) {
127
+ hasImageWithSrc = true;
128
+ break;
129
+ }
130
+ }
131
+ if (!hasImageWithSrc) return null;
132
+
133
+ let dropped = false;
134
+ const next: NodeIR[] = [];
135
+ for (const child of children) {
136
+ if (isClassedElement(child) && getDataSlot(child) === 'avatar-fallback') {
137
+ dropped = true;
138
+ continue;
139
+ }
140
+ next.push(child);
141
+ }
142
+ return dropped ? next : null;
143
+ }
144
+
145
+ /**
146
+ * Resolve a shadcn Progress.Root's `value` prop to a 0-100 percent. The
147
+ * scanner stringifies every prop, so `<Progress value={60}>` arrives as
148
+ * `props.value === "60"`. `value={null}` (indeterminate) comes through
149
+ * as missing / undefined / "null" — for static-design purposes we
150
+ * surface a recognisably partial bar (33%) so Indeterminate doesn't
151
+ * look identical to Complete.
152
+ *
153
+ * Indeterminate visual choice: shadcn / base-ui animates the indicator
154
+ * sliding across the track. Figma can't animate; 33% is a recognised
155
+ * "in progress, no specific value" representation.
156
+ */
157
+ const PROGRESS_INDETERMINATE_PERCENT = 33;
158
+
159
+ function resolveProgressPercent(value: unknown): number {
160
+ if (typeof value === 'number' && Number.isFinite(value)) {
161
+ return Math.max(0, Math.min(100, value));
162
+ }
163
+ if (typeof value === 'string') {
164
+ const trimmed = value.trim();
165
+ if (trimmed.length === 0 || trimmed === 'null' || trimmed === 'undefined') {
166
+ return PROGRESS_INDETERMINATE_PERCENT;
167
+ }
168
+ const parsed = parseFloat(trimmed);
169
+ if (Number.isFinite(parsed)) {
170
+ return Math.max(0, Math.min(100, parsed));
171
+ }
172
+ }
173
+ return PROGRESS_INDETERMINATE_PERCENT;
174
+ }
175
+
176
+ /**
177
+ * Walk the Progress subtree and inject a width class on the
178
+ * `progress-indicator` descendant so the bar visibly reflects the
179
+ * value prop. shadcn / base-ui sets this via inline `transform:
180
+ * translateX(-N%)` at runtime which the plugin's className-only
181
+ * scanner never sees.
182
+ */
183
+ function applyProgressIndicatorWidth(
184
+ root: Extract<NodeIR, { kind: 'element' | 'component' }>,
185
+ ): NodeIR | null {
186
+ if (getDataSlot(root) !== 'progress') return null;
187
+ const percent = resolveProgressPercent(root.props ? root.props.value : undefined);
188
+ // `w-[N%]` so the universal parser (sizing.ts) sets widthMode=FILL +
189
+ // widthFraction = N/100; the post-append pipeline resizes the
190
+ // indicator to N% of the Track's content width.
191
+ const widthClass = `w-[${percent}%]`;
192
+
193
+ let changed = false;
194
+ const inject = (n: NodeIR): NodeIR => {
195
+ if (!isClassedElement(n)) {
196
+ // Walk through ring / fragment shapes.
197
+ if (n.kind === 'fragment') {
198
+ let any = false;
199
+ const nx: NodeIR[] = [];
200
+ for (const c of n.children) {
201
+ const r = inject(c);
202
+ if (r !== c) any = true;
203
+ nx.push(r);
204
+ }
205
+ return any ? Object.assign({}, n, { children: nx }) : n;
206
+ }
207
+ if (n.kind === 'ring') {
208
+ const nc = inject(n.child);
209
+ return nc === n.child ? n : Object.assign({}, n, { child: nc });
210
+ }
211
+ return n;
212
+ }
213
+ let nextN: NodeIR = n;
214
+ if (getDataSlot(n) === 'progress-indicator') {
215
+ const merged = mergeMissing(n.classes ?? [], [widthClass]);
216
+ if (merged !== n.classes) {
217
+ nextN = Object.assign({}, n, { classes: merged });
218
+ changed = true;
219
+ }
220
+ }
221
+ if (!('children' in nextN) || !Array.isArray((nextN as { children?: unknown }).children)) {
222
+ return nextN;
223
+ }
224
+ let kidsChanged = false;
225
+ const kids = (nextN as { children: NodeIR[] }).children;
226
+ const nextKids: NodeIR[] = [];
227
+ for (const c of kids) {
228
+ const r = inject(c);
229
+ if (r !== c) kidsChanged = true;
230
+ nextKids.push(r);
231
+ }
232
+ return kidsChanged ? Object.assign({}, nextN, { children: nextKids }) : nextN;
233
+ };
234
+
235
+ const out = inject(root);
236
+ return changed ? out : null;
237
+ }
238
+
239
+ /**
240
+ * Walk the Slider subtree and rebuild it so the indicator + thumbs
241
+ * reflect the value prop:
242
+ *
243
+ * - **Indicator** gets `absolute left-[min%] w-[(max-min)%]` so the
244
+ * filled portion of the track matches the value range (for a
245
+ * single-value slider, min=0 and the indicator stretches from the
246
+ * left edge to the thumb position).
247
+ * - **Thumbs**: the scanner emits a SINGLE template even for range
248
+ * sliders (`{values.map(...)}` is not statically expanded). The
249
+ * adapter clones the template once per value and injects
250
+ * `absolute left-[Vi%] -translate-x-1/2` on each clone, centering
251
+ * it on its value point exactly like base-ui's inline styles do
252
+ * at runtime.
253
+ */
254
+ function applySliderPositioning(
255
+ root: Extract<NodeIR, { kind: 'element' | 'component' }>,
256
+ ): NodeIR | null {
257
+ if (getDataSlot(root) !== 'slider') return null;
258
+ const rawValue = root.props && (root.props.defaultValue ?? root.props.value);
259
+ const rawMax = root.props ? root.props.max : undefined;
260
+ // base-ui's Slider is min=0..max only; pass min=0 explicitly.
261
+ const pcts = resolveValuePercents(rawValue, 0, rawMax);
262
+ if (pcts.length === 0) return null;
263
+
264
+ // Figma plugin sandbox doesn't support spread (`...arr`); compute min/max
265
+ // by hand. For a single-value slider, the bar fills from 0 to that value.
266
+ let minPct = pcts[0];
267
+ let maxPct = pcts[0];
268
+ if (pcts.length > 1) {
269
+ for (let i = 1; i < pcts.length; i++) {
270
+ if (pcts[i] < minPct) minPct = pcts[i];
271
+ if (pcts[i] > maxPct) maxPct = pcts[i];
272
+ }
273
+ } else {
274
+ minPct = 0;
275
+ }
276
+ const indicatorWidth = Math.max(0, maxPct - minPct);
277
+
278
+ let touched = false;
279
+
280
+ const transform = (n: NodeIR): NodeIR | NodeIR[] => {
281
+ if (!isClassedElement(n)) {
282
+ if (n.kind === 'fragment') {
283
+ let any = false;
284
+ const out: NodeIR[] = [];
285
+ for (const c of n.children) {
286
+ const r = transform(c);
287
+ if (Array.isArray(r)) {
288
+ any = true;
289
+ for (let i = 0; i < r.length; i++) out.push(r[i]);
290
+ continue;
291
+ }
292
+ if (r !== c) any = true;
293
+ out.push(r);
294
+ }
295
+ return any ? Object.assign({}, n, { children: out }) : n;
296
+ }
297
+ if (n.kind === 'ring') {
298
+ const r = transform(n.child);
299
+ const next = Array.isArray(r) ? r[0] : r;
300
+ return next === n.child ? n : Object.assign({}, n, { child: next });
301
+ }
302
+ return n;
303
+ }
304
+
305
+ const slot = getDataSlot(n);
306
+
307
+ // Indicator: position + width. Also convert kind='component' → 'element'
308
+ // so the renderer doesn't try a state-instance lookup (Slider's def is
309
+ // mis-classified as `state` by the scanner because of `data-[disabled]`
310
+ // — every sub-primitive resolves back to that same def and would try
311
+ // the state-master which can't represent the value-driven geometry).
312
+ if (slot === 'slider-indicator') {
313
+ const extras = [`left-[${minPct}%]`, `w-[${indicatorWidth}%]`];
314
+ const merged = mergeMissing(n.classes ?? [], extras);
315
+ const out = toElementWithClasses(n, merged !== n.classes ? merged : n.classes ?? []);
316
+ if (out !== n) touched = true;
317
+ return out;
318
+ }
319
+
320
+ // Thumb template → one clone per value, each absolutely positioned
321
+ // AND coerced to `kind: 'element'` for the same reason as the
322
+ // indicator above.
323
+ if (slot === 'slider-thumb') {
324
+ const clones: NodeIR[] = [];
325
+ for (const pct of pcts) {
326
+ const extras = ['absolute', `left-[${pct}%]`, '-translate-x-1/2'];
327
+ const cloneClasses = mergeMissing(n.classes ?? [], extras);
328
+ clones.push(toElementWithClasses(n, cloneClasses));
329
+ }
330
+ touched = true;
331
+ return clones;
332
+ }
333
+
334
+ // Non-leaf slider parts (`slider`, `slider-control`, `slider-track`):
335
+ // coerce to element so the wrapper-frame path renders the JSX children
336
+ // instead of going through the state-instance lookup.
337
+ let nextN: NodeIR = n;
338
+ if (slot === 'slider' || slot === 'slider-control' || slot === 'slider-track') {
339
+ const coerced = toElementWithClasses(n, n.classes ?? []);
340
+ if (coerced !== n) {
341
+ nextN = coerced;
342
+ touched = true;
343
+ }
344
+ }
345
+
346
+ // Recurse — and splice in any cloned children returned as arrays.
347
+ if (!('children' in nextN) || !Array.isArray((nextN as { children?: unknown }).children)) {
348
+ return nextN;
349
+ }
350
+ let kidsChanged = false;
351
+ const original = (nextN as { children: NodeIR[] }).children;
352
+ const next: NodeIR[] = [];
353
+ for (const c of original) {
354
+ const r = transform(c);
355
+ if (Array.isArray(r)) {
356
+ if (r.length !== 1 || r[0] !== c) kidsChanged = true;
357
+ // sandbox: no spread — push items one-by-one
358
+ for (let i = 0; i < r.length; i++) next.push(r[i]);
359
+ } else {
360
+ if (r !== c) kidsChanged = true;
361
+ next.push(r);
362
+ }
363
+ }
364
+ return kidsChanged ? Object.assign({}, nextN, { children: next }) : nextN;
365
+ };
366
+
367
+ const out = transform(root);
368
+ const single = Array.isArray(out) ? out[0] : out;
369
+ return touched ? single : null;
370
+ }
371
+
372
+ /**
373
+ * Drop Radix ScrollArea control children AND flatten the Viewport
374
+ * wrapper. Two operations rolled into one pass because they target
375
+ * sibling positions inside the same ScrollAreaPrimitive.Root subtree.
376
+ *
377
+ * 1. `ScrollAreaPrimitive.Scrollbar` / `.Thumb` / `.Corner` are
378
+ * runtime-only behavioural overlays (scrollbar fades in while the
379
+ * user scrolls, Corner only appears when both axes overflow). In a
380
+ * static design-system render they manifest as visible content
381
+ * blobs inside the scroll-area. Strip them.
382
+ *
383
+ * 2. `ScrollAreaPrimitive.Viewport` is a structural wrapper Radix
384
+ * uses for `overflow: hidden` + scroll-position tracking — it
385
+ * carries no visual styling beyond `h-full w-full
386
+ * rounded-[inherit]`. In Figma the default frame fill (white)
387
+ * paints it as a second nested rounded rectangle, producing the
388
+ * "double card / ghost-border" effect the user reported. Flatten
389
+ * by pulling Viewport's children up to be Root's direct children.
390
+ *
391
+ * Returns the rewritten children array when something changed, `null`
392
+ * when the subtree was untouched (so callers can keep object identity
393
+ * stable for caching).
394
+ */
395
+ const RADIX_SCROLL_AREA_RUNTIME_TAGS = new Set([
396
+ 'ScrollAreaPrimitive.Scrollbar',
397
+ 'ScrollAreaPrimitive.Thumb',
398
+ 'ScrollAreaPrimitive.Corner',
399
+ ]);
400
+
401
+ /**
402
+ * JSX-tree tags the shadcn adapter unconditionally drops at render time.
403
+ * Exposed so pre-render scans that walk classes (e.g. responsive-signal
404
+ * detection) can ignore classes that won't actually reach Figma — without
405
+ * this, a `sm:bg-black/60` on `ScrollAreaPrimitive.Scrollbar` falsely
406
+ * trips `treeHasResponsiveClasses` and emits a duplicate-content
407
+ * Responsive preview block even though every breakpoint render is
408
+ * identical post-adapter.
409
+ */
410
+ export function isShadcnAdapterDroppedTag(tagName: string): boolean {
411
+ return RADIX_SCROLL_AREA_RUNTIME_TAGS.has(tagName);
412
+ }
413
+ function flattenScrollAreaSubtree(node: NodeIR): NodeIR[] | null {
414
+ if (!('children' in node) || !Array.isArray(node.children)) return null;
415
+ let changed = false;
416
+ const next: NodeIR[] = [];
417
+ for (const child of node.children) {
418
+ if (!isClassedElement(child)) {
419
+ next.push(child);
420
+ continue;
421
+ }
422
+ const tag = (child as { tagName?: string }).tagName;
423
+ if (typeof tag === 'string' && RADIX_SCROLL_AREA_RUNTIME_TAGS.has(tag)) {
424
+ changed = true;
425
+ continue;
426
+ }
427
+ if (tag === 'ScrollAreaPrimitive.Viewport') {
428
+ const viewportChildren = ('children' in child && Array.isArray(child.children))
429
+ ? child.children
430
+ : [];
431
+ for (const vc of viewportChildren) next.push(vc);
432
+ changed = true;
433
+ continue;
434
+ }
435
+ next.push(child);
436
+ }
437
+ return changed ? next : null;
438
+ }
439
+
440
+ /**
441
+ * Recurse the IR, injecting registry classes for any element whose
442
+ * `data-slot` matches a known shadcn pattern AND applying shadcn-specific
443
+ * tree transforms (e.g. drop AvatarFallback when AvatarImage has a real
444
+ * src). Returns a new tree (input untouched) only when something
445
+ * actually changed; identical-shape input is returned by reference so
446
+ * downstream caches (`NODE_LAYOUT_CACHE`) stay warm.
447
+ */
448
+ export function applyShadcnAdapter(node: NodeIR): NodeIR {
449
+ if (!node || node.kind === 'text' || node.kind === 'divider') return node;
450
+
451
+ if (node.kind === 'fragment') {
452
+ let changed = false;
453
+ const nextChildren: NodeIR[] = [];
454
+ for (const child of node.children) {
455
+ const next = applyShadcnAdapter(child);
456
+ if (next !== child) changed = true;
457
+ nextChildren.push(next);
458
+ }
459
+ return changed ? Object.assign({}, node, { children: nextChildren }) : node;
460
+ }
461
+
462
+ if (node.kind === 'ring') {
463
+ const nextChild = applyShadcnAdapter(node.child);
464
+ return nextChild === node.child ? node : Object.assign({}, node, { child: nextChild });
465
+ }
466
+
467
+ let nextNode: NodeIR = node;
468
+
469
+ const slot = getDataSlot(node);
470
+ if (slot) {
471
+ const extras = SLOT_CLASS_INJECTIONS[slot];
472
+ if (extras && isClassedElement(node)) {
473
+ const merged = mergeMissing(node.classes ?? [], extras);
474
+ if (merged !== node.classes) {
475
+ nextNode = Object.assign({}, node, { classes: merged });
476
+ }
477
+ }
478
+ }
479
+
480
+ if (!('children' in nextNode) || !Array.isArray((nextNode as { children?: unknown }).children)) {
481
+ return nextNode;
482
+ }
483
+
484
+ // Tree transform: shadcn Avatar drops its Fallback when the Image has
485
+ // a real src. Runs before recursion so the fallback subtree isn't
486
+ // walked unnecessarily.
487
+ if (isClassedElement(nextNode)) {
488
+ const filtered = dropFallbackWhenAvatarImageHasSrc(nextNode);
489
+ if (filtered) {
490
+ nextNode = Object.assign({}, nextNode, { children: filtered });
491
+ }
492
+ }
493
+
494
+ // Tree transform: Radix ScrollArea drops its Scrollbar / Thumb /
495
+ // Corner overlays (runtime-only behaviour, not visual content) AND
496
+ // flattens its Viewport wrapper (no visual styling of its own —
497
+ // Figma's default frame fill would render it as a ghost-bordered
498
+ // duplicate of Root).
499
+ if (isClassedElement(nextNode)) {
500
+ const filtered = flattenScrollAreaSubtree(nextNode);
501
+ if (filtered) {
502
+ nextNode = Object.assign({}, nextNode, { children: filtered });
503
+ }
504
+ }
505
+
506
+ // Tree transform: shadcn Progress reads `value` on Root and (at
507
+ // runtime) inline-styles the Indicator's transform/width. Inject a
508
+ // `w-[N%]` class on the descendant indicator so the universal
509
+ // fractional-width pipeline sizes it correctly.
510
+ if (isClassedElement(nextNode)) {
511
+ const withIndicator = applyProgressIndicatorWidth(nextNode);
512
+ if (withIndicator) {
513
+ nextNode = withIndicator;
514
+ }
515
+ }
516
+
517
+ // Tree transform: shadcn Slider positions its indicator + thumbs from
518
+ // the Root's `defaultValue` / `value` prop at runtime. Inject percent
519
+ // positioning so each thumb sits at its value point and the indicator
520
+ // fills the value range. For range sliders the scanner only emits one
521
+ // thumb template — the transform clones it into N siblings.
522
+ if (isClassedElement(nextNode)) {
523
+ const sliderPositioned = applySliderPositioning(nextNode);
524
+ if (sliderPositioned) {
525
+ nextNode = sliderPositioned as typeof nextNode;
526
+ }
527
+ }
528
+
529
+ let childrenChanged = false;
530
+ const original = (nextNode as { children: NodeIR[] }).children;
531
+ const nextChildren: NodeIR[] = [];
532
+ for (const child of original) {
533
+ const next = applyShadcnAdapter(child);
534
+ if (next !== child) childrenChanged = true;
535
+ nextChildren.push(next);
536
+ }
537
+ if (childrenChanged) {
538
+ return Object.assign({}, nextNode, { children: nextChildren });
539
+ }
540
+ return nextNode;
541
+ }
@@ -2,7 +2,7 @@
2
2
  // All GitHub operations are relayed through the UI iframe because the
3
3
  // Figma plugin sandbox has no direct network access.
4
4
 
5
- import { loadConfig, GITHUB_CONFIG } from './config';
5
+ import { loadConfig, GITHUB_CONFIG, waitForUIReady } from '../plugin';
6
6
  import {
7
7
  TOKENS,
8
8
  getVariableTokenDiffWithOptions,
@@ -11,10 +11,9 @@ import {
11
11
  type ThemeTokens,
12
12
  type TokenGroup,
13
13
  type Tokens,
14
- } from './tokens';
15
- import { colorToLabel, debug, parseColor, mergeTokens as deepMergeTokens } from './colors';
16
- import { waitForUIReady } from './dev-server';
17
- import type { ResolvedTokenSourceMode, TokenSourceMode } from './token-source';
14
+ } from '../tokens';
15
+ import { colorToLabel, debug, parseColor, mergeTokens as deepMergeTokens } from '../tokens';
16
+ import type { ResolvedTokenSourceMode, TokenSourceMode } from '../tokens';
18
17
 
19
18
  // ---------------------------------------------------------------------------
20
19
  // Types
@@ -101,7 +100,7 @@ const CSS_DISCOVERY_PATHS = [
101
100
  'styles/globals.css',
102
101
  ];
103
102
  const PATCH_ENDPOINT_PORTS = [4000, 3000, 5173];
104
- const PATCH_ENDPOINT_PATH = '/api/figma/patch-tokens';
103
+ const PATCH_ENDPOINT_PATH = '/api/inkbridge/patch-tokens';
105
104
 
106
105
  // ---------------------------------------------------------------------------
107
106
  // Module-level state for the GitHub fetch relay
@@ -398,16 +397,15 @@ export function applyClassChangesToFile(content: string, componentChange: Compon
398
397
  }
399
398
 
400
399
  function normalizeTokenSourceInfo(raw: unknown): TokenSourceInfo {
401
- const source =
402
- raw && typeof raw === 'object' && typeof (raw as any).source === 'string' && (raw as any).source.trim()
403
- ? (raw as any).source.trim()
404
- : 'embedded:tokens.ts';
405
- const modeRaw = raw && typeof raw === 'object' ? (raw as any).mode : null;
400
+ const obj = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : null;
401
+ const sourceRaw = obj && typeof obj.source === 'string' ? obj.source.trim() : '';
402
+ const source = sourceRaw || 'embedded:tokens.ts';
403
+ const modeRaw = obj ? obj.mode : null;
406
404
  const mode: ResolvedTokenSourceMode =
407
405
  modeRaw === 'css' || modeRaw === 'dtcg' || modeRaw === 'embedded' ? modeRaw : 'embedded';
408
- const requestedRaw = raw && typeof raw === 'object' ? (raw as any).requestedMode : null;
406
+ const requestedRaw = obj ? obj.requestedMode : null;
409
407
  const requestedMode: TokenSourceMode | undefined =
410
- requestedRaw === 'auto' || requestedRaw === 'css' || requestedRaw === 'dtcg' ? requestedRaw : undefined;
408
+ requestedRaw === 'css' || requestedRaw === 'dtcg' ? requestedRaw : undefined;
411
409
  return { source, mode, requestedMode };
412
410
  }
413
411
 
@@ -652,17 +650,40 @@ function getActiveThemeNames(tokens: Tokens): Set<string> {
652
650
  return out;
653
651
  }
654
652
 
653
+ /**
654
+ * Detect whether the CSS file uses class-based theme selectors (.secondary)
655
+ * or data-theme attribute selectors (:root[data-theme="secondary"]).
656
+ * Defaults to 'class' (Tailwind-native) when no existing theme blocks are found.
657
+ */
658
+ function detectThemeConvention(cssText: string): 'class' | 'data-theme' {
659
+ if (/\[data-theme\s*=\s*["']?[^"'\]]+["']?\]/.test(cssText)) return 'data-theme';
660
+ return 'class';
661
+ }
662
+
655
663
  function parseRuleThemeNames(selector: string): string[] {
656
664
  const themes = new Set<string>();
657
665
  const normalized = String(selector || '').trim().toLowerCase();
658
666
  if (!normalized) return [];
659
667
 
668
+ // data-theme attribute approach: :root[data-theme="secondary"]
660
669
  const dataThemeMatches = normalized.matchAll(/\[data-theme\s*=\s*["']?([^"'\\\]]+)["']?\]/g);
661
670
  for (const match of dataThemeMatches) {
662
671
  const name = String(match[1] || '').trim();
663
672
  if (name) themes.add(name);
664
673
  }
665
674
 
675
+ // Class-based approach: .secondary, html.secondary
676
+ // Skip compound selectors that include .dark (e.g. .dark.secondary) — the
677
+ // plugin only writes light-theme tokens, so dark compound blocks are left alone.
678
+ const hasDarkClass = /(?:^|[\s.])dark\b/.test(normalized);
679
+ if (!hasDarkClass) {
680
+ const classMatches = normalized.matchAll(/\.([a-z][a-z0-9-]*)/g);
681
+ for (const match of classMatches) {
682
+ const name = String(match[1] || '').trim();
683
+ if (name && name !== 'dark' && name !== 'root') themes.add(name);
684
+ }
685
+ }
686
+
666
687
  if (/(\.|:)(dark)\b/.test(normalized)) themes.add('dark');
667
688
  if (normalized.includes(':root') && themes.size === 0) themes.add('primary');
668
689
  return Array.from(themes);
@@ -783,10 +804,10 @@ function findFirstBlockForTheme(cssText: string, themeName: string): CssRuleBloc
783
804
  export function patchCssVariables(
784
805
  cssText: string,
785
806
  updatesByTheme: Map<string, Map<string, string>>,
786
- activeThemeNames?: Set<string>
787
- ,
807
+ activeThemeNames?: Set<string>,
788
808
  fullThemeUpdatesByTheme?: Map<string, Map<string, string>>
789
809
  ): string {
810
+ const themeConvention = detectThemeConvention(cssText);
790
811
  let nextCss = cssText;
791
812
  const pending = cloneCssVariableUpdates(updatesByTheme);
792
813
  const fullThemeUpdates = fullThemeUpdatesByTheme
@@ -843,7 +864,11 @@ export function patchCssVariables(
843
864
  continue;
844
865
  }
845
866
 
846
- const selector = themeName === 'primary' ? ':root' : ':root[data-theme="' + themeName + '"]';
867
+ const selector = themeName === 'primary'
868
+ ? ':root'
869
+ : themeConvention === 'class'
870
+ ? '.' + themeName
871
+ : ':root[data-theme="' + themeName + '"]';
847
872
  const completeThemeUpdates =
848
873
  fullThemeUpdates && fullThemeUpdates.get(themeName)
849
874
  ? new Map(fullThemeUpdates.get(themeName) as Map<string, string>)
@@ -919,8 +944,8 @@ function queueFileIfChanged(
919
944
  }
920
945
 
921
946
  function resolveConfiguredMode(mode: unknown): TokenSourceMode {
922
- if (mode === 'css' || mode === 'dtcg' || mode === 'auto') return mode;
923
- return 'auto';
947
+ if (mode === 'dtcg') return 'dtcg';
948
+ return 'css';
924
949
  }
925
950
 
926
951
  async function buildTokenCommitPlan(token: string): Promise<TokenCommitPlan> {
@@ -930,8 +955,8 @@ async function buildTokenCommitPlan(token: string): Promise<TokenCommitPlan> {
930
955
  // This avoids rewriting untouched tokens and keeps push diffs focused.
931
956
  const variableDiffOptions = {
932
957
  allowNewTokensFromFigma: GITHUB_CONFIG!.allowNewTokensFromFigma === true,
933
- newTokenPrefixes: Array.isArray((GITHUB_CONFIG as any).newTokenPrefixes)
934
- ? ((GITHUB_CONFIG as any).newTokenPrefixes as string[])
958
+ newTokenPrefixes: Array.isArray(GITHUB_CONFIG!.newTokenPrefixes)
959
+ ? GITHUB_CONFIG!.newTokenPrefixes
935
960
  : [],
936
961
  };
937
962
  const variableTokenPatch = getVariableTokenDiffWithOptions(variableDiffOptions);
@@ -946,7 +971,7 @@ async function buildTokenCommitPlan(token: string): Promise<TokenCommitPlan> {
946
971
 
947
972
  const preferDtcg =
948
973
  configuredMode === 'dtcg' ||
949
- (configuredMode === 'auto' && sourceInfo.mode === 'dtcg');
974
+ (configuredMode === 'css' && sourceInfo.mode === 'dtcg');
950
975
 
951
976
  if (preferDtcg) {
952
977
  const dtcg = tokensToDTCG(workingTokens);
@@ -0,0 +1 @@
1
+ export * from './github';