@agent-native/core 0.119.0 → 0.119.1

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 (64) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/ingestion/figma-node-to-html.ts +20 -99
  5. package/corpus/core/src/ingestion/figma-paint-math.ts +371 -0
  6. package/corpus/core/src/ingestion/index.ts +20 -0
  7. package/corpus/templates/assets/app/routes/library.tsx +17 -9
  8. package/corpus/templates/design/AGENTS.md +18 -5
  9. package/corpus/templates/design/FIGMA_INTEROPERABILITY.md +25 -12
  10. package/corpus/templates/design/actions/hydrate-figma-paste-images.ts +115 -0
  11. package/corpus/templates/design/actions/import-figma-clipboard.ts +83 -18
  12. package/corpus/templates/design/actions/import-figma-frame.ts +34 -21
  13. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +28 -1
  14. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +106 -11
  15. package/corpus/templates/design/app/components/design/FigmaHydrationDialog.tsx +273 -0
  16. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +7 -7
  17. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +57 -9
  18. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +10 -0
  19. package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +8 -1
  20. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
  21. package/corpus/templates/design/app/i18n-data.ts +150 -2
  22. package/corpus/templates/design/app/lib/design-file-upload.ts +85 -0
  23. package/corpus/templates/design/app/lib/design-import.ts +19 -1
  24. package/corpus/templates/design/app/lib/figma-clipboard.ts +110 -10
  25. package/corpus/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md +6 -0
  26. package/corpus/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md +5 -0
  27. package/corpus/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md +6 -0
  28. package/corpus/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md +6 -0
  29. package/corpus/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md +6 -0
  30. package/corpus/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md +6 -0
  31. package/corpus/templates/design/server/handlers/import-design-file.ts +50 -0
  32. package/corpus/templates/design/server/lib/fig-file-decoder.ts +90 -49
  33. package/corpus/templates/design/server/lib/fig-file-import.ts +19 -5
  34. package/corpus/templates/design/server/lib/fig-file-to-html.ts +583 -78
  35. package/corpus/templates/design/server/lib/figma-clipboard-local-decode.ts +238 -0
  36. package/corpus/templates/design/server/lib/figma-image-hydration.ts +389 -0
  37. package/corpus/templates/design/server/lib/figma-node-import.ts +136 -36
  38. package/corpus/templates/design/server/register-secrets.ts +14 -2
  39. package/dist/collab/awareness.d.ts +2 -2
  40. package/dist/collab/awareness.d.ts.map +1 -1
  41. package/dist/collab/struct-routes.d.ts +1 -1
  42. package/dist/ingestion/figma-node-to-html.d.ts.map +1 -1
  43. package/dist/ingestion/figma-node-to-html.js +14 -88
  44. package/dist/ingestion/figma-node-to-html.js.map +1 -1
  45. package/dist/ingestion/figma-paint-math.d.ts +157 -0
  46. package/dist/ingestion/figma-paint-math.d.ts.map +1 -0
  47. package/dist/ingestion/figma-paint-math.js +274 -0
  48. package/dist/ingestion/figma-paint-math.js.map +1 -0
  49. package/dist/ingestion/index.d.ts +1 -0
  50. package/dist/ingestion/index.d.ts.map +1 -1
  51. package/dist/ingestion/index.js +1 -0
  52. package/dist/ingestion/index.js.map +1 -1
  53. package/dist/observability/routes.d.ts +5 -5
  54. package/dist/progress/routes.d.ts +1 -1
  55. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  56. package/dist/provider-api/actions/provider-api.d.ts +7 -7
  57. package/dist/resources/handlers.d.ts +1 -1
  58. package/dist/secrets/routes.d.ts +3 -3
  59. package/dist/server/realtime-token.d.ts +1 -1
  60. package/dist/server/transcribe-voice.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/src/ingestion/figma-node-to-html.ts +20 -99
  63. package/src/ingestion/figma-paint-math.ts +371 -0
  64. package/src/ingestion/index.ts +20 -0
@@ -310,7 +310,7 @@ export declare function createProviderApiActions(runtime: Pick<ProviderApiRuntim
310
310
  scope?: "org" | "user" | undefined;
311
311
  }, {
312
312
  id?: undefined;
313
- found?: undefined;
313
+ deleted?: undefined;
314
314
  providers: {
315
315
  id: string;
316
316
  label: string;
@@ -322,46 +322,46 @@ export declare function createProviderApiActions(runtime: Pick<ProviderApiRuntim
322
322
  }[];
323
323
  count: number;
324
324
  provider?: undefined;
325
- deleted?: undefined;
325
+ found?: undefined;
326
326
  registered?: undefined;
327
327
  label?: undefined;
328
328
  message?: undefined;
329
329
  } | {
330
330
  id?: undefined;
331
+ deleted?: undefined;
331
332
  count?: undefined;
332
333
  providers?: undefined;
333
334
  found: boolean;
334
335
  provider: import("../custom-registry.js").CustomProviderConfig;
335
- deleted?: undefined;
336
336
  registered?: undefined;
337
337
  label?: undefined;
338
338
  message?: undefined;
339
339
  } | {
340
+ deleted?: undefined;
340
341
  count?: undefined;
341
342
  providers?: undefined;
342
343
  provider?: undefined;
343
344
  found: boolean;
344
345
  id: string;
345
- deleted?: undefined;
346
346
  registered?: undefined;
347
347
  label?: undefined;
348
348
  message?: undefined;
349
349
  } | {
350
- found?: undefined;
351
350
  count?: undefined;
352
351
  providers?: undefined;
353
352
  provider?: undefined;
353
+ found?: undefined;
354
354
  deleted: boolean;
355
355
  id: string;
356
356
  registered?: undefined;
357
357
  label?: undefined;
358
358
  message?: undefined;
359
359
  } | {
360
- found?: undefined;
360
+ deleted?: undefined;
361
361
  count?: undefined;
362
362
  providers?: undefined;
363
363
  provider?: undefined;
364
- deleted?: undefined;
364
+ found?: undefined;
365
365
  registered: boolean;
366
366
  id: string;
367
367
  label: string;
@@ -49,8 +49,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
49
49
  }>;
50
50
  /** DELETE /_agent-native/resources/:id — delete a resource */
51
51
  export declare function handleDeleteResource(event: any): Promise<{
52
- ok?: undefined;
53
52
  error: string;
53
+ ok?: undefined;
54
54
  } | {
55
55
  error?: undefined;
56
56
  ok: boolean;
@@ -34,16 +34,16 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
34
34
  /** POST /_agent-native/secrets/:key — write a secret. */
35
35
  export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
36
36
  error: string;
37
- ok?: undefined;
38
37
  status?: undefined;
38
+ ok?: undefined;
39
39
  } | {
40
40
  ok: boolean;
41
41
  status: string;
42
42
  error?: undefined;
43
43
  } | {
44
- ok?: undefined;
45
44
  error: string;
46
45
  removed?: undefined;
46
+ ok?: undefined;
47
47
  } | {
48
48
  ok: boolean;
49
49
  removed: boolean;
@@ -54,9 +54,9 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
54
54
  * current stored value without changing anything. Useful for the "Test" button.
55
55
  */
56
56
  export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
57
- ok?: undefined;
58
57
  error: string;
59
58
  note?: undefined;
59
+ ok?: undefined;
60
60
  } | {
61
61
  ok: boolean;
62
62
  note?: undefined;
@@ -26,9 +26,9 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
- error?: undefined;
30
29
  token: string;
31
30
  expiresAt: string;
32
31
  ttlSeconds: number;
32
+ error?: undefined;
33
33
  }>>;
34
34
  //# sourceMappingURL=realtime-token.d.ts.map
@@ -20,7 +20,7 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- text: string;
24
23
  error?: undefined;
24
+ text: string;
25
25
  }>>;
26
26
  //# sourceMappingURL=transcribe-voice.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.119.0",
3
+ "version": "0.119.1",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -62,6 +62,15 @@
62
62
  * mismatch.
63
63
  */
64
64
 
65
+ import {
66
+ cssBlendMode,
67
+ gradientAngleDegrees as gradientAngleDegreesMath,
68
+ remapLinearStopPosition as remapLinearStopPositionMath,
69
+ resolveGradientHandles,
70
+ vectorLength,
71
+ type GradientHandles,
72
+ } from "./figma-paint-math.js";
73
+
65
74
  export interface FigmaColor {
66
75
  r: number;
67
76
  g: number;
@@ -422,16 +431,8 @@ class FidelityTracker {
422
431
  // Gradient angle / position derivation
423
432
  // ---------------------------------------------------------------------------
424
433
 
425
- interface GradientGeometry {
426
- start: { x: number; y: number };
427
- end: { x: number; y: number };
428
- width: { x: number; y: number };
429
- }
430
-
431
- function resolveGradientGeometry(paint: FigmaPaint): GradientGeometry | null {
432
- const handles = paint.gradientHandlePositions;
433
- if (!handles || handles.length < 3) return null;
434
- return { start: handles[0]!, end: handles[1]!, width: handles[2]! };
434
+ function resolveGradientGeometry(paint: FigmaPaint): GradientHandles | null {
435
+ return resolveGradientHandles(paint.gradientHandlePositions);
435
436
  }
436
437
 
437
438
  /**
@@ -453,13 +454,7 @@ export function gradientAngleDegrees(
453
454
  paint: FigmaPaint,
454
455
  box: { width: number; height: number },
455
456
  ): number | null {
456
- const geometry = resolveGradientGeometry(paint);
457
- if (!geometry) return null;
458
- const dx = (geometry.end.x - geometry.start.x) * box.width;
459
- const dy = (geometry.end.y - geometry.start.y) * box.height;
460
- const angleRad = Math.atan2(dy, dx);
461
- const angleDeg = (angleRad * 180) / Math.PI + 90;
462
- return ((angleDeg % 360) + 360) % 360;
457
+ return gradientAngleDegreesMath(paint, box);
463
458
  }
464
459
 
465
460
  function gradientStopsCss(
@@ -478,59 +473,12 @@ function gradientStopsCss(
478
473
  .join(", ");
479
474
  }
480
475
 
481
- /**
482
- * CSS `linear-gradient(angle, ...)` always stretches its 0%/100% stops
483
- * across the box's FULL diagonal extent at that angle (the CSS spec's
484
- * "gradient line" always spans corner-to-corner) -- it has no way to say
485
- * "start partway in, end partway in" the way Figma's actual gradient handles
486
- * can (a designer can drag the start/end handles anywhere, including short
487
- * of the shape's edges, or past them). Figma's own stop positions are
488
- * fractions of the literal start-handle-to-end-handle distance, which only
489
- * happens to coincide with the CSS full-box span when the handles are
490
- * dragged exactly corner-to-corner -- a common case, but far from the only
491
- * one, and the divergence gets worse the more the box's aspect ratio departs
492
- * from square (rotated/skewed handles included, e.g. gradientTransform-authored
493
- * paints). This projects each Figma stop's real pixel position onto the same
494
- * angle CSS will use and re-expresses it as a percentage of the CSS line's
495
- * length, so a partial/offset gradient renders at the same actual pixel
496
- * positions Figma draws it at instead of silently stretching to fill the box.
497
- */
498
476
  function remapLinearStopPosition(
499
- geometry: GradientGeometry,
477
+ geometry: GradientHandles,
500
478
  box: { width: number; height: number },
501
479
  angleDeg: number,
502
480
  ): (position: number) => number {
503
- const angleRad = (angleDeg * Math.PI) / 180;
504
- const ux = Math.sin(angleRad);
505
- const uy = -Math.cos(angleRad);
506
- const lineLength = box.width * Math.abs(ux) + box.height * Math.abs(uy);
507
- if (lineLength < 1e-6) return (position) => position;
508
- const startPx = {
509
- x: geometry.start.x * box.width,
510
- y: geometry.start.y * box.height,
511
- };
512
- const endPx = {
513
- x: geometry.end.x * box.width,
514
- y: geometry.end.y * box.height,
515
- };
516
- const centerX = box.width / 2;
517
- const centerY = box.height / 2;
518
- return (position: number) => {
519
- const pointX = startPx.x + position * (endPx.x - startPx.x);
520
- const pointY = startPx.y + position * (endPx.y - startPx.y);
521
- const projected = (pointX - centerX) * ux + (pointY - centerY) * uy;
522
- return (projected + lineLength / 2) / lineLength;
523
- };
524
- }
525
-
526
- function vectorLength(
527
- from: { x: number; y: number },
528
- to: { x: number; y: number },
529
- box: { width: number; height: number },
530
- ): number {
531
- const dx = (to.x - from.x) * box.width;
532
- const dy = (to.y - from.y) * box.height;
533
- return Math.sqrt(dx * dx + dy * dy);
481
+ return remapLinearStopPositionMath(geometry, box, angleDeg);
534
482
  }
535
483
 
536
484
  /**
@@ -960,49 +908,22 @@ function buildEffects(
960
908
  // Blend modes
961
909
  // ---------------------------------------------------------------------------
962
910
 
963
- const CSS_BLEND_MODES = new Set([
964
- "multiply",
965
- "screen",
966
- "overlay",
967
- "darken",
968
- "lighten",
969
- "color-dodge",
970
- "color-burn",
971
- "hard-light",
972
- "soft-light",
973
- "difference",
974
- "exclusion",
975
- "hue",
976
- "saturation",
977
- "color",
978
- "luminosity",
979
- ]);
980
-
981
- const FIGMA_ONLY_BLEND_MODE_FALLBACK: Record<string, string> = {
982
- LINEAR_BURN: "multiply",
983
- LINEAR_DODGE: "plus-lighter",
984
- LIGHTER: "plus-lighter",
985
- DARKER: "darken",
986
- };
987
-
988
911
  function buildBlendMode(
989
912
  node: FigmaNode,
990
913
  tracker: FidelityTracker,
991
914
  ): string | undefined {
992
915
  const mode = node.blendMode;
993
- if (!mode || mode === "PASS_THROUGH" || mode === "NORMAL") return undefined;
994
- const cssMode = mode.toLowerCase().replace(/_/g, "-");
995
- if (CSS_BLEND_MODES.has(cssMode)) return cssMode;
996
- const fallback = FIGMA_ONLY_BLEND_MODE_FALLBACK[mode];
997
- if (fallback) {
916
+ if (!mode) return undefined;
917
+ const result = cssBlendMode(mode);
918
+ if (!result) return undefined;
919
+ if (result.verdict === "approximated") {
998
920
  tracker.record(
999
921
  node,
1000
922
  "approximated",
1001
- `Figma blend mode "${mode}" has no CSS equivalent; approximated as mix-blend-mode: ${fallback}.`,
923
+ `Figma blend mode "${mode}" has no CSS equivalent; approximated as mix-blend-mode: ${result.cssMode}.`,
1002
924
  );
1003
- return fallback;
1004
925
  }
1005
- return undefined;
926
+ return result.cssMode;
1006
927
  }
1007
928
 
1008
929
  // ---------------------------------------------------------------------------
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Pure, synchronous math helpers for Figma gradient geometry and blend-mode
3
+ * normalisation. No DOM, no CSS, no network.
4
+ *
5
+ * Supports two gradient-transform sources:
6
+ * - REST API `gradientHandlePositions` (3-element Vec2 array, already in
7
+ * normalized 0..1 node-space).
8
+ * - 2×3 affine transform in **either** the Kiwi/fig-file object form
9
+ * `{m00..m12}` or the REST/Plugin API array form `[[a,b,tx],[c,d,ty]]`.
10
+ * Both encode the **node-to-gradient** mapping (same convention as Figma's
11
+ * own `gradientTransform` field); invert to obtain handle positions.
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface Vec2 {
19
+ x: number;
20
+ y: number;
21
+ }
22
+
23
+ export interface GradientHandles {
24
+ start: Vec2;
25
+ end: Vec2;
26
+ width: Vec2;
27
+ }
28
+
29
+ /**
30
+ * 2×3 affine transform in the row-major object form used by Kiwi/fig-file
31
+ * decoded paint nodes:
32
+ * [m00, m01, m02]
33
+ * [m10, m11, m12]
34
+ */
35
+ export interface Mat2x3Object {
36
+ m00: number;
37
+ m01: number;
38
+ m02: number;
39
+ m10: number;
40
+ m11: number;
41
+ m12: number;
42
+ }
43
+
44
+ /**
45
+ * 2×3 affine transform in the nested-array form used by the Figma REST API
46
+ * and Plugin API `gradientTransform` field:
47
+ * [[a, b, tx], [c, d, ty]]
48
+ */
49
+ export type Mat2x3Array = [[number, number, number], [number, number, number]];
50
+
51
+ export type BlendVerdict = "exact" | "approximated";
52
+
53
+ export interface BlendModeResult {
54
+ cssMode: string;
55
+ verdict: BlendVerdict;
56
+ }
57
+
58
+ export type GradientKind = "LINEAR" | "RADIAL" | "ANGULAR" | "DIAMOND";
59
+
60
+ export interface GradientGeometry {
61
+ kind: GradientKind;
62
+ handles: GradientHandles;
63
+ start: Vec2;
64
+ end: Vec2;
65
+ center: Vec2;
66
+ rx: number;
67
+ ry: number;
68
+ rotationDeg: number;
69
+ fromDeg: number;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Matrix helpers
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Convert the REST/Plugin nested-array form to the object form. */
77
+ export function mat2x3FromArray(m: Mat2x3Array): Mat2x3Object {
78
+ return {
79
+ m00: m[0][0],
80
+ m01: m[0][1],
81
+ m02: m[0][2],
82
+ m10: m[1][0],
83
+ m11: m[1][1],
84
+ m12: m[1][2],
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Invert a 2×3 affine transform. Returns null when the matrix is singular
90
+ * (determinant near zero, i.e. the gradient has collapsed to a line or point).
91
+ *
92
+ * The 2×2 rotation/scale sub-matrix is [[m00,m01],[m10,m11]]; the
93
+ * standard 2×2 inverse is applied and the translation is back-solved:
94
+ * inv_tx = (-m11*m02 + m01*m12) / det
95
+ * inv_ty = ( m10*m02 - m00*m12) / det
96
+ */
97
+ export function invert2x3(m: Mat2x3Object): Mat2x3Object | null {
98
+ const det = m.m00 * m.m11 - m.m01 * m.m10;
99
+ if (Math.abs(det) < 1e-8) return null;
100
+ const inv00 = m.m11 / det;
101
+ const inv01 = -m.m01 / det;
102
+ const inv10 = -m.m10 / det;
103
+ const inv11 = m.m00 / det;
104
+ return {
105
+ m00: inv00,
106
+ m01: inv01,
107
+ m02: (-m.m11 * m.m02 + m.m01 * m.m12) / det,
108
+ m10: inv10,
109
+ m11: inv11,
110
+ m12: (m.m10 * m.m02 - m.m00 * m.m12) / det,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Apply a 2×3 transform to a 2-D point.
116
+ * The point is treated as a homogeneous [x, y, 1]^T column vector.
117
+ */
118
+ function applyMat2x3(m: Mat2x3Object, v: Vec2): Vec2 {
119
+ return {
120
+ x: m.m00 * v.x + m.m01 * v.y + m.m02,
121
+ y: m.m10 * v.x + m.m11 * v.y + m.m12,
122
+ };
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Handle positions from transforms
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Derive REST-style `GradientHandles` from a 2×3 **node-to-gradient**
131
+ * transform in Kiwi/fig-file object form.
132
+ *
133
+ * Figma's gradient transform encodes where the gradient's natural coordinate
134
+ * system sits inside the node's normalized [0,1]² box. The three canonical
135
+ * gradient-space points (start, end, width) map back to node-space by
136
+ * inverting the transform:
137
+ * handles = inv(M) * {(0,0), (1,0), (0,1)}
138
+ */
139
+ export function handlePositionsFromObjectTransform(
140
+ t: Mat2x3Object,
141
+ ): GradientHandles | null {
142
+ const inv = invert2x3(t);
143
+ if (!inv) return null;
144
+ return {
145
+ start: applyMat2x3(inv, { x: 0, y: 0 }),
146
+ end: applyMat2x3(inv, { x: 1, y: 0 }),
147
+ width: applyMat2x3(inv, { x: 0, y: 1 }),
148
+ };
149
+ }
150
+
151
+ export function gradientGeometryFromTransform(
152
+ kind: GradientKind,
153
+ transform: Mat2x3Object,
154
+ box: { width: number; height: number },
155
+ ): GradientGeometry | null {
156
+ const inverse = invert2x3(transform);
157
+ if (!inverse) return null;
158
+ const toPixels = (point: Vec2): Vec2 => ({
159
+ x: point.x * box.width,
160
+ y: point.y * box.height,
161
+ });
162
+ const startNormalized = applyMat2x3(inverse, { x: 0, y: 0.5 });
163
+ const endNormalized = applyMat2x3(inverse, { x: 1, y: 0.5 });
164
+ const widthNormalized = applyMat2x3(inverse, { x: 1, y: 0 });
165
+ const center = toPixels(applyMat2x3(inverse, { x: 0.5, y: 0.5 }));
166
+ const vertex = toPixels(applyMat2x3(inverse, { x: 1, y: 0.5 }));
167
+ const covertex = toPixels(applyMat2x3(inverse, { x: 0.5, y: 1 }));
168
+ const vertexDx = vertex.x - center.x;
169
+ const vertexDy = vertex.y - center.y;
170
+ return {
171
+ kind,
172
+ handles: {
173
+ start: startNormalized,
174
+ end: endNormalized,
175
+ width: widthNormalized,
176
+ },
177
+ start: toPixels(startNormalized),
178
+ end: toPixels(endNormalized),
179
+ center,
180
+ rx: Math.hypot(vertexDx, vertexDy),
181
+ ry: Math.hypot(covertex.x - center.x, covertex.y - center.y),
182
+ rotationDeg: (Math.atan2(vertexDy, vertexDx) * 180) / Math.PI,
183
+ fromDeg: (Math.atan2(-transform.m10, transform.m00) * 180) / Math.PI,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Derive REST-style `GradientHandles` from a 2×3 **node-to-gradient**
189
+ * transform in REST/Plugin API nested-array form.
190
+ */
191
+ export function handlePositionsFromArrayTransform(
192
+ t: Mat2x3Array,
193
+ ): GradientHandles | null {
194
+ return handlePositionsFromObjectTransform(mat2x3FromArray(t));
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Gradient geometry from REST handle positions
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Resolve `GradientHandles` from a raw `gradientHandlePositions` array as
203
+ * returned by the Figma REST API. Returns null when the array is too short.
204
+ */
205
+ export function resolveGradientHandles(
206
+ gradientHandlePositions: Array<Vec2> | undefined,
207
+ ): GradientHandles | null {
208
+ if (!gradientHandlePositions || gradientHandlePositions.length < 3)
209
+ return null;
210
+ return {
211
+ start: gradientHandlePositions[0]!,
212
+ end: gradientHandlePositions[1]!,
213
+ width: gradientHandlePositions[2]!,
214
+ };
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Gradient angle
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Derive a CSS `linear-gradient()` angle (degrees) from Figma's normalized
223
+ * `gradientHandlePositions`. Handle positions are normalized independently in
224
+ * x and y (0..1 relative to the node's bounding box), so the angle must be
225
+ * computed in actual pixel space using the node's real width/height —
226
+ * otherwise a non-square box silently distorts the angle.
227
+ *
228
+ * Identity: left-to-right handles (start=(0,0.5), end=(1,0.5)) → 90 deg.
229
+ * Top-to-bottom handles (start=(0.5,0), end=(0.5,1)) → 180 deg.
230
+ *
231
+ * This is the same function previously inlined in figma-node-to-html.ts and
232
+ * is preserved here verbatim for public API compatibility.
233
+ */
234
+ export function gradientAngleDegrees(
235
+ paint: { gradientHandlePositions?: Array<Vec2> },
236
+ box: { width: number; height: number },
237
+ ): number | null {
238
+ const handles = resolveGradientHandles(paint.gradientHandlePositions);
239
+ if (!handles) return null;
240
+ return gradientAngleDegreesFromHandles(handles, box);
241
+ }
242
+
243
+ /**
244
+ * Same calculation as `gradientAngleDegrees` but accepts already-resolved
245
+ * `GradientHandles` — useful when handles come from a transform inversion.
246
+ */
247
+ export function gradientAngleDegreesFromHandles(
248
+ handles: GradientHandles,
249
+ box: { width: number; height: number },
250
+ ): number {
251
+ const dx = (handles.end.x - handles.start.x) * box.width;
252
+ const dy = (handles.end.y - handles.start.y) * box.height;
253
+ const angleRad = Math.atan2(dy, dx);
254
+ const angleDeg = (angleRad * 180) / Math.PI + 90;
255
+ return ((angleDeg % 360) + 360) % 360;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Linear stop position remapping
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * CSS `linear-gradient(angle, ...)` always stretches its 0%/100% stops across
264
+ * the box's full diagonal at that angle (the CSS "gradient line" always spans
265
+ * corner-to-corner). Figma's stop positions are fractions of the literal
266
+ * handle-to-handle distance, which only coincides with the CSS span when the
267
+ * handles are dragged exactly corner-to-corner.
268
+ *
269
+ * This function returns a remap closure that projects each Figma stop's real
270
+ * pixel position onto the CSS gradient line and re-expresses it as a
271
+ * percentage of the CSS line's length, so a partial/offset gradient renders at
272
+ * the same pixel positions Figma draws it at.
273
+ */
274
+ export function remapLinearStopPosition(
275
+ handles: GradientHandles,
276
+ box: { width: number; height: number },
277
+ angleDeg: number,
278
+ ): (position: number) => number {
279
+ const angleRad = (angleDeg * Math.PI) / 180;
280
+ const ux = Math.sin(angleRad);
281
+ const uy = -Math.cos(angleRad);
282
+ const lineLength = box.width * Math.abs(ux) + box.height * Math.abs(uy);
283
+ if (lineLength < 1e-6) return (position) => position;
284
+ const startPx = {
285
+ x: handles.start.x * box.width,
286
+ y: handles.start.y * box.height,
287
+ };
288
+ const endPx = { x: handles.end.x * box.width, y: handles.end.y * box.height };
289
+ const cx = box.width / 2;
290
+ const cy = box.height / 2;
291
+ return (position: number): number => {
292
+ const px = startPx.x + position * (endPx.x - startPx.x);
293
+ const py = startPx.y + position * (endPx.y - startPx.y);
294
+ const projected = (px - cx) * ux + (py - cy) * uy;
295
+ return (projected + lineLength / 2) / lineLength;
296
+ };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Vector length (pixel-space)
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /**
304
+ * Euclidean distance between two normalized-coordinate points after scaling
305
+ * into actual pixel space.
306
+ */
307
+ export function vectorLength(
308
+ from: Vec2,
309
+ to: Vec2,
310
+ box: { width: number; height: number },
311
+ ): number {
312
+ const dx = (to.x - from.x) * box.width;
313
+ const dy = (to.y - from.y) * box.height;
314
+ return Math.sqrt(dx * dx + dy * dy);
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // CSS blend mode mapping
319
+ // ---------------------------------------------------------------------------
320
+
321
+ const CSS_BLEND_MODES = new Set([
322
+ "multiply",
323
+ "screen",
324
+ "overlay",
325
+ "darken",
326
+ "lighten",
327
+ "color-dodge",
328
+ "color-burn",
329
+ "hard-light",
330
+ "soft-light",
331
+ "difference",
332
+ "exclusion",
333
+ "hue",
334
+ "saturation",
335
+ "color",
336
+ "luminosity",
337
+ ]);
338
+
339
+ /**
340
+ * Figma-only blend modes that have no exact CSS equivalent. The value is the
341
+ * closest CSS mode (approximation) and callers should record the verdict.
342
+ */
343
+ const FIGMA_ONLY_BLEND_MODE_FALLBACK: Record<string, string> = {
344
+ LINEAR_BURN: "plus-darker",
345
+ LINEAR_DODGE: "plus-lighter",
346
+ LIGHTER: "plus-lighter",
347
+ DARKER: "darken",
348
+ };
349
+
350
+ /**
351
+ * Map a Figma blend mode string to a CSS `mix-blend-mode` value with an
352
+ * explicit fidelity verdict:
353
+ * - `"exact"` — CSS supports the mode natively.
354
+ * - `"approximated"` — mapped to the closest CSS equivalent.
355
+ *
356
+ * Returns `null` for `PASS_THROUGH`, `NORMAL`, and unrecognised modes (caller
357
+ * should omit the CSS property entirely in those cases).
358
+ */
359
+ export function cssBlendMode(figmaBlendMode: string): BlendModeResult | null {
360
+ if (
361
+ !figmaBlendMode ||
362
+ figmaBlendMode === "PASS_THROUGH" ||
363
+ figmaBlendMode === "NORMAL"
364
+ )
365
+ return null;
366
+ const cssMode = figmaBlendMode.toLowerCase().replace(/_/g, "-");
367
+ if (CSS_BLEND_MODES.has(cssMode)) return { cssMode, verdict: "exact" };
368
+ const fallback = FIGMA_ONLY_BLEND_MODE_FALLBACK[figmaBlendMode];
369
+ if (fallback) return { cssMode: fallback, verdict: "approximated" };
370
+ return null;
371
+ }
@@ -53,6 +53,26 @@ export {
53
53
  type FigmaRgba,
54
54
  type SummarizeFigmaNodeResult,
55
55
  } from "./figma.js";
56
+ export {
57
+ cssBlendMode,
58
+ gradientAngleDegreesFromHandles,
59
+ gradientGeometryFromTransform,
60
+ handlePositionsFromArrayTransform,
61
+ handlePositionsFromObjectTransform,
62
+ invert2x3,
63
+ mat2x3FromArray,
64
+ remapLinearStopPosition,
65
+ resolveGradientHandles,
66
+ vectorLength,
67
+ type BlendModeResult,
68
+ type BlendVerdict,
69
+ type GradientGeometry,
70
+ type GradientHandles,
71
+ type GradientKind,
72
+ type Mat2x3Array,
73
+ type Mat2x3Object,
74
+ type Vec2,
75
+ } from "./figma-paint-math.js";
56
76
  export {
57
77
  assertFigmaNodeTreeComplexity,
58
78
  collectFallbackNodeIds,