@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/ingestion/figma-node-to-html.ts +20 -99
- package/corpus/core/src/ingestion/figma-paint-math.ts +371 -0
- package/corpus/core/src/ingestion/index.ts +20 -0
- package/corpus/templates/assets/app/routes/library.tsx +17 -9
- package/corpus/templates/design/AGENTS.md +18 -5
- package/corpus/templates/design/FIGMA_INTEROPERABILITY.md +25 -12
- package/corpus/templates/design/actions/hydrate-figma-paste-images.ts +115 -0
- package/corpus/templates/design/actions/import-figma-clipboard.ts +83 -18
- package/corpus/templates/design/actions/import-figma-frame.ts +34 -21
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +28 -1
- package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +106 -11
- package/corpus/templates/design/app/components/design/FigmaHydrationDialog.tsx +273 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +7 -7
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +57 -9
- package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +10 -0
- package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +8 -1
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
- package/corpus/templates/design/app/i18n-data.ts +150 -2
- package/corpus/templates/design/app/lib/design-file-upload.ts +85 -0
- package/corpus/templates/design/app/lib/design-import.ts +19 -1
- package/corpus/templates/design/app/lib/figma-clipboard.ts +110 -10
- package/corpus/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md +6 -0
- package/corpus/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md +5 -0
- package/corpus/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md +6 -0
- package/corpus/templates/design/server/handlers/import-design-file.ts +50 -0
- package/corpus/templates/design/server/lib/fig-file-decoder.ts +90 -49
- package/corpus/templates/design/server/lib/fig-file-import.ts +19 -5
- package/corpus/templates/design/server/lib/fig-file-to-html.ts +583 -78
- package/corpus/templates/design/server/lib/figma-clipboard-local-decode.ts +238 -0
- package/corpus/templates/design/server/lib/figma-image-hydration.ts +389 -0
- package/corpus/templates/design/server/lib/figma-node-import.ts +136 -36
- package/corpus/templates/design/server/register-secrets.ts +14 -2
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/ingestion/figma-node-to-html.d.ts.map +1 -1
- package/dist/ingestion/figma-node-to-html.js +14 -88
- package/dist/ingestion/figma-node-to-html.js.map +1 -1
- package/dist/ingestion/figma-paint-math.d.ts +157 -0
- package/dist/ingestion/figma-paint-math.d.ts.map +1 -0
- package/dist/ingestion/figma-paint-math.js +274 -0
- package/dist/ingestion/figma-paint-math.js.map +1 -0
- package/dist/ingestion/index.d.ts +1 -0
- package/dist/ingestion/index.d.ts.map +1 -1
- package/dist/ingestion/index.js +1 -0
- package/dist/ingestion/index.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +7 -7
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/ingestion/figma-node-to-html.ts +20 -99
- package/src/ingestion/figma-paint-math.ts +371 -0
- package/src/ingestion/index.ts +20 -0
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -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
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.119.
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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:
|
|
477
|
+
geometry: GradientHandles,
|
|
500
478
|
box: { width: number; height: number },
|
|
501
479
|
angleDeg: number,
|
|
502
480
|
): (position: number) => number {
|
|
503
|
-
|
|
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
|
|
994
|
-
const
|
|
995
|
-
if (
|
|
996
|
-
|
|
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: ${
|
|
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
|
|
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
|
|
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={
|
|
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={
|
|
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
|
}
|