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,1074 @@
1
+ /**
2
+ * Icon Builder Module
3
+ *
4
+ * Handles icon creation, SVG processing, and icon manipulation for Figma.
5
+ * Extracted from ui-builder.ts for better modularity.
6
+ */
7
+
8
+ import { COMPONENT_DEFS } from '../tokens';
9
+ import type { RGB } from '../tokens/colors';
10
+ import type { NodeIR } from '../tailwind/node-ir';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export const ICON_PATHS: Record<string, { d: string; viewBox: number; stroke: boolean; strokeWidth?: number }> = {
17
+ home: {
18
+ d: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
19
+ viewBox: 24,
20
+ stroke: true,
21
+ strokeWidth: 2
22
+ },
23
+ portfolio: {
24
+ d: "M3 3C2.44772 3 2 3.44772 2 4V20C2 20.5523 2.44772 21 3 21H21C21.5523 21 22 20.5523 22 20V4C22 3.44772 21.5523 3 21 3H3ZM8 5V8H4V5H8ZM4 14V10H8V14H4ZM4 16H8V19H4V16ZM10 16H20V19H10V16ZM20 14H10V10H20V14ZM20 5V8H10V5H20Z",
25
+ viewBox: 24,
26
+ stroke: false
27
+ },
28
+ strategy: {
29
+ d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10Z M16.5 7.5 14 14 7.5 16.5 10 10l6.5-2.5Zm-4.5 5.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
30
+ viewBox: 24,
31
+ stroke: true,
32
+ strokeWidth: 1.8
33
+ },
34
+ pda: {
35
+ d: "M22.0049 7.99979H13.0049C12.4526 7.99979 12.0049 8.4475 12.0049 8.99979V14.9998C12.0049 15.5521 12.4526 15.9998 13.0049 15.9998H22.0049V19.9998C22.0049 20.5521 21.5572 20.9998 21.0049 20.9998H3.00488C2.4526 20.9998 2.00488 20.5521 2.00488 19.9998V3.99979C2.00488 3.4475 2.4526 2.99979 3.00488 2.99979H21.0049C21.5572 2.99979 22.0049 3.4475 22.0049 3.99979V7.99979ZM15.0049 10.9998H18.0049V12.9998H15.0049V10.9998Z",
36
+ viewBox: 24,
37
+ stroke: false
38
+ },
39
+ account: {
40
+ d: "M3 4.99509C3 3.89323 3.89262 3 4.99509 3H19.0049C20.1068 3 21 3.89262 21 4.99509V19.0049C21 20.1068 20.1074 21 19.0049 21H4.99509C3.89323 21 3 20.1074 3 19.0049V4.99509ZM5 5V19H19V5H5ZM7.97216 18.1808C7.35347 17.9129 6.76719 17.5843 6.22083 17.2024C7.46773 15.2753 9.63602 14 12.1022 14C14.5015 14 16.6189 15.2071 17.8801 17.0472C17.3438 17.4436 16.7664 17.7877 16.1555 18.0718C15.2472 16.8166 13.77 16 12.1022 16C10.3865 16 8.87271 16.8641 7.97216 18.1808ZM12 13C10.067 13 8.5 11.433 8.5 9.5C8.5 7.567 10.067 6 12 6C13.933 6 15.5 7.567 15.5 9.5C15.5 11.433 13.933 13 12 13ZM12 11C12.8284 11 13.5 10.3284 13.5 9.5C13.5 8.67157 12.8284 8 12 8C11.1716 8 10.5 8.67157 10.5 9.5C10.5 10.3284 11.1716 11 12 11Z",
41
+ viewBox: 24,
42
+ stroke: false
43
+ },
44
+ logout: {
45
+ d: "M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z",
46
+ viewBox: 20,
47
+ stroke: false
48
+ },
49
+ menu: {
50
+ d: "M4 6h16M4 12h16M4 18h16",
51
+ viewBox: 24,
52
+ stroke: true,
53
+ strokeWidth: 2
54
+ },
55
+ close: {
56
+ d: "M6 18L18 6M6 6l12 12",
57
+ viewBox: 24,
58
+ stroke: true,
59
+ strokeWidth: 2
60
+ },
61
+ 'chevron-down': {
62
+ d: "M6 9l6 6 6-6",
63
+ viewBox: 24,
64
+ stroke: true,
65
+ strokeWidth: 2
66
+ },
67
+ check: {
68
+ d: "M5 13l4 4L19 7",
69
+ viewBox: 24,
70
+ stroke: true,
71
+ strokeWidth: 2
72
+ }
73
+ };
74
+
75
+ const SVG_CHILD_TAGS: Record<string, boolean> = {
76
+ path: true,
77
+ circle: true,
78
+ rect: true,
79
+ line: true,
80
+ polyline: true,
81
+ polygon: true,
82
+ ellipse: true,
83
+ g: true,
84
+ use: true,
85
+ defs: true,
86
+ clippath: true,
87
+ mask: true,
88
+ marker: true,
89
+ };
90
+
91
+ // SVG attribute names that are camelCase in the spec and must NOT be
92
+ // converted to kebab-case when serializing JSX props back to SVG. The default
93
+ // transform (`viewBox` → `view-box`, `markerWidth` → `marker-width`, etc.)
94
+ // produces invalid SVG attributes for these names. Presentation attributes
95
+ // like `strokeWidth` / `strokeDasharray` ARE kebab-case in SVG, so they're
96
+ // not in this set and continue to use the default conversion.
97
+ const SVG_PRESERVE_CASE_ATTRS: Record<string, boolean> = {
98
+ viewBox: true,
99
+ preserveAspectRatio: true,
100
+ refX: true,
101
+ refY: true,
102
+ markerWidth: true,
103
+ markerHeight: true,
104
+ markerUnits: true,
105
+ gradientUnits: true,
106
+ gradientTransform: true,
107
+ patternUnits: true,
108
+ patternTransform: true,
109
+ patternContentUnits: true,
110
+ };
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Helper Functions
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Convert RGB to hex string
118
+ */
119
+ export function rgbToHex(color: RGB): string {
120
+ const r = Math.round(Math.max(0, Math.min(1, color.r)) * 255);
121
+ const g = Math.round(Math.max(0, Math.min(1, color.g)) * 255);
122
+ const b = Math.round(Math.max(0, Math.min(1, color.b)) * 255);
123
+ return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
124
+ }
125
+
126
+ function escapeSvgAttr(value: string): string {
127
+ return String(value || '')
128
+ .replace(/&/g, '&amp;')
129
+ .replace(/"/g, '&quot;')
130
+ .replace(/'/g, '&#39;')
131
+ .replace(/</g, '&lt;')
132
+ .replace(/>/g, '&gt;');
133
+ }
134
+
135
+ function extractSvgAttribute(svg: string, attr: string): string | null {
136
+ const re = new RegExp('<svg[^>]*\\s' + attr + '=["\\\']([^"\\\']+)["\\\']', 'i');
137
+ const match = svg.match(re);
138
+ return match ? match[1] : null;
139
+ }
140
+
141
+ function hasMonochromeBlackPaint(svg: string): boolean {
142
+ const strokeMatch = /\sstroke=["']([^"']+)["']/i.exec(svg);
143
+ const fillMatch = /\sfill=["']([^"']+)["']/i.exec(svg);
144
+ const isBlack = (v: string | undefined): boolean => {
145
+ if (!v) return false;
146
+ const n = v.trim().toLowerCase();
147
+ return n === '#000' || n === '#000000' || n === 'black' || n === 'rgb(0,0,0)' || n === 'rgb(0, 0, 0)';
148
+ };
149
+ return isBlack(strokeMatch?.[1]) || isBlack(fillMatch?.[1]);
150
+ }
151
+
152
+ function normalizeSvgPaintValue(value: string | null): string | null {
153
+ if (!value) return null;
154
+ const lower = value.trim().toLowerCase();
155
+ if (lower === 'none') return null;
156
+ return value;
157
+ }
158
+
159
+ /**
160
+ * Flatten `<g>`'s stroke / fill / stroke-* attributes onto child shape
161
+ * elements that don't already set their own. Mirrors CSS inheritance.
162
+ *
163
+ * Why: Figma's `createNodeFromSvg` does NOT honor `<g>` attribute
164
+ * inheritance — a `<path>` inside `<g stroke="#X">` gets neither the
165
+ * stroke nor a fill, then the fallback paint logic in
166
+ * `createIconFromSvg` fills it with the icon color. The arrow-arc
167
+ * pattern (`<g stroke="currentColor"><path d="M..A.." stroke-dasharray="6 6"
168
+ * marker-end="..." /></g>`) was the canonical break: paths rendered as
169
+ * filled green wedges instead of dashed stroked arcs.
170
+ *
171
+ * Implementation: iteratively replace innermost `<g>` blocks (those
172
+ * containing no other `<g>`) with the same `<g>` but with inheritable
173
+ * attrs inlined onto child `<path>` / `<line>` / `<polyline>` / etc.
174
+ * After each pass the next-innermost `<g>` becomes innermost; repeat
175
+ * until stable.
176
+ */
177
+ const SHAPE_TAGS_FOR_INHERIT = ['path', 'line', 'polyline', 'polygon', 'circle', 'rect', 'ellipse'];
178
+ const SHAPE_TAGS_PATTERN = '(' + SHAPE_TAGS_FOR_INHERIT.join('|') + ')';
179
+ const INHERITABLE_ATTRS = [
180
+ 'stroke',
181
+ 'stroke-width',
182
+ 'stroke-linecap',
183
+ 'stroke-linejoin',
184
+ 'stroke-dasharray',
185
+ 'fill',
186
+ // Opacity / fill-opacity / stroke-opacity are inherited per SVG spec
187
+ // and common in icon libraries that fade `<g>`-grouped paths
188
+ // (e.g. duotone Lucide variants, Heroicons solid/outline pairs).
189
+ 'opacity',
190
+ 'fill-opacity',
191
+ 'stroke-opacity',
192
+ ];
193
+
194
+ /**
195
+ * Inline inheritable attrs from the root `<svg>` element onto each shape
196
+ * child that doesn't already declare them. Run AFTER
197
+ * `flattenGroupInheritance` so any `<g>` overrides on the way down win
198
+ * first; only shapes still missing an attr fall back to the root.
199
+ *
200
+ * Why: many icon libraries set stroke / fill / stroke-width at the SVG
201
+ * root level (e.g. `<svg stroke="currentColor" fill="none"
202
+ * stroke-width="2">`). Figma's `createNodeFromSvg` doesn't propagate
203
+ * these to children — without this pass, every icon with that pattern
204
+ * loses its stroke styling.
205
+ */
206
+ export function flattenSvgRootInheritance(svg: string): string {
207
+ const rootMatch = svg.match(/<svg\b([^>]*)>/i);
208
+ if (!rootMatch) return svg;
209
+ const rootAttrs = rootMatch[1];
210
+ const inherits: string[] = [];
211
+ for (const attr of INHERITABLE_ATTRS) {
212
+ const re = new RegExp('\\s' + attr + '=["\']([^"\']+)["\']', 'i');
213
+ const m = rootAttrs.match(re);
214
+ if (m) inherits.push(attr + '="' + m[1] + '"');
215
+ }
216
+ if (inherits.length === 0) return svg;
217
+ const shapeRe = new RegExp('<' + SHAPE_TAGS_PATTERN + '\\b([^>]*?)(/?)>', 'gi');
218
+ return svg.replace(shapeRe, function(shapeMatch, tagName, shapeAttrs, slash) {
219
+ const additions = inherits.filter(function(p) {
220
+ const name = p.split('=')[0];
221
+ return !new RegExp('\\s' + name + '=', 'i').test(shapeAttrs);
222
+ });
223
+ if (additions.length === 0) return shapeMatch;
224
+ return '<' + tagName + shapeAttrs + ' ' + additions.join(' ') + slash + '>';
225
+ });
226
+ }
227
+
228
+ export function flattenGroupInheritance(svg: string): string {
229
+ let prev: string;
230
+ let out = svg;
231
+ let safety = 32;
232
+ do {
233
+ prev = out;
234
+ // Match innermost <g> (the one with no nested <g> inside). Inline its
235
+ // inheritable attrs onto child shape elements that don't already declare
236
+ // their own, then STRIP those attrs from the <g> itself so it's no
237
+ // longer "innermost-with-inheritable-attrs" on the next pass. The next
238
+ // pass picks up the now-leaf outer <g>.
239
+ out = out.replace(
240
+ /<g\b([^>]*)>((?:(?!<g\b)[\s\S])*?)<\/g>/gi,
241
+ function(match, gAttrsRaw, gContent) {
242
+ let gAttrs = gAttrsRaw as string;
243
+ const inherits: string[] = [];
244
+ let strippedGAttrs = gAttrs;
245
+ for (const attr of INHERITABLE_ATTRS) {
246
+ const re = new RegExp('\\s' + attr + '=["\']([^"\']+)["\']', 'i');
247
+ const m = gAttrs.match(re);
248
+ if (m) {
249
+ inherits.push(attr + '="' + m[1] + '"');
250
+ strippedGAttrs = strippedGAttrs.replace(re, '');
251
+ }
252
+ }
253
+ if (inherits.length === 0) return match;
254
+ const shapeRe = new RegExp('<' + SHAPE_TAGS_PATTERN + '\\b([^>]*?)(/?)>', 'gi');
255
+ const inlined = (gContent as string).replace(shapeRe, function(shapeMatch, tagName, shapeAttrs, slash) {
256
+ // Filter out attrs the shape already declares — child's own value wins.
257
+ const additions = inherits.filter(function(p) {
258
+ const name = p.split('=')[0];
259
+ return !new RegExp('\\s' + name + '=', 'i').test(shapeAttrs);
260
+ });
261
+ if (additions.length === 0) return shapeMatch;
262
+ return '<' + tagName + shapeAttrs + ' ' + additions.join(' ') + slash + '>';
263
+ });
264
+ // If the <g> now has no remaining attrs, unwrap it so the outer <g>
265
+ // can be treated as innermost on the next pass.
266
+ if (strippedGAttrs.trim() === '') {
267
+ return inlined;
268
+ }
269
+ return '<g' + strippedGAttrs + '>' + inlined + '</g>';
270
+ }
271
+ );
272
+ safety--;
273
+ } while (out !== prev && safety > 0);
274
+ return out;
275
+ }
276
+
277
+ /**
278
+ * Inline SVG `<marker>` references — Figma's `createNodeFromSvg` does NOT
279
+ * render `marker-end` / `marker-start` / `marker-mid`, so arrowheads on
280
+ * paths silently disappear (canonical case: the round-trip arc arrows).
281
+ *
282
+ * For each `<path d="…" marker-end="url(#id)">` we:
283
+ * 1. Parse the `d` attribute to find the LAST command's endpoint and the
284
+ * tangent direction at that endpoint.
285
+ * 2. Look up the `<marker>` definition by id.
286
+ * 3. Inject a `<g transform="translate(x y) rotate(θ) scale(sx sy)
287
+ * translate(-refX -refY)">…</g>` containing the marker's inner SVG,
288
+ * so it renders at the endpoint, rotated along the tangent.
289
+ *
290
+ * Currently handles M / L / A commands in `d` (covers the round-trip arc
291
+ * pattern). Circular arcs only (rx === ry, no x-axis rotation). Other path
292
+ * commands cause the marker to be skipped — better than an incorrect
293
+ * arrowhead orientation.
294
+ */
295
+ type MarkerDef = {
296
+ viewBoxW: number;
297
+ viewBoxH: number;
298
+ refX: number;
299
+ refY: number;
300
+ markerWidth: number;
301
+ markerHeight: number;
302
+ markerUnits: 'strokeWidth' | 'userSpaceOnUse';
303
+ content: string;
304
+ };
305
+
306
+ function parseMarkerAttrs(svg: string): Map<string, MarkerDef> {
307
+ const result = new Map<string, MarkerDef>();
308
+ const re = /<marker\b([^>]*)>([\s\S]*?)<\/marker>/gi;
309
+ let m: RegExpExecArray | null;
310
+ while ((m = re.exec(svg)) !== null) {
311
+ const attrs = m[1];
312
+ const inner = m[2].trim();
313
+ const idMatch = attrs.match(/\sid=["']([^"']+)["']/i);
314
+ if (!idMatch) continue;
315
+ const vbMatch = attrs.match(/\sviewBox=["']([^"']+)["']/i);
316
+ const vb = vbMatch ? vbMatch[1].trim().split(/[\s,]+/).map(parseFloat) : [0, 0, 10, 10];
317
+ const vbW = (vb.length >= 4 && Number.isFinite(vb[2])) ? vb[2] : 10;
318
+ const vbH = (vb.length >= 4 && Number.isFinite(vb[3])) ? vb[3] : 10;
319
+ const refXMatch = attrs.match(/\srefX=["']([^"']+)["']/i);
320
+ const refYMatch = attrs.match(/\srefY=["']([^"']+)["']/i);
321
+ const mwMatch = attrs.match(/\smarkerWidth=["']([^"']+)["']/i);
322
+ const mhMatch = attrs.match(/\smarkerHeight=["']([^"']+)["']/i);
323
+ const muMatch = attrs.match(/\smarkerUnits=["']([^"']+)["']/i);
324
+ // SVG default for `markerUnits` is `strokeWidth` — marker dimensions
325
+ // are scaled by the host path's stroke width. Without honouring this,
326
+ // markers render at literal markerWidth/markerHeight in user units,
327
+ // which is much smaller than the browser shows on a stroked path.
328
+ const markerUnits: 'strokeWidth' | 'userSpaceOnUse' =
329
+ muMatch && muMatch[1].toLowerCase() === 'userspaceonuse'
330
+ ? 'userSpaceOnUse'
331
+ : 'strokeWidth';
332
+ result.set(idMatch[1], {
333
+ viewBoxW: vbW,
334
+ viewBoxH: vbH,
335
+ refX: refXMatch ? parseFloat(refXMatch[1]) : 0,
336
+ refY: refYMatch ? parseFloat(refYMatch[1]) : 0,
337
+ markerWidth: mwMatch ? parseFloat(mwMatch[1]) : 3,
338
+ markerHeight: mhMatch ? parseFloat(mhMatch[1]) : 3,
339
+ markerUnits,
340
+ content: inner,
341
+ });
342
+ }
343
+ return result;
344
+ }
345
+
346
+ function parseMarkerRef(attr: string | undefined): string | null {
347
+ if (!attr) return null;
348
+ const m = attr.match(/url\(#([^)]+)\)/);
349
+ return m ? m[1] : null;
350
+ }
351
+
352
+ type PathSegment = {
353
+ cmd: string;
354
+ start: [number, number];
355
+ end: [number, number];
356
+ /** Last control point relative to the endpoint — direction (end - lastCtrl) is the tangent. */
357
+ lastCtrl?: [number, number];
358
+ arc?: number[];
359
+ };
360
+
361
+ function computePathEndpointAndTangent(d: string): { x: number; y: number; angleDeg: number } | null {
362
+ // Tokenise: capture command letters and signed numbers (incl. decimals).
363
+ const tokens = d.match(/[MLAHVCSQTZmlahvcsqtz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g);
364
+ if (!tokens) return null;
365
+ let i = 0;
366
+ let cx = 0, cy = 0;
367
+ let startX = 0, startY = 0;
368
+ // Track previous quadratic control for T-command smoothing (reflection
369
+ // across the current point). Cubic S-command reflects the FIRST control
370
+ // from the previous segment — irrelevant for endpoint tangent (which
371
+ // depends only on the SECOND control), so not tracked.
372
+ let prevQuadraticCtrl: [number, number] | null = null;
373
+ let last: PathSegment | null = null;
374
+ while (i < tokens.length) {
375
+ const t = tokens[i];
376
+ if (!/^[a-zA-Z]$/.test(t)) { i++; continue; }
377
+ const cmd = t;
378
+ const upper = cmd.toUpperCase();
379
+ const isRel = cmd !== upper;
380
+ i++;
381
+ if (upper === 'M' || upper === 'L') {
382
+ const x = parseFloat(tokens[i]);
383
+ const y = parseFloat(tokens[i + 1]);
384
+ i += 2;
385
+ const ex = isRel ? cx + x : x;
386
+ const ey = isRel ? cy + y : y;
387
+ last = { cmd: upper, start: [cx, cy], end: [ex, ey] };
388
+ cx = ex; cy = ey;
389
+ if (upper === 'M') { startX = ex; startY = ey; }
390
+ prevQuadraticCtrl = null;
391
+ } else if (upper === 'H') {
392
+ const x = parseFloat(tokens[i]);
393
+ i += 1;
394
+ const ex = isRel ? cx + x : x;
395
+ last = { cmd: 'L', start: [cx, cy], end: [ex, cy] };
396
+ cx = ex;
397
+ prevQuadraticCtrl = null;
398
+ } else if (upper === 'V') {
399
+ const y = parseFloat(tokens[i]);
400
+ i += 1;
401
+ const ey = isRel ? cy + y : y;
402
+ last = { cmd: 'L', start: [cx, cy], end: [cx, ey] };
403
+ cy = ey;
404
+ prevQuadraticCtrl = null;
405
+ } else if (upper === 'C') {
406
+ const x1 = parseFloat(tokens[i]);
407
+ const y1 = parseFloat(tokens[i + 1]);
408
+ const x2 = parseFloat(tokens[i + 2]);
409
+ const y2 = parseFloat(tokens[i + 3]);
410
+ const x = parseFloat(tokens[i + 4]);
411
+ const y = parseFloat(tokens[i + 5]);
412
+ i += 6;
413
+ const c1: [number, number] = [isRel ? cx + x1 : x1, isRel ? cy + y1 : y1];
414
+ const c2: [number, number] = [isRel ? cx + x2 : x2, isRel ? cy + y2 : y2];
415
+ const ex = isRel ? cx + x : x;
416
+ const ey = isRel ? cy + y : y;
417
+ last = { cmd: 'C', start: [cx, cy], end: [ex, ey], lastCtrl: c2 };
418
+ cx = ex; cy = ey;
419
+ // c1 is unused for tangent computation but tracked for completeness.
420
+ void c1;
421
+ prevQuadraticCtrl = null;
422
+ } else if (upper === 'S') {
423
+ const x2 = parseFloat(tokens[i]);
424
+ const y2 = parseFloat(tokens[i + 1]);
425
+ const x = parseFloat(tokens[i + 2]);
426
+ const y = parseFloat(tokens[i + 3]);
427
+ i += 4;
428
+ const c2: [number, number] = [isRel ? cx + x2 : x2, isRel ? cy + y2 : y2];
429
+ const ex = isRel ? cx + x : x;
430
+ const ey = isRel ? cy + y : y;
431
+ // Tangent at endpoint = (end - c2). Reflection of prev cubic ctrl
432
+ // would be the FIRST control for this segment, irrelevant for end
433
+ // tangent.
434
+ last = { cmd: 'S', start: [cx, cy], end: [ex, ey], lastCtrl: c2 };
435
+ cx = ex; cy = ey;
436
+ prevQuadraticCtrl = null;
437
+ } else if (upper === 'Q') {
438
+ const x1 = parseFloat(tokens[i]);
439
+ const y1 = parseFloat(tokens[i + 1]);
440
+ const x = parseFloat(tokens[i + 2]);
441
+ const y = parseFloat(tokens[i + 3]);
442
+ i += 4;
443
+ const c1: [number, number] = [isRel ? cx + x1 : x1, isRel ? cy + y1 : y1];
444
+ const ex = isRel ? cx + x : x;
445
+ const ey = isRel ? cy + y : y;
446
+ last = { cmd: 'Q', start: [cx, cy], end: [ex, ey], lastCtrl: c1 };
447
+ cx = ex; cy = ey;
448
+ prevQuadraticCtrl = c1;
449
+ } else if (upper === 'T') {
450
+ const x = parseFloat(tokens[i]);
451
+ const y = parseFloat(tokens[i + 1]);
452
+ i += 2;
453
+ // T's control point is the reflection of the previous quadratic
454
+ // control across the current point.
455
+ const c1: [number, number] = prevQuadraticCtrl
456
+ ? [2 * cx - prevQuadraticCtrl[0], 2 * cy - prevQuadraticCtrl[1]]
457
+ : [cx, cy];
458
+ const ex = isRel ? cx + x : x;
459
+ const ey = isRel ? cy + y : y;
460
+ last = { cmd: 'T', start: [cx, cy], end: [ex, ey], lastCtrl: c1 };
461
+ cx = ex; cy = ey;
462
+ prevQuadraticCtrl = c1;
463
+ } else if (upper === 'A') {
464
+ const rx = parseFloat(tokens[i]);
465
+ const ry = parseFloat(tokens[i + 1]);
466
+ const rot = parseFloat(tokens[i + 2]);
467
+ const lar = parseFloat(tokens[i + 3]);
468
+ const swp = parseFloat(tokens[i + 4]);
469
+ const x = parseFloat(tokens[i + 5]);
470
+ const y = parseFloat(tokens[i + 6]);
471
+ i += 7;
472
+ const ex = isRel ? cx + x : x;
473
+ const ey = isRel ? cy + y : y;
474
+ last = { cmd: 'A', start: [cx, cy], end: [ex, ey], arc: [rx, ry, rot, lar, swp] };
475
+ cx = ex; cy = ey;
476
+ prevQuadraticCtrl = null;
477
+ } else if (upper === 'Z') {
478
+ // Close path: line back to subpath start.
479
+ last = { cmd: 'L', start: [cx, cy], end: [startX, startY] };
480
+ cx = startX; cy = startY;
481
+ prevQuadraticCtrl = null;
482
+ } else {
483
+ // Unsupported command — abandon marker placement.
484
+ return null;
485
+ }
486
+ }
487
+ if (!last) return null;
488
+ let angle: number;
489
+ if (last.cmd === 'M' || last.cmd === 'L') {
490
+ const dx = last.end[0] - last.start[0];
491
+ const dy = last.end[1] - last.start[1];
492
+ if (dx === 0 && dy === 0) return null;
493
+ angle = Math.atan2(dy, dx);
494
+ } else if ((last.cmd === 'C' || last.cmd === 'S' || last.cmd === 'Q' || last.cmd === 'T') && last.lastCtrl) {
495
+ // For bezier curves the tangent at the endpoint is the direction from
496
+ // the last control point TO the endpoint.
497
+ const dx = last.end[0] - last.lastCtrl[0];
498
+ const dy = last.end[1] - last.lastCtrl[1];
499
+ if (dx === 0 && dy === 0) return null;
500
+ angle = Math.atan2(dy, dx);
501
+ } else if (last.cmd === 'A' && last.arc) {
502
+ const [rx, ry, rot, lar, swp] = last.arc;
503
+ if (Math.abs(rx - ry) > 0.001 || Math.abs(rot) > 0.001) return null;
504
+ const r = rx;
505
+ const [x1, y1] = last.start;
506
+ const [x2, y2] = last.end;
507
+ const dx = x2 - x1;
508
+ const dy = y2 - y1;
509
+ const chord = Math.sqrt(dx * dx + dy * dy);
510
+ if (chord === 0 || chord > 2 * r + 0.001) return null;
511
+ const halfChord = chord / 2;
512
+ const h = Math.sqrt(Math.max(0, r * r - halfChord * halfChord));
513
+ // Perpendicular to chord (rotate (dx,dy) by 90° CCW in SVG y-down).
514
+ const px = -dy / chord;
515
+ const py = dx / chord;
516
+ // Center selection: lar XOR swp picks the opposite side.
517
+ const sign = (lar !== swp) ? 1 : -1;
518
+ const mx = (x1 + x2) / 2;
519
+ const my = (y1 + y2) / 2;
520
+ const centerX = mx + sign * h * px;
521
+ const centerY = my + sign * h * py;
522
+ const rdx = x2 - centerX;
523
+ const rdy = y2 - centerY;
524
+ // Tangent at endpoint = velocity along the path. For an arc
525
+ // parametrised as (cx + r*cos θ, cy + r*sin θ), velocity is
526
+ // (-r*sin θ, r*cos θ) = (-rdy, rdx) at the endpoint (since
527
+ // rdx = r*cos θ, rdy = r*sin θ). Sweep=1 means θ INCREASES along the
528
+ // path → that's the forward direction. Sweep=0 reverses it.
529
+ const tdx = (swp === 1) ? -rdy : rdy;
530
+ const tdy = (swp === 1) ? rdx : -rdx;
531
+ angle = Math.atan2(tdy, tdx);
532
+ } else {
533
+ return null;
534
+ }
535
+ return { x: last.end[0], y: last.end[1], angleDeg: angle * 180 / Math.PI };
536
+ }
537
+
538
+ function buildInlineMarkerGroup(
539
+ marker: MarkerDef,
540
+ x: number,
541
+ y: number,
542
+ angleDeg: number,
543
+ hostStrokeWidth: number
544
+ ): string {
545
+ // With `markerUnits="strokeWidth"` (the SVG default), the marker's box
546
+ // is multiplied by the host path's stroke width. With "userSpaceOnUse"
547
+ // markerWidth/markerHeight are taken as literal user-unit dimensions.
548
+ const unitsMultiplier = marker.markerUnits === 'strokeWidth' ? hostStrokeWidth : 1;
549
+ const sx = (marker.markerWidth * unitsMultiplier) / marker.viewBoxW;
550
+ const sy = (marker.markerHeight * unitsMultiplier) / marker.viewBoxH;
551
+ // Transform applies right-to-left in SVG; this matches the marker
552
+ // positioning spec: translate refX/refY of the (scaled, rotated) marker
553
+ // viewBox to (x, y) on the host path.
554
+ const t = `translate(${x} ${y}) rotate(${angleDeg}) scale(${sx} ${sy}) translate(${-marker.refX} ${-marker.refY})`;
555
+ return `<g transform="${t}">${marker.content}</g>`;
556
+ }
557
+
558
+ export function inlineSvgMarkers(svg: string): string {
559
+ const markers = parseMarkerAttrs(svg);
560
+ if (markers.size === 0) return svg;
561
+ const additions: string[] = [];
562
+ const pathRe = /<path\b([^>]*?)\/?>/gi;
563
+ let m: RegExpExecArray | null;
564
+ while ((m = pathRe.exec(svg)) !== null) {
565
+ const attrs = m[1];
566
+ const dMatch = attrs.match(/\sd=["']([^"']+)["']/i);
567
+ if (!dMatch) continue;
568
+ const markerEndMatch = attrs.match(/\smarker-end=["']([^"']+)["']/i);
569
+ const markerStartMatch = attrs.match(/\smarker-start=["']([^"']+)["']/i);
570
+ if (markerEndMatch) {
571
+ const id = parseMarkerRef(markerEndMatch[1]);
572
+ if (id && markers.has(id)) {
573
+ const pt = computePathEndpointAndTangent(dMatch[1]);
574
+ if (pt) {
575
+ // Extract host path's stroke-width — `flattenGroupInheritance`
576
+ // (run earlier in the pipeline) already inlined `<g>`'s
577
+ // stroke-width onto each child path, so reading the path's own
578
+ // attribute covers the inherited case too. Default to 1 per SVG
579
+ // spec when not set.
580
+ const swMatch = attrs.match(/\sstroke-width=["']([^"']+)["']/i);
581
+ const hostStrokeWidth = swMatch ? parseFloat(swMatch[1]) : 1;
582
+ additions.push(buildInlineMarkerGroup(
583
+ markers.get(id)!,
584
+ pt.x,
585
+ pt.y,
586
+ pt.angleDeg,
587
+ Number.isFinite(hostStrokeWidth) ? hostStrokeWidth : 1
588
+ ));
589
+ }
590
+ }
591
+ }
592
+ // marker-start handling could be added by reversing the path or
593
+ // tracking the first move/line tangent — not needed for round-trip's
594
+ // current arrow design (only marker-end is used).
595
+ void markerStartMatch;
596
+ }
597
+ if (additions.length === 0) return svg;
598
+ // Insert additions just before </svg> so they render on top (Figma
599
+ // honours document order for paint stacking).
600
+ return svg.replace(/<\/svg>/i, additions.join('') + '</svg>');
601
+ }
602
+
603
+ /**
604
+ * Preprocess SVG for Figma compatibility
605
+ * - Replaces "currentColor" with the actual hex color (Figma doesn't understand CSS currentColor)
606
+ * - Flattens `<g>` inheritance onto child shape elements (Figma's SVG
607
+ * importer doesn't honor it)
608
+ * - Inlines `<marker>` references as transformed geometry at path
609
+ * endpoints (Figma's SVG importer doesn't render markers)
610
+ */
611
+ function preprocessSvgForFigma(svg: string, color?: RGB): string {
612
+ const hexColor = color ? rgbToHex(color) : '#000000';
613
+ let output = svg.replace(/currentColor/gi, hexColor);
614
+ output = output.replace(/\scolor=["'][^"']*["']/gi, '');
615
+ output = output.replace(/\sstyle=["']([^"']*)["']/gi, function(_match, rawStyles) {
616
+ const cleaned = String(rawStyles || '')
617
+ .split(';')
618
+ .map(s => s.trim())
619
+ .filter(Boolean)
620
+ .filter(s => s.toLowerCase().indexOf('color:') !== 0)
621
+ .join('; ');
622
+ if (!cleaned) return '';
623
+ return ' style="' + cleaned + '"';
624
+ });
625
+ output = flattenGroupInheritance(output);
626
+ output = flattenSvgRootInheritance(output);
627
+ output = inlineSvgMarkers(output);
628
+ const rootFill = normalizeSvgPaintValue(extractSvgAttribute(output, 'fill'));
629
+ const rootStroke = normalizeSvgPaintValue(extractSvgAttribute(output, 'stroke'));
630
+
631
+ output = output.replace(/<path\b([^>]*)>/gi, function(_match, rawAttrs) {
632
+ let attrs = rawAttrs || '';
633
+ let suffix = '';
634
+ if (attrs.trim().endsWith('/')) {
635
+ attrs = attrs.replace(/\s*\/$/, '');
636
+ suffix = ' /';
637
+ }
638
+
639
+ const hasFill = /\sfill=/.test(attrs);
640
+ const hasStroke = /\sstroke=/.test(attrs);
641
+
642
+ if (!hasStroke && rootStroke) {
643
+ attrs += ' stroke="' + rootStroke + '"';
644
+ }
645
+ if (!hasFill) {
646
+ if (!hasStroke && rootFill) {
647
+ attrs += ' fill="' + rootFill + '"';
648
+ } else if (!hasStroke && !rootFill && !rootStroke) {
649
+ attrs += ' fill="' + hexColor + '"';
650
+ }
651
+ }
652
+
653
+ return '<path' + attrs + suffix + '>';
654
+ });
655
+
656
+ return output;
657
+ }
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // SVG Node Manipulation
661
+ // ---------------------------------------------------------------------------
662
+
663
+ function collectVectors(node: SceneNode): VectorNode[] {
664
+ if (node.type === 'VECTOR') return [node];
665
+ if ('findAll' in node) {
666
+ return node.findAll((child) => child.type === 'VECTOR') as VectorNode[];
667
+ }
668
+ return [];
669
+ }
670
+
671
+ function getVectorPaintUsage(node: SceneNode): { hasFills: boolean; hasStrokes: boolean } {
672
+ let hasFills = false;
673
+ let hasStrokes = false;
674
+ for (const vector of collectVectors(node)) {
675
+ if (Array.isArray(vector.fills) && vector.fills.length > 0) hasFills = true;
676
+ if (Array.isArray(vector.strokes) && vector.strokes.length > 0) hasStrokes = true;
677
+ }
678
+ return { hasFills, hasStrokes };
679
+ }
680
+
681
+ function recolorIconPaint(node: SceneNode, color: RGB): void {
682
+ const paint: SolidPaint = { type: 'SOLID', color: { r: color.r, g: color.g, b: color.b } };
683
+ for (const vector of collectVectors(node)) {
684
+ if (Array.isArray(vector.fills) && vector.fills.length > 0) {
685
+ vector.fills = [paint];
686
+ }
687
+ if (Array.isArray(vector.strokes) && vector.strokes.length > 0) {
688
+ vector.strokes = [paint];
689
+ }
690
+ }
691
+ }
692
+
693
+ function applyIconPaint(
694
+ node: SceneNode,
695
+ color: RGB,
696
+ options: { stroke?: boolean; strokeWidth?: number } = {}
697
+ ): void {
698
+ for (const vector of collectVectors(node)) {
699
+ const paint: SolidPaint = { type: 'SOLID', color: { r: color.r, g: color.g, b: color.b } };
700
+ if (options.stroke) {
701
+ vector.strokes = [paint];
702
+ vector.strokeWeight = options.strokeWidth || vector.strokeWeight || 1.5;
703
+ vector.fills = [];
704
+ } else {
705
+ vector.fills = [paint];
706
+ vector.strokes = [];
707
+ }
708
+ }
709
+ }
710
+
711
+ export function normalizeIconNode(node: SceneNode): void {
712
+ if (!node) return;
713
+ if ('clipsContent' in node) node.clipsContent = false;
714
+ if ('layoutGrow' in node) node.layoutGrow = 0;
715
+ for (const vector of collectVectors(node)) {
716
+ if ('strokeAlign' in vector) {
717
+ try {
718
+ vector.strokeAlign = 'CENTER';
719
+ } catch (_err) {
720
+ // ignore
721
+ }
722
+ }
723
+ }
724
+ }
725
+
726
+ export function flattenSvgNode(node: SceneNode): VectorNode | null {
727
+ if (!node || !('children' in node)) return null;
728
+ const children = node.children;
729
+ if (!children.length) return null;
730
+ try {
731
+ const flattened = figma.flatten(Array.from(children));
732
+ flattened.name = node.name;
733
+ normalizeIconNode(flattened);
734
+ try {
735
+ const parent = node.parent;
736
+ if (parent && 'appendChild' in parent) {
737
+ parent.appendChild(flattened);
738
+ }
739
+ node.remove();
740
+ } catch (_err) {
741
+ // ignore
742
+ }
743
+ return flattened;
744
+ } catch (_err) {
745
+ return null;
746
+ }
747
+ }
748
+
749
+ function resizeNode(node: SceneNode, width: number, height: number): void {
750
+ if ('resizeWithoutConstraints' in node) node.resizeWithoutConstraints(width, height);
751
+ else if ('resize' in node) node.resize(width, height);
752
+ }
753
+
754
+ export function resizeSvgNodeTo(node: SceneNode, width: number, height: number): void {
755
+ if (!node || width <= 0 || height <= 0) return;
756
+ if (node.type === 'VECTOR') {
757
+ resizeNode(node, width, height);
758
+ return;
759
+ }
760
+ const vectors = collectVectors(node);
761
+ const baseWidth = ('width' in node ? node.width : 0) || 0;
762
+ const baseHeight = ('height' in node ? node.height : 0) || 0;
763
+ if (!vectors.length || baseWidth <= 0 || baseHeight <= 0) {
764
+ resizeNode(node, width, height);
765
+ return;
766
+ }
767
+ const sx = width / baseWidth;
768
+ const sy = height / baseHeight;
769
+ for (const vector of vectors) {
770
+ resizeNode(vector, vector.width * sx, vector.height * sy);
771
+ if (typeof vector.x === 'number') vector.x = vector.x * sx;
772
+ if (typeof vector.y === 'number') vector.y = vector.y * sy;
773
+ }
774
+ resizeNode(node, width, height);
775
+ }
776
+
777
+ export function wrapIconNode(
778
+ icon: SceneNode,
779
+ width: number,
780
+ height: number,
781
+ pad: number,
782
+ name: string
783
+ ): FrameNode {
784
+ const wrapper = figma.createFrame();
785
+ wrapper.name = name;
786
+ wrapper.layoutMode = 'NONE';
787
+ wrapper.fills = [];
788
+ wrapper.strokes = [];
789
+ wrapper.resize(width, height);
790
+ wrapper.clipsContent = false;
791
+ if ('layoutGrow' in wrapper) wrapper.layoutGrow = 0;
792
+
793
+ const innerWidth = Math.max(1, width - pad * 2);
794
+ const innerHeight = Math.max(1, height - pad * 2);
795
+ if (innerWidth !== width || innerHeight !== height) {
796
+ resizeSvgNodeTo(icon, innerWidth, innerHeight);
797
+ }
798
+ normalizeIconNode(icon);
799
+ if ('x' in icon && 'width' in icon) icon.x = Math.round((width - icon.width) / 2);
800
+ if ('y' in icon && 'height' in icon) icon.y = Math.round((height - icon.height) / 2);
801
+ wrapper.appendChild(icon);
802
+ return wrapper;
803
+ }
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // Icon Creation Functions
807
+ // ---------------------------------------------------------------------------
808
+
809
+ /**
810
+ * Create an icon from the built-in ICON_PATHS
811
+ */
812
+ export function createIcon(name: string, color: RGB): FrameNode | null {
813
+ const spec = ICON_PATHS[name];
814
+ if (!spec) return null;
815
+ const hexColor = color && typeof color.r === 'number' ? rgbToHex(color) : '#000000';
816
+ const strokeAttrs = spec.stroke
817
+ ? `fill="none" stroke="${hexColor}" stroke-width="${spec.strokeWidth || 1.5}" stroke-linecap="round" stroke-linejoin="round"`
818
+ : `fill="${hexColor}"`;
819
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${spec.viewBox} ${spec.viewBox}"><path d="${spec.d}" ${strokeAttrs} /></svg>`;
820
+ const node = figma.createNodeFromSvg(svg);
821
+ node.name = `icon/${name}`;
822
+ normalizeIconNode(node);
823
+ // Keep the imported SVG frame so its original viewBox canvas is preserved.
824
+ // Flattening collapses to tight path bounds, which distorts icon proportions
825
+ // for sparse glyphs (for example chevrons) when resized to Tailwind sizes.
826
+ const target = node;
827
+ const usage = getVectorPaintUsage(target);
828
+ if (!usage.hasFills && !usage.hasStrokes) {
829
+ applyIconPaint(target, color, { stroke: spec.stroke, strokeWidth: spec.strokeWidth || 1.5 });
830
+ }
831
+ normalizeIconNode(target);
832
+ return target;
833
+ }
834
+
835
+ /**
836
+ * Create an icon from an SVG string
837
+ */
838
+ export function createIconFromSvg(svg: string, color: RGB): FrameNode | null {
839
+ try {
840
+ // Registry SVGs from the scanner have `stroke="#000"` (or fill) baked in
841
+ // rather than `currentColor` — lucide-react sets currentColor at runtime,
842
+ // but renderToStaticMarkup resolves it to the default black. Treat those
843
+ // monochrome defaults as recolor candidates so icons pick up the
844
+ // caller's color like they would in the browser.
845
+ const usesCurrentColor = /currentcolor/i.test(svg) || hasMonochromeBlackPaint(svg);
846
+ const processedSvg = preprocessSvgForFigma(svg, color);
847
+ const node = figma.createNodeFromSvg(processedSvg);
848
+ node.name = 'icon/svg';
849
+ normalizeIconNode(node);
850
+
851
+ const vectors = collectVectors(node);
852
+ if (vectors.length === 0) {
853
+ node.remove();
854
+ return null;
855
+ }
856
+
857
+ // Keep the imported SVG frame so resize operations honor the source
858
+ // viewBox instead of scaling from tight vector bounds.
859
+ const target = node;
860
+
861
+ let finalUsage = getVectorPaintUsage(target);
862
+ if (usesCurrentColor && (finalUsage.hasFills || finalUsage.hasStrokes)) {
863
+ recolorIconPaint(target, color);
864
+ finalUsage = getVectorPaintUsage(target);
865
+ }
866
+ if (!finalUsage.hasFills && !finalUsage.hasStrokes) {
867
+ const svgHasStroke = /stroke=/.test(processedSvg) && !/stroke=["']none["']/i.test(processedSvg);
868
+ const svgHasFill = /fill=/.test(processedSvg) && !/fill=["']none["']/i.test(processedSvg);
869
+ applyIconPaint(target, color, { stroke: svgHasStroke && !svgHasFill });
870
+ finalUsage = getVectorPaintUsage(target);
871
+ if (!finalUsage.hasFills && !finalUsage.hasStrokes) {
872
+ target.remove();
873
+ return null;
874
+ }
875
+ }
876
+
877
+ normalizeIconNode(target);
878
+ return target;
879
+ } catch (_err) {
880
+ return null;
881
+ }
882
+ }
883
+
884
+ // ---------------------------------------------------------------------------
885
+ // NodeIR to SVG Conversion
886
+ // ---------------------------------------------------------------------------
887
+
888
+ function nodeIrToSvgChild(node: NodeIR): string | null {
889
+ if (node.kind !== 'element') return null;
890
+
891
+ const tag = node.tagLower || '';
892
+ const props = node.props || {};
893
+
894
+ if (!SVG_CHILD_TAGS[tag]) {
895
+ const inner = (node.children || [])
896
+ .map(function(child) { return nodeIrToSvgChild(child); })
897
+ .filter(Boolean)
898
+ .join('');
899
+ return inner || null;
900
+ }
901
+
902
+ const attrs: string[] = [];
903
+ for (const key in props) {
904
+ if (props[key] == null) continue;
905
+ if (key === 'className') continue;
906
+ const attrKey = SVG_PRESERVE_CASE_ATTRS[key]
907
+ ? key
908
+ : key.replace(/[A-Z]/g, function(m) { return '-' + m.toLowerCase(); });
909
+ attrs.push(`${attrKey}="${escapeSvgAttr(props[key])}"`);
910
+ }
911
+
912
+ const inner = (node.children || [])
913
+ .map(function(child) { return nodeIrToSvgChild(child); })
914
+ .filter(Boolean)
915
+ .join('');
916
+ if (inner) {
917
+ return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`;
918
+ }
919
+ return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''} />`;
920
+ }
921
+
922
+ export function nodeIrToSvg(node: NodeIR): string | null {
923
+ if (node.kind !== 'element' || node.tagLower !== 'svg') return null;
924
+
925
+ const attrs: string[] = [];
926
+ const props = node.props || {};
927
+
928
+ if (props.viewBox) attrs.push(`viewBox="${escapeSvgAttr(props.viewBox)}"`);
929
+ if (props.preserveAspectRatio) attrs.push(`preserveAspectRatio="${escapeSvgAttr(props.preserveAspectRatio)}"`);
930
+ if (props.width) attrs.push(`width="${escapeSvgAttr(props.width)}"`);
931
+ if (props.height) attrs.push(`height="${escapeSvgAttr(props.height)}"`);
932
+ if (props.fill) attrs.push(`fill="${escapeSvgAttr(props.fill)}"`);
933
+ if (props.stroke) attrs.push(`stroke="${escapeSvgAttr(props.stroke)}"`);
934
+ if (props['stroke-width']) attrs.push(`stroke-width="${escapeSvgAttr(props['stroke-width'])}"`);
935
+ if (props.strokeWidth) attrs.push(`stroke-width="${escapeSvgAttr(props.strokeWidth)}"`);
936
+ if (props['stroke-linecap']) attrs.push(`stroke-linecap="${escapeSvgAttr(props['stroke-linecap'])}"`);
937
+ if (props.strokeLinecap) attrs.push(`stroke-linecap="${escapeSvgAttr(props.strokeLinecap)}"`);
938
+ if (props['stroke-linejoin']) attrs.push(`stroke-linejoin="${escapeSvgAttr(props['stroke-linejoin'])}"`);
939
+ if (props.strokeLinejoin) attrs.push(`stroke-linejoin="${escapeSvgAttr(props.strokeLinejoin)}"`);
940
+
941
+ attrs.unshift(`xmlns="http://www.w3.org/2000/svg"`);
942
+
943
+ const children = (node.children || [])
944
+ .map(function(child) { return nodeIrToSvgChild(child); })
945
+ .filter(Boolean)
946
+ .join('');
947
+
948
+ const hasViewBox = !!props.viewBox;
949
+ const safeAttrs = hasViewBox ? attrs : attrs.concat(['viewBox="0 0 24 24"']);
950
+
951
+ return `<svg ${safeAttrs.join(' ')}>${children}</svg>`;
952
+ }
953
+
954
+ // ---------------------------------------------------------------------------
955
+ // Size Resolution
956
+ // ---------------------------------------------------------------------------
957
+
958
+ function resolveSpacingToken(token: string): number | null {
959
+ const spacingScale = COMPONENT_DEFS && COMPONENT_DEFS.spacingScale ? COMPONENT_DEFS.spacingScale : {};
960
+ if (spacingScale && spacingScale[token] != null) return spacingScale[token];
961
+ const numeric = parseFloat(token);
962
+ if (!Number.isNaN(numeric)) return numeric * 4;
963
+ return null;
964
+ }
965
+
966
+ // Strips variant prefixes and returns the inner class. Different from
967
+ // `tailwind/class-utils.ts#getBaseClass`, which rejects variant-prefixed
968
+ // classes outright. Icon sizing applies even to hover/responsive variants.
969
+ function stripVariantPrefix(cls: string): string | null {
970
+ if (!cls) return null;
971
+ const parts = cls.split(':');
972
+ return parts[parts.length - 1] || null;
973
+ }
974
+
975
+ export function resolveIconSizeFromClasses(classes: string[], props: Record<string, string>): { width: number; height: number } {
976
+ let width: number | null = null;
977
+ let height: number | null = null;
978
+
979
+ const parsePx = (value: string): number | null => {
980
+ const trimmed = value.trim();
981
+ // Percentage values (`width="100%"`) are NOT pixel sizes — they signal
982
+ // fill-parent intent (browser semantics). Returning `parseFloat` on
983
+ // them would yield `100`, mis-sizing e.g. `<svg width="100%" height="100%">`
984
+ // (the Bridge SVG) as a 100×100 icon. Skip; the caller falls back to
985
+ // the icon default + the layout pipeline's fill-parent handling
986
+ // (`applySvgLayoutMarksFromClasses` flags the wrap for resize).
987
+ if (/%\s*$/.test(trimmed)) return null;
988
+ const n = parseFloat(trimmed);
989
+ if (Number.isNaN(n)) return null;
990
+ return n;
991
+ };
992
+
993
+ if (props.size) {
994
+ const sizeValue = parsePx(props.size);
995
+ if (sizeValue != null) {
996
+ width = sizeValue;
997
+ height = sizeValue;
998
+ }
999
+ }
1000
+
1001
+ if (props.width) {
1002
+ const w = parsePx(props.width);
1003
+ if (w != null) width = w;
1004
+ }
1005
+ if (props.height) {
1006
+ const h = parsePx(props.height);
1007
+ if (h != null) height = h;
1008
+ }
1009
+
1010
+ // Don't `break` on the first match — responsive variants ship MULTIPLE
1011
+ // size-N classes in the resolved list (`size-10 size-14 size-16` after
1012
+ // `resolveClassesForBreakpoint` keeps base + sm + lg at lg breakpoint).
1013
+ // Tailwind / CSS semantics: last declaration wins. Picking the first
1014
+ // (smallest) made every responsive view of an icon render at the base
1015
+ // size.
1016
+ for (const cls of classes) {
1017
+ const base = stripVariantPrefix(cls);
1018
+ if (!base) continue;
1019
+ const sizeBracket = base.match(/^size-\[(\d+(?:\.\d+)?)px\]$/);
1020
+ if (sizeBracket) {
1021
+ const size = parseFloat(sizeBracket[1]);
1022
+ width = size;
1023
+ height = size;
1024
+ continue;
1025
+ }
1026
+ const sizeToken = base.match(/^size-(\d+(?:\.\d+)?)$/);
1027
+ if (sizeToken) {
1028
+ const size = resolveSpacingToken(sizeToken[1]);
1029
+ if (size != null) {
1030
+ width = size;
1031
+ height = size;
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ for (const cls of classes) {
1037
+ const base = stripVariantPrefix(cls);
1038
+ if (!base) continue;
1039
+ const wBracket = base.match(/^w-\[(\d+(?:\.\d+)?)px\]$/);
1040
+ if (wBracket) {
1041
+ width = parseFloat(wBracket[1]);
1042
+ continue;
1043
+ }
1044
+ const hBracket = base.match(/^h-\[(\d+(?:\.\d+)?)px\]$/);
1045
+ if (hBracket) {
1046
+ height = parseFloat(hBracket[1]);
1047
+ continue;
1048
+ }
1049
+ const wToken = base.match(/^w-(\d+(?:\.\d+)?)$/);
1050
+ if (wToken) {
1051
+ const w = resolveSpacingToken(wToken[1]);
1052
+ if (w != null) width = w;
1053
+ }
1054
+ const hToken = base.match(/^h-(\d+(?:\.\d+)?)$/);
1055
+ if (hToken) {
1056
+ const h = resolveSpacingToken(hToken[1]);
1057
+ if (h != null) height = h;
1058
+ }
1059
+ }
1060
+
1061
+ if (width == null && height == null) {
1062
+ width = 24;
1063
+ height = 24;
1064
+ } else if (width == null && height != null) {
1065
+ width = height;
1066
+ } else if (height == null && width != null) {
1067
+ height = width;
1068
+ }
1069
+
1070
+ return { width: width as number, height: height as number };
1071
+ }
1072
+
1073
+ // Re-export for convenience
1074
+ export { getVectorPaintUsage };