@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
|
@@ -310,7 +310,7 @@ export declare function createProviderApiActions(runtime: Pick<ProviderApiRuntim
|
|
|
310
310
|
scope?: "org" | "user" | undefined;
|
|
311
311
|
}, {
|
|
312
312
|
id?: undefined;
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
+
deleted?: undefined;
|
|
361
361
|
count?: undefined;
|
|
362
362
|
providers?: undefined;
|
|
363
363
|
provider?: undefined;
|
|
364
|
-
|
|
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;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -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
|
package/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
|
+
}
|
package/src/ingestion/index.ts
CHANGED
|
@@ -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,
|