@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
package/corpus/README.md CHANGED
@@ -28,6 +28,6 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
 
29
29
  ## Generated Counts
30
30
 
31
- - core files: 1545
31
+ - core files: 1546
32
32
  - toolkit files: 159
33
- - template files: 6296
33
+ - template files: 6306
@@ -1,5 +1,13 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.119.1
4
+
5
+ ### Patch Changes
6
+
7
+ - c015dc7: Export shared Figma paint math (gradient geometry from transforms/handles,
8
+ blend-mode mapping, linear stop remapping) from `@agent-native/core/ingestion`
9
+ so the REST and `.fig`/clipboard import renderers derive gradients identically.
10
+
3
11
  ## 0.119.0
4
12
 
5
13
  ### Minor Changes
@@ -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,
@@ -60,7 +60,6 @@ import {
60
60
  } from "react-router";
61
61
  import { toast } from "sonner";
62
62
 
63
- import { CreateLibraryDialog } from "@/components/library/CreateLibraryDialog";
64
63
  import { LibraryPresetGrid } from "@/components/library/LibraryPresetGrid";
65
64
  import { Badge } from "@/components/ui/badge";
66
65
  import { Button } from "@/components/ui/button";
@@ -2087,7 +2086,7 @@ export function LibraryWorkspace({
2087
2086
  const t = useT();
2088
2087
  const navigate = useNavigate();
2089
2088
  const routeSelectedLibraryId = useLibraryRouteSelectedId(selectedLibraryId);
2090
- const [createOpen, setCreateOpen] = useState(false);
2089
+ const createLibrary = useActionMutation("create-library");
2091
2090
  const { data, isLoading, isError, isFetching, refetch } = useActionQuery(
2092
2091
  "list-libraries",
2093
2092
  {
@@ -2131,6 +2130,20 @@ export function LibraryWorkspace({
2131
2130
  });
2132
2131
  }, [currentLibrary?.title, routeSelectedLibraryId]);
2133
2132
 
2133
+ const handleCreateKit = useCallback(() => {
2134
+ createLibrary.mutate(
2135
+ { title: t("brandKits.newBrandKit") },
2136
+ {
2137
+ onSuccess: (library: any) => {
2138
+ void navigate(`/brand-kits/${library.id}/settings`);
2139
+ },
2140
+ onError: (error: Error) => {
2141
+ toast.error(error.message);
2142
+ },
2143
+ },
2144
+ );
2145
+ }, [createLibrary, navigate, t]);
2146
+
2134
2147
  return (
2135
2148
  <div className="flex h-full min-h-0 flex-col bg-background text-foreground">
2136
2149
  <section className="min-h-0 min-w-0 flex-1 overflow-hidden">
@@ -2139,7 +2152,7 @@ export function LibraryWorkspace({
2139
2152
  selectedLibraryId={routeSelectedLibraryId}
2140
2153
  libraries={libraries}
2141
2154
  isLoading={isLoading}
2142
- onCreateKit={() => setCreateOpen(true)}
2155
+ onCreateKit={handleCreateKit}
2143
2156
  />
2144
2157
  {isError ? (
2145
2158
  <div className="flex min-h-80 flex-col items-center justify-center gap-3 px-6 text-center">
@@ -2168,15 +2181,10 @@ export function LibraryWorkspace({
2168
2181
  )}
2169
2182
  </div>
2170
2183
  ) : (
2171
- <EmptyLibraryStarter onCreateBlank={() => setCreateOpen(true)} />
2184
+ <EmptyLibraryStarter onCreateBlank={handleCreateKit} />
2172
2185
  )}
2173
2186
  </div>
2174
2187
  </section>
2175
- <CreateLibraryDialog
2176
- open={createOpen}
2177
- onOpenChange={setCreateOpen}
2178
- onCreated={(library) => navigate(`/library/${library.id}`)}
2179
- />
2180
2188
  </div>
2181
2189
  );
2182
2190
  }