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,98 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ (globalThis as unknown as { figma: unknown }).figma = {
4
+ notify: () => undefined,
5
+ showUI: () => undefined,
6
+ };
7
+
8
+ import {
9
+ applyAspectRatioIfPossible,
10
+ applyFullWidthIfPossible,
11
+ hasAspectRatio,
12
+ markAspectRatio,
13
+ markFullWidthNode,
14
+ } from '../src/layout/deferred-layout';
15
+
16
+ // Stubs sized to expose the bits the resolvers inspect.
17
+ type StubFrame = {
18
+ type: 'FRAME';
19
+ name?: string;
20
+ width: number;
21
+ height: number;
22
+ paddingLeft: number;
23
+ paddingRight: number;
24
+ paddingTop: number;
25
+ paddingBottom: number;
26
+ layoutMode: 'NONE' | 'VERTICAL' | 'HORIZONTAL';
27
+ primaryAxisSizingMode: 'AUTO' | 'FIXED';
28
+ counterAxisSizingMode: 'AUTO' | 'FIXED';
29
+ primaryAxisAlignItems?: string;
30
+ layoutAlign?: string;
31
+ layoutGrow?: number;
32
+ resize(w: number, h: number): void;
33
+ };
34
+
35
+ function frame(width: number, height: number, layoutMode: 'NONE' | 'VERTICAL' | 'HORIZONTAL' = 'NONE'): StubFrame {
36
+ return {
37
+ type: 'FRAME',
38
+ name: '',
39
+ width,
40
+ height,
41
+ paddingLeft: 0,
42
+ paddingRight: 0,
43
+ paddingTop: 0,
44
+ paddingBottom: 0,
45
+ layoutMode,
46
+ primaryAxisSizingMode: 'AUTO',
47
+ counterAxisSizingMode: 'AUTO',
48
+ resize(w, h) {
49
+ this.width = w;
50
+ this.height = h;
51
+ },
52
+ };
53
+ }
54
+
55
+ function runRegression(): void {
56
+ // ---- Inline SVG marked w-full + h-full inside a layoutMode=NONE parent --
57
+ // Mirrors the round-trip section's arrows SVG: `<svg className="absolute
58
+ // inset-0 h-full w-full">` rendered into a wrapper that defaults to 24×24
59
+ // until layout marks resize it.
60
+ const parent = frame(720, 432, 'NONE');
61
+ const svgWrap = frame(24, 24);
62
+ markFullWidthNode(svgWrap as unknown as SceneNode);
63
+ // markFullHeightNode is private to deferred-layout, but ui-builder exports
64
+ // it for plugin internals; here we simulate by setting the underlying mark
65
+ // through an alternative entry. Calling applyFullWidthIfPossible directly
66
+ // requires both marks already set — verify via the public path:
67
+ // After the SVG handler propagates marks, `applyFullWidthIfPossible` should
68
+ // resize the wrapped svg to (parent.width, parent.height).
69
+ // For test purposes we simulate this by calling applyFullWidthIfPossible
70
+ // with a parent that has finite dims.
71
+ applyFullWidthIfPossible(svgWrap as unknown as SceneNode, parent as unknown as FrameNode);
72
+ assert.equal(svgWrap.width, 720, 'w-full on NONE parent → resize to parent.width');
73
+ assert.equal(
74
+ svgWrap.primaryAxisSizingMode === 'FIXED' || svgWrap.counterAxisSizingMode === 'FIXED',
75
+ true,
76
+ 'sizing mode locked'
77
+ );
78
+
79
+ // ---- aspect-ratio applied to the wrapped svg ----------------------------
80
+ const svgAspect = frame(0, 0);
81
+ markFullWidthNode(svgAspect as unknown as SceneNode);
82
+ markAspectRatio(svgAspect as unknown as SceneNode, 5 / 3); // aspect-5/3
83
+ // Resolve width first.
84
+ applyFullWidthIfPossible(svgAspect as unknown as SceneNode, frame(720, 999, 'NONE') as unknown as FrameNode);
85
+ applyAspectRatioIfPossible(svgAspect as unknown as SceneNode);
86
+ assert.equal(svgAspect.width, 720, 'aspect svg width = parent width');
87
+ assert.equal(svgAspect.height, 432, 'aspect-5/3 height = 720 * 3/5 = 432');
88
+ assert.equal(hasAspectRatio(svgAspect as unknown as SceneNode), false, 'aspect mark consumed');
89
+ }
90
+
91
+ try {
92
+ runRegression();
93
+ console.log('svg-fill-parent-regression: PASS');
94
+ } catch (err) {
95
+ console.error('svg-fill-parent-regression: FAIL');
96
+ console.error(err);
97
+ process.exit(1);
98
+ }
@@ -0,0 +1,166 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ (globalThis as unknown as { figma: unknown }).figma = {
4
+ notify: () => undefined,
5
+ showUI: () => undefined,
6
+ };
7
+
8
+ import { flattenGroupInheritance, flattenSvgRootInheritance } from '../src/effects/icon-builder';
9
+
10
+ /**
11
+ * Regression: Figma's `createNodeFromSvg` does NOT honor SVG `<g>` attribute
12
+ * inheritance. A `<path>` inside `<g stroke="…">` gets neither the stroke
13
+ * nor a fill, and the icon-builder's paint-fallback then fills it with the
14
+ * icon color — turning the round-trip arrow arcs into filled green wedges
15
+ * instead of dashed stroked arcs with arrowheads.
16
+ *
17
+ * The fix in `preprocessSvgForFigma` is to flatten `<g>` stroke / fill /
18
+ * stroke-* attributes onto child shape elements before sending to Figma.
19
+ * This fixture pins the flattening helper itself.
20
+ */
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Case 1 — the round-trip arrows pattern: `<g stroke="X" stroke-width="2"
24
+ // stroke-linecap="round"><path d="…" stroke-dasharray="6 6" marker-end="…"
25
+ // /></g>`. Each path must inherit stroke, stroke-width, stroke-linecap.
26
+ // ---------------------------------------------------------------------------
27
+ {
28
+ const input = `<svg viewBox="0 0 600 360" fill="none">
29
+ <g stroke="#0a0" stroke-width="2" stroke-linecap="round">
30
+ <path d="M 112 50 A 195 195 0 0 1 488 50" stroke-dasharray="6 6" marker-end="url(#a)" />
31
+ <path d="M 482 170 A 195 195 0 0 1 360 285" stroke-dasharray="6 6" />
32
+ </g>
33
+ </svg>`;
34
+ const output = flattenGroupInheritance(input);
35
+ // Each path should now carry stroke, stroke-width, stroke-linecap inline.
36
+ const pathMatches = output.match(/<path[^>]*>/g) || [];
37
+ assert.equal(pathMatches.length, 2, 'two paths in fixture');
38
+ for (const p of pathMatches) {
39
+ assert.ok(/\sstroke="#0a0"/.test(p), 'path inherits stroke from <g>: ' + p);
40
+ assert.ok(/\sstroke-width="2"/.test(p), 'path inherits stroke-width from <g>: ' + p);
41
+ assert.ok(/\sstroke-linecap="round"/.test(p), 'path inherits stroke-linecap from <g>: ' + p);
42
+ }
43
+ // Path's own stroke-dasharray and marker-end must be preserved.
44
+ assert.ok(/stroke-dasharray="6 6"/.test(output), 'path own stroke-dasharray preserved');
45
+ assert.ok(/marker-end="url\(#a\)"/.test(output), 'path own marker-end preserved');
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Case 2 — child stroke wins over `<g>` stroke (CSS inheritance: own
50
+ // declaration beats inherited).
51
+ // ---------------------------------------------------------------------------
52
+ {
53
+ const input = `<g stroke="#aaa" stroke-width="2">
54
+ <path d="M 0 0 L 10 10" stroke="#000" />
55
+ </g>`;
56
+ const output = flattenGroupInheritance(input);
57
+ const pathMatch = output.match(/<path[^>]*>/);
58
+ assert.ok(pathMatch, 'path captured');
59
+ // Child's own stroke="#000" must NOT be replaced by parent's "#aaa".
60
+ assert.ok(/\sstroke="#000"/.test(pathMatch![0]), 'child own stroke wins: ' + pathMatch![0]);
61
+ assert.ok(!/\sstroke="#aaa"/.test(pathMatch![0]), 'parent stroke not added when child has own: ' + pathMatch![0]);
62
+ // But stroke-width (which child doesn't have) IS inherited.
63
+ assert.ok(/\sstroke-width="2"/.test(pathMatch![0]), 'sibling attr stroke-width still inherited: ' + pathMatch![0]);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Case 3 — nested `<g>` propagates through both levels. The inner-most
68
+ // pass runs first; the outer pass then propagates to anything not covered.
69
+ // ---------------------------------------------------------------------------
70
+ {
71
+ const input = `<g fill="#red">
72
+ <g stroke="#0a0">
73
+ <path d="M 0 0 L 10 10" />
74
+ </g>
75
+ </g>`;
76
+ const output = flattenGroupInheritance(input);
77
+ const pathMatch = output.match(/<path[^>]*>/);
78
+ assert.ok(pathMatch, 'path captured');
79
+ assert.ok(/\sstroke="#0a0"/.test(pathMatch![0]), 'inner <g> stroke inherited: ' + pathMatch![0]);
80
+ assert.ok(/\sfill="#red"/.test(pathMatch![0]), 'outer <g> fill inherited through nested level: ' + pathMatch![0]);
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Case 4 — `<g>` with no inheritable attrs is a no-op (no spurious changes).
85
+ // ---------------------------------------------------------------------------
86
+ {
87
+ const input = `<g transform="translate(10,10)">
88
+ <path d="M 0 0 L 10 10" />
89
+ </g>`;
90
+ const output = flattenGroupInheritance(input);
91
+ assert.equal(output, input, 'no-op when <g> has no inheritable attrs');
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Case 5 — multiple shape types (path, line, circle) all inherit.
96
+ // ---------------------------------------------------------------------------
97
+ {
98
+ const input = `<g stroke="#blue" stroke-width="3">
99
+ <path d="M 0 0" />
100
+ <line x1="0" y1="0" x2="10" y2="10" />
101
+ <circle cx="5" cy="5" r="3" />
102
+ </g>`;
103
+ const output = flattenGroupInheritance(input);
104
+ for (const tag of ['path', 'line', 'circle']) {
105
+ const re = new RegExp('<' + tag + '[^>]*>');
106
+ const m = output.match(re);
107
+ assert.ok(m, tag + ' captured');
108
+ assert.ok(/\sstroke="#blue"/.test(m![0]), tag + ' inherits stroke: ' + m![0]);
109
+ assert.ok(/\sstroke-width="3"/.test(m![0]), tag + ' inherits stroke-width: ' + m![0]);
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Case 6 — opacity / fill-opacity / stroke-opacity inherit from `<g>`.
115
+ // Common in duotone icon libraries (Lucide duotone, Heroicons solid/outline
116
+ // pairs) that apply opacity to a `<g>` wrapping accent paths.
117
+ // ---------------------------------------------------------------------------
118
+ {
119
+ const input = `<g opacity="0.5" fill-opacity="0.6">
120
+ <path d="M 0 0 L 10 10" />
121
+ </g>`;
122
+ const output = flattenGroupInheritance(input);
123
+ const pathMatch = output.match(/<path[^>]*>/);
124
+ assert.ok(pathMatch, 'path captured');
125
+ assert.ok(/\sopacity="0\.5"/.test(pathMatch![0]), 'path inherits opacity: ' + pathMatch![0]);
126
+ assert.ok(/\sfill-opacity="0\.6"/.test(pathMatch![0]), 'path inherits fill-opacity: ' + pathMatch![0]);
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Case 7 — root `<svg>` inheritable attrs flatten onto child shapes. Many
131
+ // icon libraries (Lucide, Heroicons) set `stroke="currentColor" fill="none"
132
+ // stroke-width="2"` at the SVG root. Figma's createNodeFromSvg doesn't
133
+ // propagate these — without `flattenSvgRootInheritance`, paths render
134
+ // without stroke / fill / stroke-width.
135
+ // ---------------------------------------------------------------------------
136
+ {
137
+ const input = `<svg stroke="#0a0" fill="none" stroke-width="2" viewBox="0 0 24 24">
138
+ <path d="M 4 4 L 20 20" />
139
+ <circle cx="12" cy="12" r="5" />
140
+ </svg>`;
141
+ const output = flattenSvgRootInheritance(input);
142
+ const pathMatch = output.match(/<path[^>]*>/);
143
+ const circleMatch = output.match(/<circle[^>]*>/);
144
+ assert.ok(pathMatch && circleMatch, 'shapes captured');
145
+ for (const m of [pathMatch![0], circleMatch![0]]) {
146
+ assert.ok(/\sstroke="#0a0"/.test(m), 'shape inherits stroke from <svg>: ' + m);
147
+ assert.ok(/\sfill="none"/.test(m), 'shape inherits fill from <svg>: ' + m);
148
+ assert.ok(/\sstroke-width="2"/.test(m), 'shape inherits stroke-width from <svg>: ' + m);
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Case 8 — root SVG attrs don't override shape's own (CSS inheritance).
154
+ // ---------------------------------------------------------------------------
155
+ {
156
+ const input = `<svg stroke="#aaa" fill="none">
157
+ <path d="M 0 0 L 10 10" stroke="#000" />
158
+ </svg>`;
159
+ const output = flattenSvgRootInheritance(input);
160
+ const pathMatch = output.match(/<path[^>]*>/);
161
+ assert.ok(/\sstroke="#000"/.test(pathMatch![0]), 'shape own stroke wins over root: ' + pathMatch![0]);
162
+ assert.ok(!/\sstroke="#aaa"/.test(pathMatch![0]), 'root stroke not added when shape has own: ' + pathMatch![0]);
163
+ assert.ok(/\sfill="none"/.test(pathMatch![0]), 'fill from root inherited (shape has no own fill): ' + pathMatch![0]);
164
+ }
165
+
166
+ console.log('svg-group-inheritance-regression: PASS');
@@ -0,0 +1,211 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ (globalThis as unknown as { figma: unknown }).figma = {
4
+ notify: () => undefined,
5
+ showUI: () => undefined,
6
+ };
7
+
8
+ import { inlineSvgMarkers } from '../src/effects/icon-builder';
9
+
10
+ /**
11
+ * Regression: Figma's `createNodeFromSvg` does NOT render `<marker>`
12
+ * references — `marker-end` arrowheads disappear silently. The fix in
13
+ * `preprocessSvgForFigma` inlines the marker geometry as a transformed
14
+ * `<g transform="translate(x y) rotate(θ) scale(sx sy) translate(-refX -refY)">`
15
+ * at the path endpoint, oriented along the tangent.
16
+ *
17
+ * This fixture pins the inlining helper for the round-trip arc pattern:
18
+ * <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
19
+ * markerWidth="4" markerHeight="4">
20
+ * <path d="M0,0 L10,5 L0,10 Z" fill="..." />
21
+ * </marker>
22
+ * <path d="M 112 50 A 195 195 0 0 1 488 50" marker-end="url(#arrow)" />
23
+ *
24
+ * Expected: an extra `<g transform="…">` block appears before `</svg>`,
25
+ * positioning the triangle at the arc endpoint with the correct tangent
26
+ * rotation.
27
+ */
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Case 1: arc path with marker-end — endpoint + tangent must inject geometry.
31
+ // ---------------------------------------------------------------------------
32
+ {
33
+ const svg = `<svg viewBox="0 0 600 360">
34
+ <defs>
35
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="4" markerHeight="4">
36
+ <path d="M0,0 L10,5 L0,10 Z" fill="#00aa00" />
37
+ </marker>
38
+ </defs>
39
+ <path d="M 112 50 A 195 195 0 0 1 488 50" marker-end="url(#arrow)" />
40
+ </svg>`;
41
+
42
+ const out = inlineSvgMarkers(svg);
43
+ // Should contain a new <g transform=...> with the marker triangle inside.
44
+ assert.match(
45
+ out,
46
+ /<g\s+transform="translate\(488 50\)\s+rotate\([^"]+\)\s+scale\(0\.4 0\.4\)\s+translate\(-9 -5\)">/,
47
+ 'inlined marker <g> with translate-to-endpoint, rotate, scale, refX/refY-align'
48
+ );
49
+ assert.ok(out.indexOf('M0,0 L10,5 L0,10 Z') !== -1, 'marker triangle path preserved');
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Case 2: tangent angle for a CW horizontal-chord arc points DOWN at right
54
+ // endpoint. For arc `M 112 50 A 195 195 0 0 1 488 50` (sweep CW, lar=small),
55
+ // chord is horizontal; center lies BELOW the chord (sign of perp); tangent
56
+ // at endpoint should point downward (positive y). Roughly 90° in SVG
57
+ // y-down coords.
58
+ // ---------------------------------------------------------------------------
59
+ {
60
+ const svg = `<svg viewBox="0 0 600 360">
61
+ <defs>
62
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="4" markerHeight="4">
63
+ <path d="M0,0 L10,5 L0,10 Z" />
64
+ </marker>
65
+ </defs>
66
+ <path d="M 112 50 A 195 195 0 0 1 488 50" marker-end="url(#arrow)" />
67
+ </svg>`;
68
+ const out = inlineSvgMarkers(svg);
69
+ const rotMatch = out.match(/rotate\(([-\d.]+)\)/);
70
+ assert.ok(rotMatch, 'rotation present');
71
+ const angle = parseFloat(rotMatch![1]);
72
+ // Arc `M 112 50 A 195 195 0 0 1 488 50`: small arc + sweep=1.
73
+ // Center at (300, 101.78) — below chord in SVG y-down (visually above the
74
+ // arc since y-down inverts). Arc passes through (300, -93) — bulges UP
75
+ // visually. Velocity at endpoint (488, 50) is (-r sin θ_end, r cos θ_end)
76
+ // where θ_end ≈ -15.4°: velocity ≈ (52, 188). Angle ≈ atan2(188, 52) ≈
77
+ // 74.5° — pointing RIGHT and DOWN, which is the direction of travel as
78
+ // the arc descends from its apex into the endpoint.
79
+ assert.ok(angle > 60 && angle < 90, `rotation angle ${angle} should be ≈74.5° (right-down, direction of travel at arc endpoint)`);
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Case 3: path WITHOUT marker-end — no inlining.
84
+ // ---------------------------------------------------------------------------
85
+ {
86
+ const svg = `<svg viewBox="0 0 100 100">
87
+ <defs><marker id="arrow"><path d="M0 0 L10 5 L0 10 Z" /></marker></defs>
88
+ <path d="M 10 10 L 90 90" />
89
+ </svg>`;
90
+ const out = inlineSvgMarkers(svg);
91
+ assert.equal(out, svg, 'no marker-end → no inlining (output unchanged)');
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Case 4: line path (M + L) — tangent is the direction from start to end.
96
+ // For `M 0 0 L 100 0` (horizontal right), tangent angle = 0°.
97
+ // ---------------------------------------------------------------------------
98
+ {
99
+ const svg = `<svg viewBox="0 0 200 100">
100
+ <defs>
101
+ <marker id="m" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6">
102
+ <path d="M0,0 L10,5 L0,10 Z" />
103
+ </marker>
104
+ </defs>
105
+ <path d="M 0 0 L 100 0" marker-end="url(#m)" />
106
+ </svg>`;
107
+ const out = inlineSvgMarkers(svg);
108
+ assert.match(
109
+ out,
110
+ /translate\(100 0\)\s+rotate\(0\)/,
111
+ 'horizontal line → endpoint (100, 0) and rotation 0°'
112
+ );
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Case 5: line going DOWN (positive y in SVG) — tangent 90°.
117
+ // ---------------------------------------------------------------------------
118
+ {
119
+ const svg = `<svg viewBox="0 0 100 200">
120
+ <defs>
121
+ <marker id="m" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="3" markerHeight="3">
122
+ <path d="M0,0 L10,5 L0,10 Z" />
123
+ </marker>
124
+ </defs>
125
+ <path d="M 50 0 L 50 100" marker-end="url(#m)" />
126
+ </svg>`;
127
+ const out = inlineSvgMarkers(svg);
128
+ assert.match(
129
+ out,
130
+ /translate\(50 100\)\s+rotate\(90\)/,
131
+ 'vertical-down line → endpoint (50, 100) and rotation 90°'
132
+ );
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Case 6: missing marker definition — silently skipped (no crash, no inline).
137
+ // ---------------------------------------------------------------------------
138
+ {
139
+ const svg = `<svg viewBox="0 0 100 100">
140
+ <path d="M 0 0 L 100 100" marker-end="url(#nonexistent)" />
141
+ </svg>`;
142
+ const out = inlineSvgMarkers(svg);
143
+ assert.equal(out, svg, 'unknown marker id → no-op');
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Case 7: cubic bezier (C command). Tangent at end = direction from second
148
+ // control to endpoint. For `M 0 0 C 50 0 100 50 100 100`: last control
149
+ // (100, 50), end (100, 100). Tangent = (0, 50), angle = atan2(50, 0) = 90°.
150
+ // ---------------------------------------------------------------------------
151
+ {
152
+ const svg = `<svg viewBox="0 0 200 200">
153
+ <defs>
154
+ <marker id="m" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="4" markerHeight="4">
155
+ <path d="M0,0 L10,5 L0,10 Z" />
156
+ </marker>
157
+ </defs>
158
+ <path d="M 0 0 C 50 0 100 50 100 100" marker-end="url(#m)" />
159
+ </svg>`;
160
+ const out = inlineSvgMarkers(svg);
161
+ assert.match(
162
+ out,
163
+ /translate\(100 100\)\s+rotate\(90\)/,
164
+ 'cubic bezier endpoint at (100, 100), tangent direction angle 90°'
165
+ );
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Case 8: quadratic bezier (Q command). Tangent at end = direction from
170
+ // control to endpoint. For `M 0 0 Q 50 0 100 50`: control (50, 0), end
171
+ // (100, 50). Tangent = (50, 50), angle = 45°.
172
+ // ---------------------------------------------------------------------------
173
+ {
174
+ const svg = `<svg viewBox="0 0 200 200">
175
+ <defs>
176
+ <marker id="m" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="4" markerHeight="4">
177
+ <path d="M0,0 L10,5 L0,10 Z" />
178
+ </marker>
179
+ </defs>
180
+ <path d="M 0 0 Q 50 0 100 50" marker-end="url(#m)" />
181
+ </svg>`;
182
+ const out = inlineSvgMarkers(svg);
183
+ assert.match(
184
+ out,
185
+ /translate\(100 50\)\s+rotate\(45\)/,
186
+ 'quadratic bezier endpoint at (100, 50), tangent direction angle 45°'
187
+ );
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Case 9: horizontal lineto (H) — endpoint inherits Y from previous point,
192
+ // tangent points along the H direction.
193
+ // ---------------------------------------------------------------------------
194
+ {
195
+ const svg = `<svg viewBox="0 0 200 200">
196
+ <defs>
197
+ <marker id="m" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="4" markerHeight="4">
198
+ <path d="M0,0 L10,5 L0,10 Z" />
199
+ </marker>
200
+ </defs>
201
+ <path d="M 10 20 H 100" marker-end="url(#m)" />
202
+ </svg>`;
203
+ const out = inlineSvgMarkers(svg);
204
+ assert.match(
205
+ out,
206
+ /translate\(100 20\)\s+rotate\(0\)/,
207
+ 'H 100 from (10, 20) → endpoint (100, 20), horizontal tangent 0°'
208
+ );
209
+ }
210
+
211
+ console.log('svg-marker-inline-regression: PASS');
@@ -0,0 +1,116 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { nodeIrToSvg } from '../src/effects/icon-builder';
4
+ import type { NodeIR } from '../src/tailwind/node-ir';
5
+
6
+ // Fixture: serialise a JSX-style svg tree (NodeIR) back to an SVG string and
7
+ // verify two things this regression locks in:
8
+ // 1. <marker> children inside <defs> survive serialisation (they used to be
9
+ // filtered out because `marker` wasn't in SVG_CHILD_TAGS, so any
10
+ // arrowhead defined this way would silently disappear in Figma).
11
+ // 2. SVG attributes that are camelCase in the spec — `viewBox`,
12
+ // `preserveAspectRatio`, `refX`, `refY`, `markerWidth`, `markerHeight` —
13
+ // are emitted as-is, NOT converted to kebab-case (e.g. `marker-width`),
14
+ // which would produce invalid SVG that `figma.createNodeFromSvg` won't
15
+ // render.
16
+ //
17
+ // Originating bug: the round-trip section's bidirectional arrows used
18
+ // <marker> + markerEnd to draw arrowheads. Without this fix, the arrowheads
19
+ // disappeared in the Figma render and the bridge silhouette lost its
20
+ // `xMidYMin slice` framing.
21
+
22
+ function elem(tagLower: string, props: Record<string, unknown>, children: NodeIR[] = []): NodeIR {
23
+ return {
24
+ kind: 'element',
25
+ tagName: tagLower,
26
+ tagLower,
27
+ props,
28
+ children,
29
+ classes: [],
30
+ } as unknown as NodeIR;
31
+ }
32
+
33
+ function runRegression(): void {
34
+ const tree = elem(
35
+ 'svg',
36
+ {
37
+ viewBox: '0 0 600 360',
38
+ preserveAspectRatio: 'xMidYMid meet',
39
+ width: '100%',
40
+ height: '100%',
41
+ fill: 'none',
42
+ },
43
+ [
44
+ elem('defs', {}, [
45
+ elem(
46
+ 'marker',
47
+ {
48
+ id: 'rt-arrow',
49
+ viewBox: '0 0 10 10',
50
+ refX: 9,
51
+ refY: 5,
52
+ markerWidth: 4,
53
+ markerHeight: 4,
54
+ orient: 'auto-start-reverse',
55
+ },
56
+ [
57
+ elem('path', {
58
+ d: 'M0,0 L10,5 L0,10 Z',
59
+ fill: 'currentColor',
60
+ }),
61
+ ]
62
+ ),
63
+ ]),
64
+ elem('path', {
65
+ d: 'M 100 100 A 50 50 0 0 1 200 100',
66
+ strokeWidth: 2,
67
+ strokeDasharray: '6 6',
68
+ markerEnd: 'url(#rt-arrow)',
69
+ }),
70
+ ]
71
+ );
72
+
73
+ const svg = nodeIrToSvg(tree);
74
+ assert.ok(svg, 'expected nodeIrToSvg to return a non-empty string');
75
+
76
+ // Wrapper attrs: viewBox + preserveAspectRatio preserved with original
77
+ // (camelCase) spelling.
78
+ assert.match(svg!, /viewBox="0 0 600 360"/, 'wrapper viewBox must be preserved');
79
+ assert.match(
80
+ svg!,
81
+ /preserveAspectRatio="xMidYMid meet"/,
82
+ 'wrapper preserveAspectRatio must be preserved'
83
+ );
84
+
85
+ // Marker tag survives.
86
+ assert.match(svg!, /<marker /, '<marker> tag must be present');
87
+ assert.match(svg!, /id="rt-arrow"/, 'marker id must be preserved');
88
+
89
+ // Marker camelCase attributes preserved (NOT marker-width / ref-x).
90
+ assert.match(svg!, /markerWidth="4"/, 'markerWidth must keep camelCase');
91
+ assert.match(svg!, /markerHeight="4"/, 'markerHeight must keep camelCase');
92
+ assert.match(svg!, /refX="9"/, 'refX must keep camelCase');
93
+ assert.match(svg!, /refY="5"/, 'refY must keep camelCase');
94
+ assert.doesNotMatch(svg!, /marker-width=/, 'must not emit kebab marker-width');
95
+ assert.doesNotMatch(svg!, /ref-x=/, 'must not emit kebab ref-x');
96
+
97
+ // marker child <path> survives.
98
+ assert.match(svg!, /<path d="M0,0 L10,5 L0,10 Z"/, 'marker child path must be present');
99
+
100
+ // Path-level presentation attributes ARE kebab-case in SVG.
101
+ assert.match(svg!, /stroke-dasharray="6 6"/, 'strokeDasharray must convert to kebab-case');
102
+ assert.match(
103
+ svg!,
104
+ /marker-end="url\(#rt-arrow\)"/,
105
+ 'markerEnd reference must convert to kebab-case (the SVG attribute is marker-end)'
106
+ );
107
+ }
108
+
109
+ try {
110
+ runRegression();
111
+ console.log('svg-marker-regression: PASS');
112
+ } catch (err) {
113
+ console.error('svg-marker-regression: FAIL');
114
+ console.error(err);
115
+ process.exit(1);
116
+ }