diff --git a/.changeset/figma-paste-housekeeping.md b/.changeset/figma-paste-housekeeping.md new file mode 100644 index 0000000000..b9172dc082 --- /dev/null +++ b/.changeset/figma-paste-housekeeping.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Export shared Figma paint math (gradient geometry from transforms/handles, +blend-mode mapping, linear stop remapping) from `@agent-native/core/ingestion` +so the REST and `.fig`/clipboard import renderers derive gradients identically. diff --git a/packages/core/src/ingestion/figma-node-to-html.ts b/packages/core/src/ingestion/figma-node-to-html.ts index ef90b0bf43..7049b84b34 100644 --- a/packages/core/src/ingestion/figma-node-to-html.ts +++ b/packages/core/src/ingestion/figma-node-to-html.ts @@ -62,6 +62,15 @@ * mismatch. */ +import { + cssBlendMode, + gradientAngleDegrees as gradientAngleDegreesMath, + remapLinearStopPosition as remapLinearStopPositionMath, + resolveGradientHandles, + vectorLength, + type GradientHandles, +} from "./figma-paint-math.js"; + export interface FigmaColor { r: number; g: number; @@ -422,16 +431,8 @@ class FidelityTracker { // Gradient angle / position derivation // --------------------------------------------------------------------------- -interface GradientGeometry { - start: { x: number; y: number }; - end: { x: number; y: number }; - width: { x: number; y: number }; -} - -function resolveGradientGeometry(paint: FigmaPaint): GradientGeometry | null { - const handles = paint.gradientHandlePositions; - if (!handles || handles.length < 3) return null; - return { start: handles[0]!, end: handles[1]!, width: handles[2]! }; +function resolveGradientGeometry(paint: FigmaPaint): GradientHandles | null { + return resolveGradientHandles(paint.gradientHandlePositions); } /** @@ -453,13 +454,7 @@ export function gradientAngleDegrees( paint: FigmaPaint, box: { width: number; height: number }, ): number | null { - const geometry = resolveGradientGeometry(paint); - if (!geometry) return null; - const dx = (geometry.end.x - geometry.start.x) * box.width; - const dy = (geometry.end.y - geometry.start.y) * box.height; - const angleRad = Math.atan2(dy, dx); - const angleDeg = (angleRad * 180) / Math.PI + 90; - return ((angleDeg % 360) + 360) % 360; + return gradientAngleDegreesMath(paint, box); } function gradientStopsCss( @@ -478,59 +473,12 @@ function gradientStopsCss( .join(", "); } -/** - * CSS `linear-gradient(angle, ...)` always stretches its 0%/100% stops - * across the box's FULL diagonal extent at that angle (the CSS spec's - * "gradient line" always spans corner-to-corner) -- it has no way to say - * "start partway in, end partway in" the way Figma's actual gradient handles - * can (a designer can drag the start/end handles anywhere, including short - * of the shape's edges, or past them). Figma's own stop positions are - * fractions of the literal start-handle-to-end-handle distance, which only - * happens to coincide with the CSS full-box span when the handles are - * dragged exactly corner-to-corner -- a common case, but far from the only - * one, and the divergence gets worse the more the box's aspect ratio departs - * from square (rotated/skewed handles included, e.g. gradientTransform-authored - * paints). This projects each Figma stop's real pixel position onto the same - * angle CSS will use and re-expresses it as a percentage of the CSS line's - * length, so a partial/offset gradient renders at the same actual pixel - * positions Figma draws it at instead of silently stretching to fill the box. - */ function remapLinearStopPosition( - geometry: GradientGeometry, + geometry: GradientHandles, box: { width: number; height: number }, angleDeg: number, ): (position: number) => number { - const angleRad = (angleDeg * Math.PI) / 180; - const ux = Math.sin(angleRad); - const uy = -Math.cos(angleRad); - const lineLength = box.width * Math.abs(ux) + box.height * Math.abs(uy); - if (lineLength < 1e-6) return (position) => position; - const startPx = { - x: geometry.start.x * box.width, - y: geometry.start.y * box.height, - }; - const endPx = { - x: geometry.end.x * box.width, - y: geometry.end.y * box.height, - }; - const centerX = box.width / 2; - const centerY = box.height / 2; - return (position: number) => { - const pointX = startPx.x + position * (endPx.x - startPx.x); - const pointY = startPx.y + position * (endPx.y - startPx.y); - const projected = (pointX - centerX) * ux + (pointY - centerY) * uy; - return (projected + lineLength / 2) / lineLength; - }; -} - -function vectorLength( - from: { x: number; y: number }, - to: { x: number; y: number }, - box: { width: number; height: number }, -): number { - const dx = (to.x - from.x) * box.width; - const dy = (to.y - from.y) * box.height; - return Math.sqrt(dx * dx + dy * dy); + return remapLinearStopPositionMath(geometry, box, angleDeg); } /** @@ -960,49 +908,22 @@ function buildEffects( // Blend modes // --------------------------------------------------------------------------- -const CSS_BLEND_MODES = new Set([ - "multiply", - "screen", - "overlay", - "darken", - "lighten", - "color-dodge", - "color-burn", - "hard-light", - "soft-light", - "difference", - "exclusion", - "hue", - "saturation", - "color", - "luminosity", -]); - -const FIGMA_ONLY_BLEND_MODE_FALLBACK: Record = { - LINEAR_BURN: "multiply", - LINEAR_DODGE: "plus-lighter", - LIGHTER: "plus-lighter", - DARKER: "darken", -}; - function buildBlendMode( node: FigmaNode, tracker: FidelityTracker, ): string | undefined { const mode = node.blendMode; - if (!mode || mode === "PASS_THROUGH" || mode === "NORMAL") return undefined; - const cssMode = mode.toLowerCase().replace(/_/g, "-"); - if (CSS_BLEND_MODES.has(cssMode)) return cssMode; - const fallback = FIGMA_ONLY_BLEND_MODE_FALLBACK[mode]; - if (fallback) { + if (!mode) return undefined; + const result = cssBlendMode(mode); + if (!result) return undefined; + if (result.verdict === "approximated") { tracker.record( node, "approximated", - `Figma blend mode "${mode}" has no CSS equivalent; approximated as mix-blend-mode: ${fallback}.`, + `Figma blend mode "${mode}" has no CSS equivalent; approximated as mix-blend-mode: ${result.cssMode}.`, ); - return fallback; } - return undefined; + return result.cssMode; } // --------------------------------------------------------------------------- diff --git a/packages/core/src/ingestion/figma-paint-math.spec.ts b/packages/core/src/ingestion/figma-paint-math.spec.ts new file mode 100644 index 0000000000..86a2e88fe9 --- /dev/null +++ b/packages/core/src/ingestion/figma-paint-math.spec.ts @@ -0,0 +1,394 @@ +import { describe, expect, it } from "vitest"; + +import { + cssBlendMode, + gradientAngleDegrees, + gradientAngleDegreesFromHandles, + handlePositionsFromArrayTransform, + handlePositionsFromObjectTransform, + invert2x3, + mat2x3FromArray, + remapLinearStopPosition, + resolveGradientHandles, + vectorLength, + type Mat2x3Array, +} from "./figma-paint-math.js"; + +// --------------------------------------------------------------------------- +// invert2x3 +// --------------------------------------------------------------------------- + +describe("invert2x3", () => { + it("returns null for a singular matrix", () => { + expect( + invert2x3({ m00: 0, m01: 0, m02: 0, m10: 0, m11: 0, m12: 0 }), + ).toBeNull(); + }); + + it("inverts the identity matrix to itself", () => { + const inv = invert2x3({ m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }); + expect(inv).not.toBeNull(); + expect(inv!.m00).toBeCloseTo(1); + expect(inv!.m01).toBeCloseTo(0); + expect(inv!.m02).toBeCloseTo(0); + expect(inv!.m10).toBeCloseTo(0); + expect(inv!.m11).toBeCloseTo(1); + expect(inv!.m12).toBeCloseTo(0); + }); + + it("inverts a pure translation matrix so the translation negates", () => { + // M = [[1, 0, 3], [0, 1, 7]] => inv = [[1, 0, -3], [0, 1, -7]] + const inv = invert2x3({ m00: 1, m01: 0, m02: 3, m10: 0, m11: 1, m12: 7 }); + expect(inv).not.toBeNull(); + expect(inv!.m02).toBeCloseTo(-3); + expect(inv!.m12).toBeCloseTo(-7); + }); + + it("round-trips: M * inv(M) = identity up to floating-point", () => { + const m = { m00: 2, m01: 0.5, m02: 0.1, m10: -0.3, m11: 1.5, m12: 0.4 }; + const inv = invert2x3(m); + expect(inv).not.toBeNull(); + const i = inv!; + // (M * inv)_00 = m00*i.m00 + m01*i.m10 ~ 1 + expect(m.m00 * i.m00 + m.m01 * i.m10).toBeCloseTo(1); + // (M * inv)_01 = m00*i.m01 + m01*i.m11 ~ 0 + expect(m.m00 * i.m01 + m.m01 * i.m11).toBeCloseTo(0); + // (M * inv)_11 = m10*i.m01 + m11*i.m11 ~ 1 + expect(m.m10 * i.m01 + m.m11 * i.m11).toBeCloseTo(1); + }); +}); + +// --------------------------------------------------------------------------- +// mat2x3FromArray +// --------------------------------------------------------------------------- + +describe("mat2x3FromArray", () => { + it("converts REST nested-array form to object form", () => { + const arr: Mat2x3Array = [ + [2, 3, 4], + [5, 6, 7], + ]; + const obj = mat2x3FromArray(arr); + expect(obj).toEqual({ m00: 2, m01: 3, m02: 4, m10: 5, m11: 6, m12: 7 }); + }); +}); + +// --------------------------------------------------------------------------- +// handlePositionsFromObjectTransform / handlePositionsFromArrayTransform +// --------------------------------------------------------------------------- + +describe("handlePositionsFromObjectTransform", () => { + it("returns null for a singular transform", () => { + expect( + handlePositionsFromObjectTransform({ + m00: 0, + m01: 0, + m02: 0, + m10: 0, + m11: 0, + m12: 0, + }), + ).toBeNull(); + }); + + it("identity transform produces canonical handle positions", () => { + // Identity node-to-gradient: gradient fills the whole [0,1]² box. + const handles = handlePositionsFromObjectTransform({ + m00: 1, + m01: 0, + m02: 0, + m10: 0, + m11: 1, + m12: 0, + }); + expect(handles).not.toBeNull(); + expect(handles!.start).toEqual({ x: 0, y: 0 }); + expect(handles!.end).toEqual({ x: 1, y: 0 }); + expect(handles!.width).toEqual({ x: 0, y: 1 }); + }); + + it("recovers start=(0,0.5) end=(1,0.5) from the REST left-to-right gradient transform", () => { + // For a left-to-right linear gradient in a box: + // The gradient transform (node-to-gradient) for start=(0,0.5),end=(1,0.5) + // maps y=0.5 to gradient_y=0 and x goes 0->1 linearly: + // u = x (gradient x = node x) + // v = y - 0.5 (gradient y = node y - 0.5, so gradient center is at y=0.5) + // Inverse (gradient-to-node): + // x = u, y = v + 0.5 + // So the transform (node-to-gradient) M satisfies: + // [m00, m01, m02] [x] = [u] => m00=1, m01=0, m02=0 + // [m10, m11, m12] [y] = [v] => m10=0, m11=1, m12=-0.5 + const handles = handlePositionsFromObjectTransform({ + m00: 1, + m01: 0, + m02: 0, + m10: 0, + m11: 1, + m12: -0.5, + }); + expect(handles).not.toBeNull(); + expect(handles!.start.x).toBeCloseTo(0); + expect(handles!.start.y).toBeCloseTo(0.5); + expect(handles!.end.x).toBeCloseTo(1); + expect(handles!.end.y).toBeCloseTo(0.5); + }); +}); + +describe("handlePositionsFromArrayTransform", () => { + it("delegates to the object form with the same result", () => { + const arr: Mat2x3Array = [ + [1, 0, 0], + [0, 1, -0.5], + ]; + const fromArr = handlePositionsFromArrayTransform(arr); + const fromObj = handlePositionsFromObjectTransform({ + m00: 1, + m01: 0, + m02: 0, + m10: 0, + m11: 1, + m12: -0.5, + }); + expect(fromArr).toEqual(fromObj); + }); +}); + +// --------------------------------------------------------------------------- +// resolveGradientHandles +// --------------------------------------------------------------------------- + +describe("resolveGradientHandles", () => { + it("returns null when handles are missing", () => { + expect(resolveGradientHandles(undefined)).toBeNull(); + }); + + it("returns null when fewer than 3 handles are provided", () => { + expect( + resolveGradientHandles([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + ).toBeNull(); + }); + + it("returns the three named handles", () => { + const result = resolveGradientHandles([ + { x: 0, y: 0.5 }, + { x: 1, y: 0.5 }, + { x: 1, y: 0 }, + ]); + expect(result).toEqual({ + start: { x: 0, y: 0.5 }, + end: { x: 1, y: 0.5 }, + width: { x: 1, y: 0 }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// gradientAngleDegrees (public compat surface) +// --------------------------------------------------------------------------- + +describe("gradientAngleDegrees", () => { + it("resolves identity left-to-right handles to 90 deg (CSS 'to right')", () => { + expect( + gradientAngleDegrees( + { + gradientHandlePositions: [ + { x: 0, y: 0.5 }, + { x: 1, y: 0.5 }, + { x: 1, y: 0 }, + ], + }, + { width: 200, height: 100 }, + ), + ).toBe(90); + }); + + it("resolves top-to-bottom handles to 180 deg (CSS 'to bottom')", () => { + expect( + gradientAngleDegrees( + { + gradientHandlePositions: [ + { x: 0.5, y: 0 }, + { x: 0.5, y: 1 }, + { x: 1, y: 0 }, + ], + }, + { width: 200, height: 100 }, + ), + ).toBe(180); + }); + + it("resolves square 45-deg diagonal to 135 deg", () => { + expect( + gradientAngleDegrees( + { + gradientHandlePositions: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: 0 }, + ], + }, + { width: 100, height: 100 }, + ), + ).toBe(135); + }); + + it("corrects for non-square box aspect ratio", () => { + // Tall narrow box: dx=50, dy=200 -> atan2(200,50) ~= 75.96 -> +90 ~= 165.96 + const angle = gradientAngleDegrees( + { + gradientHandlePositions: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: 0 }, + ], + }, + { width: 50, height: 200 }, + ); + expect(angle).not.toBe(135); + expect(angle).toBeCloseTo(165.96, 1); + }); + + it("returns null when gradientHandlePositions is missing", () => { + expect(gradientAngleDegrees({}, { width: 100, height: 100 })).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// gradientAngleDegreesFromHandles +// --------------------------------------------------------------------------- + +describe("gradientAngleDegreesFromHandles", () => { + it("matches gradientAngleDegrees for the same handle data", () => { + const handles = { + start: { x: 0, y: 0.5 }, + end: { x: 1, y: 0.5 }, + width: { x: 1, y: 0 }, + }; + const box = { width: 200, height: 100 }; + expect(gradientAngleDegreesFromHandles(handles, box)).toBe(90); + }); +}); + +// --------------------------------------------------------------------------- +// remapLinearStopPosition +// --------------------------------------------------------------------------- + +describe("remapLinearStopPosition", () => { + it("returns the identity mapping for a gradient whose handles exactly span the CSS line", () => { + // A horizontal gradient on a 100x100 box: handles from (0,0.5) to (1,0.5). + // The CSS line at 90 deg has length 100 (box width). The start handle + // projects to 0% and the end handle to 100%, so stop positions are + // unchanged. + const handles = { + start: { x: 0, y: 0.5 }, + end: { x: 1, y: 0.5 }, + width: { x: 1, y: 0 }, + }; + const box = { width: 100, height: 100 }; + const remap = remapLinearStopPosition(handles, box, 90); + expect(remap(0)).toBeCloseTo(0); + expect(remap(0.5)).toBeCloseTo(0.5); + expect(remap(1)).toBeCloseTo(1); + }); + + it("shifts stops when the gradient handles don't span the full box", () => { + // Handles span only the middle 50% of a 100x100 box: start=(0.25,0.5) + // end=(0.75,0.5). The CSS line (90 deg, 100px) goes 0..100px; + // handle start projects to 25px and end to 75px. A stop at position=0 + // (the start handle) should map to 0.25 and one at position=1 to 0.75. + const handles = { + start: { x: 0.25, y: 0.5 }, + end: { x: 0.75, y: 0.5 }, + width: { x: 0.75, y: 0 }, + }; + const box = { width: 100, height: 100 }; + const remap = remapLinearStopPosition(handles, box, 90); + expect(remap(0)).toBeCloseTo(0.25); + expect(remap(1)).toBeCloseTo(0.75); + }); + + it("returns identity when lineLength is near zero", () => { + // 0-degree angle in a zero-width box -> lineLength ~ 0 + const handles = { + start: { x: 0, y: 0 }, + end: { x: 1, y: 0 }, + width: { x: 0, y: 1 }, + }; + const remap = remapLinearStopPosition(handles, { width: 0, height: 0 }, 0); + expect(remap(0.4)).toBeCloseTo(0.4); + }); +}); + +// --------------------------------------------------------------------------- +// vectorLength +// --------------------------------------------------------------------------- + +describe("vectorLength", () => { + it("returns pixel-space length between two normalized points", () => { + // (0,0) -> (1,0) on a 100x50 box = 100px + expect( + vectorLength({ x: 0, y: 0 }, { x: 1, y: 0 }, { width: 100, height: 50 }), + ).toBeCloseTo(100); + // (0,0) -> (0,1) on a 100x50 box = 50px + expect( + vectorLength({ x: 0, y: 0 }, { x: 0, y: 1 }, { width: 100, height: 50 }), + ).toBeCloseTo(50); + // (0,0) -> (1,1) on a 100x100 box = 100*sqrt(2) + expect( + vectorLength({ x: 0, y: 0 }, { x: 1, y: 1 }, { width: 100, height: 100 }), + ).toBeCloseTo(100 * Math.sqrt(2)); + }); +}); + +// --------------------------------------------------------------------------- +// cssBlendMode +// --------------------------------------------------------------------------- + +describe("cssBlendMode", () => { + it("returns null for PASS_THROUGH and NORMAL", () => { + expect(cssBlendMode("PASS_THROUGH")).toBeNull(); + expect(cssBlendMode("NORMAL")).toBeNull(); + }); + + it("returns exact for natively-supported CSS blend modes", () => { + expect(cssBlendMode("MULTIPLY")).toEqual({ + cssMode: "multiply", + verdict: "exact", + }); + expect(cssBlendMode("SCREEN")).toEqual({ + cssMode: "screen", + verdict: "exact", + }); + expect(cssBlendMode("HARD_LIGHT")).toEqual({ + cssMode: "hard-light", + verdict: "exact", + }); + }); + + it("returns approximated for Figma-only blend modes", () => { + expect(cssBlendMode("LINEAR_BURN")).toEqual({ + cssMode: "plus-darker", + verdict: "approximated", + }); + expect(cssBlendMode("LINEAR_DODGE")).toEqual({ + cssMode: "plus-lighter", + verdict: "approximated", + }); + expect(cssBlendMode("LIGHTER")).toEqual({ + cssMode: "plus-lighter", + verdict: "approximated", + }); + expect(cssBlendMode("DARKER")).toEqual({ + cssMode: "darken", + verdict: "approximated", + }); + }); + + it("returns null for unrecognised modes", () => { + expect(cssBlendMode("DISSOLVE")).toBeNull(); + expect(cssBlendMode("UNKNOWN_MODE")).toBeNull(); + }); +}); diff --git a/packages/core/src/ingestion/figma-paint-math.ts b/packages/core/src/ingestion/figma-paint-math.ts new file mode 100644 index 0000000000..6cbf513f1a --- /dev/null +++ b/packages/core/src/ingestion/figma-paint-math.ts @@ -0,0 +1,371 @@ +/** + * Pure, synchronous math helpers for Figma gradient geometry and blend-mode + * normalisation. No DOM, no CSS, no network. + * + * Supports two gradient-transform sources: + * - REST API `gradientHandlePositions` (3-element Vec2 array, already in + * normalized 0..1 node-space). + * - 2×3 affine transform in **either** the Kiwi/fig-file object form + * `{m00..m12}` or the REST/Plugin API array form `[[a,b,tx],[c,d,ty]]`. + * Both encode the **node-to-gradient** mapping (same convention as Figma's + * own `gradientTransform` field); invert to obtain handle positions. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface Vec2 { + x: number; + y: number; +} + +export interface GradientHandles { + start: Vec2; + end: Vec2; + width: Vec2; +} + +/** + * 2×3 affine transform in the row-major object form used by Kiwi/fig-file + * decoded paint nodes: + * [m00, m01, m02] + * [m10, m11, m12] + */ +export interface Mat2x3Object { + m00: number; + m01: number; + m02: number; + m10: number; + m11: number; + m12: number; +} + +/** + * 2×3 affine transform in the nested-array form used by the Figma REST API + * and Plugin API `gradientTransform` field: + * [[a, b, tx], [c, d, ty]] + */ +export type Mat2x3Array = [[number, number, number], [number, number, number]]; + +export type BlendVerdict = "exact" | "approximated"; + +export interface BlendModeResult { + cssMode: string; + verdict: BlendVerdict; +} + +export type GradientKind = "LINEAR" | "RADIAL" | "ANGULAR" | "DIAMOND"; + +export interface GradientGeometry { + kind: GradientKind; + handles: GradientHandles; + start: Vec2; + end: Vec2; + center: Vec2; + rx: number; + ry: number; + rotationDeg: number; + fromDeg: number; +} + +// --------------------------------------------------------------------------- +// Matrix helpers +// --------------------------------------------------------------------------- + +/** Convert the REST/Plugin nested-array form to the object form. */ +export function mat2x3FromArray(m: Mat2x3Array): Mat2x3Object { + return { + m00: m[0][0], + m01: m[0][1], + m02: m[0][2], + m10: m[1][0], + m11: m[1][1], + m12: m[1][2], + }; +} + +/** + * Invert a 2×3 affine transform. Returns null when the matrix is singular + * (determinant near zero, i.e. the gradient has collapsed to a line or point). + * + * The 2×2 rotation/scale sub-matrix is [[m00,m01],[m10,m11]]; the + * standard 2×2 inverse is applied and the translation is back-solved: + * inv_tx = (-m11*m02 + m01*m12) / det + * inv_ty = ( m10*m02 - m00*m12) / det + */ +export function invert2x3(m: Mat2x3Object): Mat2x3Object | null { + const det = m.m00 * m.m11 - m.m01 * m.m10; + if (Math.abs(det) < 1e-8) return null; + const inv00 = m.m11 / det; + const inv01 = -m.m01 / det; + const inv10 = -m.m10 / det; + const inv11 = m.m00 / det; + return { + m00: inv00, + m01: inv01, + m02: (-m.m11 * m.m02 + m.m01 * m.m12) / det, + m10: inv10, + m11: inv11, + m12: (m.m10 * m.m02 - m.m00 * m.m12) / det, + }; +} + +/** + * Apply a 2×3 transform to a 2-D point. + * The point is treated as a homogeneous [x, y, 1]^T column vector. + */ +function applyMat2x3(m: Mat2x3Object, v: Vec2): Vec2 { + return { + x: m.m00 * v.x + m.m01 * v.y + m.m02, + y: m.m10 * v.x + m.m11 * v.y + m.m12, + }; +} + +// --------------------------------------------------------------------------- +// Handle positions from transforms +// --------------------------------------------------------------------------- + +/** + * Derive REST-style `GradientHandles` from a 2×3 **node-to-gradient** + * transform in Kiwi/fig-file object form. + * + * Figma's gradient transform encodes where the gradient's natural coordinate + * system sits inside the node's normalized [0,1]² box. The three canonical + * gradient-space points (start, end, width) map back to node-space by + * inverting the transform: + * handles = inv(M) * {(0,0), (1,0), (0,1)} + */ +export function handlePositionsFromObjectTransform( + t: Mat2x3Object, +): GradientHandles | null { + const inv = invert2x3(t); + if (!inv) return null; + return { + start: applyMat2x3(inv, { x: 0, y: 0 }), + end: applyMat2x3(inv, { x: 1, y: 0 }), + width: applyMat2x3(inv, { x: 0, y: 1 }), + }; +} + +export function gradientGeometryFromTransform( + kind: GradientKind, + transform: Mat2x3Object, + box: { width: number; height: number }, +): GradientGeometry | null { + const inverse = invert2x3(transform); + if (!inverse) return null; + const toPixels = (point: Vec2): Vec2 => ({ + x: point.x * box.width, + y: point.y * box.height, + }); + const startNormalized = applyMat2x3(inverse, { x: 0, y: 0.5 }); + const endNormalized = applyMat2x3(inverse, { x: 1, y: 0.5 }); + const widthNormalized = applyMat2x3(inverse, { x: 1, y: 0 }); + const center = toPixels(applyMat2x3(inverse, { x: 0.5, y: 0.5 })); + const vertex = toPixels(applyMat2x3(inverse, { x: 1, y: 0.5 })); + const covertex = toPixels(applyMat2x3(inverse, { x: 0.5, y: 1 })); + const vertexDx = vertex.x - center.x; + const vertexDy = vertex.y - center.y; + return { + kind, + handles: { + start: startNormalized, + end: endNormalized, + width: widthNormalized, + }, + start: toPixels(startNormalized), + end: toPixels(endNormalized), + center, + rx: Math.hypot(vertexDx, vertexDy), + ry: Math.hypot(covertex.x - center.x, covertex.y - center.y), + rotationDeg: (Math.atan2(vertexDy, vertexDx) * 180) / Math.PI, + fromDeg: (Math.atan2(-transform.m10, transform.m00) * 180) / Math.PI, + }; +} + +/** + * Derive REST-style `GradientHandles` from a 2×3 **node-to-gradient** + * transform in REST/Plugin API nested-array form. + */ +export function handlePositionsFromArrayTransform( + t: Mat2x3Array, +): GradientHandles | null { + return handlePositionsFromObjectTransform(mat2x3FromArray(t)); +} + +// --------------------------------------------------------------------------- +// Gradient geometry from REST handle positions +// --------------------------------------------------------------------------- + +/** + * Resolve `GradientHandles` from a raw `gradientHandlePositions` array as + * returned by the Figma REST API. Returns null when the array is too short. + */ +export function resolveGradientHandles( + gradientHandlePositions: Array | undefined, +): GradientHandles | null { + if (!gradientHandlePositions || gradientHandlePositions.length < 3) + return null; + return { + start: gradientHandlePositions[0]!, + end: gradientHandlePositions[1]!, + width: gradientHandlePositions[2]!, + }; +} + +// --------------------------------------------------------------------------- +// Gradient angle +// --------------------------------------------------------------------------- + +/** + * Derive a CSS `linear-gradient()` angle (degrees) from Figma's normalized + * `gradientHandlePositions`. Handle positions are normalized independently in + * x and y (0..1 relative to the node's bounding box), so the angle must be + * computed in actual pixel space using the node's real width/height — + * otherwise a non-square box silently distorts the angle. + * + * Identity: left-to-right handles (start=(0,0.5), end=(1,0.5)) → 90 deg. + * Top-to-bottom handles (start=(0.5,0), end=(0.5,1)) → 180 deg. + * + * This is the same function previously inlined in figma-node-to-html.ts and + * is preserved here verbatim for public API compatibility. + */ +export function gradientAngleDegrees( + paint: { gradientHandlePositions?: Array }, + box: { width: number; height: number }, +): number | null { + const handles = resolveGradientHandles(paint.gradientHandlePositions); + if (!handles) return null; + return gradientAngleDegreesFromHandles(handles, box); +} + +/** + * Same calculation as `gradientAngleDegrees` but accepts already-resolved + * `GradientHandles` — useful when handles come from a transform inversion. + */ +export function gradientAngleDegreesFromHandles( + handles: GradientHandles, + box: { width: number; height: number }, +): number { + const dx = (handles.end.x - handles.start.x) * box.width; + const dy = (handles.end.y - handles.start.y) * box.height; + const angleRad = Math.atan2(dy, dx); + const angleDeg = (angleRad * 180) / Math.PI + 90; + return ((angleDeg % 360) + 360) % 360; +} + +// --------------------------------------------------------------------------- +// Linear stop position remapping +// --------------------------------------------------------------------------- + +/** + * CSS `linear-gradient(angle, ...)` always stretches its 0%/100% stops across + * the box's full diagonal at that angle (the CSS "gradient line" always spans + * corner-to-corner). Figma's stop positions are fractions of the literal + * handle-to-handle distance, which only coincides with the CSS span when the + * handles are dragged exactly corner-to-corner. + * + * This function returns a remap closure that projects each Figma stop's real + * pixel position onto the CSS gradient line and re-expresses it as a + * percentage of the CSS line's length, so a partial/offset gradient renders at + * the same pixel positions Figma draws it at. + */ +export function remapLinearStopPosition( + handles: GradientHandles, + box: { width: number; height: number }, + angleDeg: number, +): (position: number) => number { + const angleRad = (angleDeg * Math.PI) / 180; + const ux = Math.sin(angleRad); + const uy = -Math.cos(angleRad); + const lineLength = box.width * Math.abs(ux) + box.height * Math.abs(uy); + if (lineLength < 1e-6) return (position) => position; + const startPx = { + x: handles.start.x * box.width, + y: handles.start.y * box.height, + }; + const endPx = { x: handles.end.x * box.width, y: handles.end.y * box.height }; + const cx = box.width / 2; + const cy = box.height / 2; + return (position: number): number => { + const px = startPx.x + position * (endPx.x - startPx.x); + const py = startPx.y + position * (endPx.y - startPx.y); + const projected = (px - cx) * ux + (py - cy) * uy; + return (projected + lineLength / 2) / lineLength; + }; +} + +// --------------------------------------------------------------------------- +// Vector length (pixel-space) +// --------------------------------------------------------------------------- + +/** + * Euclidean distance between two normalized-coordinate points after scaling + * into actual pixel space. + */ +export function vectorLength( + from: Vec2, + to: Vec2, + box: { width: number; height: number }, +): number { + const dx = (to.x - from.x) * box.width; + const dy = (to.y - from.y) * box.height; + return Math.sqrt(dx * dx + dy * dy); +} + +// --------------------------------------------------------------------------- +// CSS blend mode mapping +// --------------------------------------------------------------------------- + +const CSS_BLEND_MODES = new Set([ + "multiply", + "screen", + "overlay", + "darken", + "lighten", + "color-dodge", + "color-burn", + "hard-light", + "soft-light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity", +]); + +/** + * Figma-only blend modes that have no exact CSS equivalent. The value is the + * closest CSS mode (approximation) and callers should record the verdict. + */ +const FIGMA_ONLY_BLEND_MODE_FALLBACK: Record = { + LINEAR_BURN: "plus-darker", + LINEAR_DODGE: "plus-lighter", + LIGHTER: "plus-lighter", + DARKER: "darken", +}; + +/** + * Map a Figma blend mode string to a CSS `mix-blend-mode` value with an + * explicit fidelity verdict: + * - `"exact"` — CSS supports the mode natively. + * - `"approximated"` — mapped to the closest CSS equivalent. + * + * Returns `null` for `PASS_THROUGH`, `NORMAL`, and unrecognised modes (caller + * should omit the CSS property entirely in those cases). + */ +export function cssBlendMode(figmaBlendMode: string): BlendModeResult | null { + if ( + !figmaBlendMode || + figmaBlendMode === "PASS_THROUGH" || + figmaBlendMode === "NORMAL" + ) + return null; + const cssMode = figmaBlendMode.toLowerCase().replace(/_/g, "-"); + if (CSS_BLEND_MODES.has(cssMode)) return { cssMode, verdict: "exact" }; + const fallback = FIGMA_ONLY_BLEND_MODE_FALLBACK[figmaBlendMode]; + if (fallback) return { cssMode: fallback, verdict: "approximated" }; + return null; +} diff --git a/packages/core/src/ingestion/index.ts b/packages/core/src/ingestion/index.ts index 85a177c3eb..04727a9be1 100644 --- a/packages/core/src/ingestion/index.ts +++ b/packages/core/src/ingestion/index.ts @@ -53,6 +53,26 @@ export { type FigmaRgba, type SummarizeFigmaNodeResult, } from "./figma.js"; +export { + cssBlendMode, + gradientAngleDegreesFromHandles, + gradientGeometryFromTransform, + handlePositionsFromArrayTransform, + handlePositionsFromObjectTransform, + invert2x3, + mat2x3FromArray, + remapLinearStopPosition, + resolveGradientHandles, + vectorLength, + type BlendModeResult, + type BlendVerdict, + type GradientGeometry, + type GradientHandles, + type GradientKind, + type Mat2x3Array, + type Mat2x3Object, + type Vec2, +} from "./figma-paint-math.js"; export { assertFigmaNodeTreeComplexity, collectFallbackNodeIds, diff --git a/scripts/i18n-catalog-english-value-baseline.txt b/scripts/i18n-catalog-english-value-baseline.txt index c4d4343889..cfee6977d1 100644 --- a/scripts/i18n-catalog-english-value-baseline.txt +++ b/scripts/i18n-catalog-english-value-baseline.txt @@ -228,6 +228,22 @@ templates/clips/app/i18n/pt-BR|sharePage.agentNativeClips|Agent-Native Clips templates/clips/app/i18n/zh-CN|sharePage.agentNativeClips|Agent-Native Clips templates/clips/app/i18n/zh-TW|sharePage.agentNativeClips|Agent-Native Clips templates/design/app/i18n/ar-SA|designEditor.askAgent|Ask agent +templates/design/app/i18n/ar-SA|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/ar-SA|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/ar-SA|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/ar-SA|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/ar-SA|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/ar-SA|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/ar-SA|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/ar-SA|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/ar-SA|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/ar-SA|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/ar-SA|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/ar-SA|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/ar-SA|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/ar-SA|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/ar-SA|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/ar-SA|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/ar-SA|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/ar-SA|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/ar-SA|designEditor.tokens.add|Add token @@ -238,6 +254,22 @@ templates/design/app/i18n/ar-SA|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/ar-SA|designEditor.tokens.newToken|New token templates/design/app/i18n/ar-SA|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/de-DE|designEditor.askAgent|Ask agent +templates/design/app/i18n/de-DE|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/de-DE|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/de-DE|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/de-DE|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/de-DE|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/de-DE|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/de-DE|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/de-DE|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/de-DE|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/de-DE|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/de-DE|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/de-DE|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/de-DE|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/de-DE|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/de-DE|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/de-DE|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/de-DE|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/de-DE|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/de-DE|designEditor.tokens.add|Add token @@ -248,6 +280,22 @@ templates/design/app/i18n/de-DE|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/de-DE|designEditor.tokens.newToken|New token templates/design/app/i18n/de-DE|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/es-ES|designEditor.askAgent|Ask agent +templates/design/app/i18n/es-ES|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/es-ES|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/es-ES|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/es-ES|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/es-ES|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/es-ES|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/es-ES|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/es-ES|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/es-ES|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/es-ES|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/es-ES|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/es-ES|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/es-ES|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/es-ES|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/es-ES|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/es-ES|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/es-ES|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/es-ES|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/es-ES|designEditor.tokens.add|Add token @@ -258,6 +306,22 @@ templates/design/app/i18n/es-ES|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/es-ES|designEditor.tokens.newToken|New token templates/design/app/i18n/es-ES|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/fr-FR|designEditor.askAgent|Ask agent +templates/design/app/i18n/fr-FR|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/fr-FR|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/fr-FR|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/fr-FR|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/fr-FR|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/fr-FR|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/fr-FR|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/fr-FR|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/fr-FR|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/fr-FR|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/fr-FR|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/fr-FR|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/fr-FR|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/fr-FR|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/fr-FR|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/fr-FR|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/fr-FR|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/fr-FR|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/fr-FR|designEditor.tokens.add|Add token @@ -268,6 +332,22 @@ templates/design/app/i18n/fr-FR|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/fr-FR|designEditor.tokens.newToken|New token templates/design/app/i18n/fr-FR|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/hi-IN|designEditor.askAgent|Ask agent +templates/design/app/i18n/hi-IN|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/hi-IN|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/hi-IN|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/hi-IN|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/hi-IN|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/hi-IN|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/hi-IN|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/hi-IN|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/hi-IN|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/hi-IN|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/hi-IN|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/hi-IN|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/hi-IN|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/hi-IN|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/hi-IN|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/hi-IN|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/hi-IN|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/hi-IN|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/hi-IN|designEditor.tokens.add|Add token @@ -278,6 +358,22 @@ templates/design/app/i18n/hi-IN|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/hi-IN|designEditor.tokens.newToken|New token templates/design/app/i18n/hi-IN|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/ja-JP|designEditor.askAgent|Ask agent +templates/design/app/i18n/ja-JP|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/ja-JP|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/ja-JP|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/ja-JP|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/ja-JP|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/ja-JP|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/ja-JP|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/ja-JP|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/ja-JP|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/ja-JP|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/ja-JP|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/ja-JP|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/ja-JP|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/ja-JP|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/ja-JP|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/ja-JP|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/ja-JP|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/ja-JP|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/ja-JP|designEditor.tokens.add|Add token @@ -288,6 +384,22 @@ templates/design/app/i18n/ja-JP|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/ja-JP|designEditor.tokens.newToken|New token templates/design/app/i18n/ja-JP|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/ko-KR|designEditor.askAgent|Ask agent +templates/design/app/i18n/ko-KR|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/ko-KR|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/ko-KR|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/ko-KR|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/ko-KR|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/ko-KR|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/ko-KR|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/ko-KR|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/ko-KR|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/ko-KR|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/ko-KR|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/ko-KR|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/ko-KR|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/ko-KR|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/ko-KR|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/ko-KR|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/ko-KR|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/ko-KR|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/ko-KR|designEditor.tokens.add|Add token @@ -298,6 +410,22 @@ templates/design/app/i18n/ko-KR|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/ko-KR|designEditor.tokens.newToken|New token templates/design/app/i18n/ko-KR|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/pt-BR|designEditor.askAgent|Ask agent +templates/design/app/i18n/pt-BR|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/pt-BR|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/pt-BR|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/pt-BR|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/pt-BR|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/pt-BR|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/pt-BR|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/pt-BR|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/pt-BR|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/pt-BR|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/pt-BR|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/pt-BR|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/pt-BR|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/pt-BR|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/pt-BR|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/pt-BR|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/pt-BR|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/pt-BR|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/pt-BR|designEditor.tokens.add|Add token @@ -308,6 +436,22 @@ templates/design/app/i18n/pt-BR|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/pt-BR|designEditor.tokens.newToken|New token templates/design/app/i18n/pt-BR|designEditor.tokens.refresh|Refresh tokens templates/design/app/i18n/zh-CN|designEditor.askAgent|Ask agent +templates/design/app/i18n/zh-CN|designEditor.import.figUploadDescriptionShort|Local import — no Figma API quota used. Embedded images included. +templates/design/app/i18n/zh-CN|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/zh-CN|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/zh-CN|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/zh-CN|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/zh-CN|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/zh-CN|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/zh-CN|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. +templates/design/app/i18n/zh-CN|designEditor.import.figmaPasteBodyImages|Image fills may be missing without a token. Upload the .fig file to include embedded images. +templates/design/app/i18n/zh-CN|designEditor.import.figmaPasteBodyUnlimited|Works without a Figma token — geometry, layout, and text import immediately. +templates/design/app/i18n/zh-CN|designEditor.import.rateLimitAlternatives|No-quota alternatives: +templates/design/app/i18n/zh-CN|designEditor.import.rateLimitGeneric|Figma rate-limited this import. The quota resets automatically. +templates/design/app/i18n/zh-CN|designEditor.import.rateLimitTitle|Figma paused this import +templates/design/app/i18n/zh-CN|designEditor.import.rateLimitUpgrade|View Figma plan options → +templates/design/app/i18n/zh-CN|designEditor.import.rateLimitUseFig|Upload .fig — unlimited +templates/design/app/i18n/zh-CN|designEditor.import.rateLimitUsePaste|Paste from Figma — unlimited templates/design/app/i18n/zh-CN|designEditor.toasts.componentCreateFailed|Could not create component templates/design/app/i18n/zh-CN|designEditor.toasts.componentCreated|Component created templates/design/app/i18n/zh-CN|designEditor.tokens.add|Add token @@ -317,3 +461,10 @@ templates/design/app/i18n/zh-CN|designEditor.tokens.emptyHint|Add design tokens templates/design/app/i18n/zh-CN|designEditor.tokens.empty|No tokens yet templates/design/app/i18n/zh-CN|designEditor.tokens.newToken|New token templates/design/app/i18n/zh-CN|designEditor.tokens.refresh|Refresh tokens +templates/design/app/i18n/zh-TW|designEditor.import.figmaHydrationChooseFig|Fill images from .fig +templates/design/app/i18n/zh-TW|designEditor.import.figmaHydrationFigError|Couldn't read images from that .fig file. +templates/design/app/i18n/zh-TW|designEditor.import.figmaHydrationFigOption|Fastest — pulls every image straight from the file. No token, no rate limits. +templates/design/app/i18n/zh-TW|designEditor.import.figmaHydrationFigTitle|Have the original .fig file? +templates/design/app/i18n/zh-TW|designEditor.import.figmaHydrationInvalidFig|Choose a .fig file exported from Figma. +templates/design/app/i18n/zh-TW|designEditor.import.figmaHydrationOrToken|Or fetch from Figma +templates/design/app/i18n/zh-TW|designEditor.import.figmaHydrationRateLimit|Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above. diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 439afaef36..a074bae601 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -7038,15 +7038,51 @@ export const editorChromeBridgeScript: string = `"use strict"; } var content = getFigmaClipboardContent(e.clipboardData); clearPendingPlainPasteHotkey(); - if (!content) return; - stopNativeInteraction(e); - window.parent.postMessage( - { - type: "figma-clipboard-paste", - content - }, - "*" - ); + if (content) { + stopNativeInteraction(e); + window.parent.postMessage( + { type: "figma-clipboard-paste", content }, + "*" + ); + return; + } + var imageFiles = Array.from(e.clipboardData?.items ?? []).filter(function(item) { + return item.kind === "file" && item.type.startsWith("image/"); + }).map(function(item) { + return item.getAsFile(); + }).filter(function(f) { + return Boolean(f); + }); + if (imageFiles.length > 0) { + stopNativeInteraction(e); + var readPromises = imageFiles.map(function(file) { + return new Promise(function(resolve) { + var reader = new FileReader(); + reader.onload = function() { + resolve({ + dataUrl: typeof reader.result === "string" ? reader.result : "", + type: file.type, + name: file.name + }); + }; + reader.onerror = function() { + resolve(null); + }; + reader.readAsDataURL(file); + }); + }); + void Promise.all(readPromises).then(function(results) { + var valid = results.filter(function(r) { + return r && r.dataUrl; + }); + if (valid.length > 0) { + window.parent.postMessage( + { type: "canvas-image-paste", files: valid }, + "*" + ); + } + }); + } }, true ); diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 86c3b316fe..cca9d0de82 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -109,8 +109,11 @@ ladder. `design-systems` skill's "Import from Figma" section. - Uploading a raw `.fig` file in the Design editor's Import panel decodes the container/Kiwi document locally into editable screens — no Builder - connection needed — and is scoped to screens only; it never creates or - updates a design system. This is separate from uploading `.fig` on the + connection needed — and accepts an optional Figma frame URL alongside the + file. When the URL contains a `node-id`, only that frame or its containing + top-level frame is imported; a mismatch falls back to all frames. This path + uses no Figma REST API quota and is scoped to screens only; it never creates + or updates a design system. This is separate from uploading `.fig` on the Design System Setup page, which still indexes tokens/brand-kit data through Builder and does not parse `.fig` locally. See the `design-systems` skill for both paths. @@ -118,9 +121,19 @@ ladder. `figmeta.selectedNodeData`; `import-figma-clipboard` uses those before any heuristic matching and supports multi-selection. Clipboard metadata is not a public Figma contract, so a copied frame link remains the stable exact path - if Figma changes that field. Without a token, current Figma's binary-only - clipboard has no browser-readable HTML fallback; give setup guidance instead - of claiming a successful import. + if Figma changes that field. Without a token, `import-figma-clipboard` falls + back to a local Kiwi binary decode: geometry, auto-layout, text, solid fills, + and strokes are editable immediately; image fills become annotated + placeholders (`data-figma-image-ref=""`) that can be filled in later two + ways. (1) Token-free: upload the original `.fig` — the paste-result dialog's + "Fill images from .fig" option, or `hydrateFileIds` on the `.fig` upload route + (`/api/import-design-file`) — which matches each placeholder's SHA-1 hash to + the `.fig`'s embedded `images/` bytes, mirrors them to durable storage, and + needs no Figma API. (2) With a token: `hydrate-figma-paste-images` resolves the + same placeholders through Figma's REST image endpoint. This is not a + full-fidelity import — report which image fills are still unresolved and offer + to hydrate them. Clipboard paste is the fast no-quota path; the original `.fig` + is what carries the real image bytes for both upload and paste-hydration. - For "what's in this Figma file/frame?" or "show me a screenshot of this frame" without importing anything, use `get-figma-design-context` — no `nodeId` lists pages/top-level frames (like the official Figma MCP's diff --git a/templates/design/FIGMA_INTEROPERABILITY.md b/templates/design/FIGMA_INTEROPERABILITY.md index 17c6d9ae21..c3ecc0f653 100644 --- a/templates/design/FIGMA_INTEROPERABILITY.md +++ b/templates/design/FIGMA_INTEROPERABILITY.md @@ -15,18 +15,19 @@ product must report them instead of claiming success. ## Capability matrix -| Workflow or feature | Current behavior | Fidelity | Required verification | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| Figma frame URL / file key | Reads the exact node through `file_content:read`, converts it to a new Design screen, mirrors expiring images into durable storage, and returns a per-node fidelity report. | Mixed; see node matrix below. | REST fixture, authenticated file, screenshot comparison. | -| Figma URL without a node id | Imports the first top-level object on the first page. A specific frame URL is recommended for deterministic results. | Same as node import. | Multi-page and empty-page fixtures. | -| Figma branch URL | Uses the branch key and imports that branch's node. | Same as node import. | Main/branch pair with divergent content. | -| Figma clipboard to Design | Uses private `figmeta.selectedNodeData` ids when present, then the same REST converter. Older visible HTML is only a fallback. Binary-only clipboard data without ids/token is not decoded. | Exact selection identity while Figma's private metadata shape remains compatible; node fidelity is mixed. | Real Chrome copy from single, multi, nested, and 100+ node selections. | -| `.fig` upload | Bounded best-effort decoding of known Kiwi/ZIP variants into editable HTML. Embedded images are moved to durable storage. | Experimental. The format is proprietary and has no compatibility guarantee. | Corpus of real files from multiple Figma versions; never only generated containers. | -| Design to Figma clipboard | Copies an SVG built from the live rendered DOM. Figma imports supported SVG primitives as editable layers. | Visual/vector handoff, not a native semantic round trip. Auto layout, variables, components, prototypes, HTML state, and code identity are not recreated by SVG. | Paste into real Figma and inspect layer types, text, images, effects, clipping, and bounds. | -| Design SVG download | Same conversion as clipboard, with a server-render fallback when a live DOM is unavailable. | Same SVG limits; the export report lists approximations and omissions. | Live and server paths, selected layer and whole screen. | -| Native Design to Figma write | Use Figma's official MCP `use_figma` write-to-canvas path when the connected client/account supports it. | Native Figma structures, subject to Figma MCP beta limitations and permissions. | Full-seat/edit-permission account and a real destination file. | -| `.fig` download | Not supported. There is no documented public `.fig` authoring contract. | Unsupported. | Do not label SVG/ZIP as `.fig`. | -| Open-ended Figma chat | Provider catalog/docs/request expose the REST surface allowed by the user's scoped token; non-read calls require approval. Native canvas authoring requires official Figma MCP, not a personal access token alone. | Endpoint-dependent. | Read scopes, expired/revoked token, rate limiting, Enterprise-variable permissions, MCP connection. | +| Workflow or feature | Current behavior | Fidelity | Required verification | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Figma frame URL / file key | Reads the exact node through `file_content:read`, converts it to a new Design screen, mirrors expiring images into durable storage, and returns a per-node fidelity report. | Mixed; see node matrix below. | REST fixture, authenticated file, screenshot comparison. | +| Figma URL without a node id | Imports the first top-level object on the first page. A specific frame URL is recommended for deterministic results. | Same as node import. | Multi-page and empty-page fixtures. | +| Figma branch URL | Uses the branch key and imports that branch's node. | Same as node import. | Main/branch pair with divergent content. | +| Figma clipboard to Design | Uses private `figmeta.selectedNodeData` ids when present, then the same REST converter. With a token: full fidelity matching `import-figma-frame`. Without a token: local Kiwi binary decode — geometry, auto-layout, text, solid fills, and strokes are editable; image fills are stamped with `data-figma-image-ref=""` placeholders and can be resolved retroactively two ways: token-free by uploading the original `.fig` (the paste dialog's "Fill images from .fig" / `hydrateFileIds` on the `.fig` upload route), which matches each placeholder hash to the `.fig`'s embedded image bytes; or with a token via `hydrate-figma-paste-images`. | Exact selection identity while Figma's private metadata shape remains compatible; node fidelity is mixed. No-token imports resolve images retroactively — from the `.fig` (no quota) or a connected token. | Real Chrome copy from single, multi, nested, and 100+ node selections; token-less copy followed by `.fig` hydration and by deferred token connect, verifying image resolution both ways. | +| `.fig` upload | Bounded best-effort decoding of known Kiwi/ZIP variants into editable HTML. Embedded images are moved to durable storage. Optionally accepts a Figma frame URL: when its `node-id` matches the decoded file, Design imports only that top-level frame (or its ancestor for a nested node); a mismatch imports all frames with a warning. No Figma REST API calls are made. | Experimental. The format is proprietary and has no compatibility guarantee. | Corpus of real files from multiple Figma versions; never only generated containers. | +| `.fig` upload + frame URL | Accepts an optional Figma frame link. Normalizes the node-id and matches the decoded .fig GUID (`sessionID:localID`) to the matching top-level frame. Nested node IDs resolve to their top-level frame. On mismatch, all frames are imported. No Figma API quota is used. | Best-effort. The GUID mapping is reliable for frames in the same file but undocumented — test with real files before relying on it. | Real .fig/frame-link pairs from Figma across file versions. | +| Design to Figma clipboard | Copies an SVG built from the live rendered DOM. Figma imports supported SVG primitives as editable layers. | Visual/vector handoff, not a native semantic round trip. Auto layout, variables, components, prototypes, HTML state, and code identity are not recreated by SVG. | Paste into real Figma and inspect layer types, text, images, effects, clipping, and bounds. | +| Design SVG download | Same conversion as clipboard, with a server-render fallback when a live DOM is unavailable. | Same SVG limits; the export report lists approximations and omissions. | Live and server paths, selected layer and whole screen. | +| Native Design to Figma write | Use Figma's official MCP `use_figma` write-to-canvas path when the connected client/account supports it. | Native Figma structures, subject to Figma MCP beta limitations and permissions. | Full-seat/edit-permission account and a real destination file. | +| `.fig` download | Not supported. There is no documented public `.fig` authoring contract. | Unsupported. | Do not label SVG/ZIP as `.fig`. | +| Open-ended Figma chat | Provider catalog/docs/request expose the REST surface allowed by the user's scoped token; non-read calls require approval. Native canvas authoring requires official Figma MCP, not a personal access token alone. | Endpoint-dependent. | Read scopes, expired/revoked token, rate limiting, Enterprise-variable permissions, MCP connection. | ## REST node conversion matrix @@ -54,6 +55,18 @@ product must report them instead of claiming success. | Videos, emoji paints, FigJam-only and unknown node types | Rendered fallback when Figma can render the node. | Visual fallback only. | | Hidden or 0%-opacity subtrees | Omitted without downloading their assets. | Visually exact and avoids unnecessary work. | +## Figma REST rate limits + +- Viewer and Collab seats may receive up to 6 Tier 1 requests per month for + file, node, and image endpoints. The actual limit may be lower. +- Dev and Full seats receive 10–20 Tier 1 requests per minute, depending on the + resource's plan. +- On HTTP 429, Figma returns `Retry-After` in seconds plus + `X-Figma-Plan-Tier`, `X-Figma-Rate-Limit-Type`, and + `X-Figma-Upgrade-Link` metadata. +- Figma does not expose a requests-remaining counter. +- Clipboard paste and `.fig` upload are zero-quota local alternatives. + ## Safety and scale limits - REST responses are capped at 4 MB. Multi-selection requests split diff --git a/templates/design/actions/hydrate-figma-paste-images.spec.ts b/templates/design/actions/hydrate-figma-paste-images.spec.ts new file mode 100644 index 0000000000..eb9ede9c49 --- /dev/null +++ b/templates/design/actions/hydrate-figma-paste-images.spec.ts @@ -0,0 +1,352 @@ +/** + * hydrate-figma-paste-images.spec.ts + * + * Covers: + * - collectImageRefHashes: scan HTML for data-figma-image-ref hashes + * - hydrateImageRefsInHtml: replace url("about:blank") with real URLs in order + * - Action routing: no-refs early return, full resolution, partial resolution, + * no-figmaFileKey guard, Figma-returns-empty guard + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + db: { + select: vi.fn(), + from: vi.fn(), + innerJoin: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + }, + assertAccess: vi.fn(), + accessFilter: vi.fn(() => "access-filter-sentinel"), + readLiveSourceFile: vi.fn(), + writeInlineSourceFile: vi.fn(), + agentEnterDocument: vi.fn(), + agentLeaveDocument: vi.fn(), + resolveImageFillRefs: vi.fn(), + mutateDesignData: vi.fn(), +})); + +vi.mock("@agent-native/core/sharing", () => ({ + assertAccess: mocks.assertAccess, + accessFilter: mocks.accessFilter, +})); + +vi.mock("@agent-native/core/collab", () => ({ + agentEnterDocument: mocks.agentEnterDocument, + agentLeaveDocument: mocks.agentLeaveDocument, +})); + +vi.mock("../server/source-workspace.js", () => ({ + readLiveSourceFile: mocks.readLiveSourceFile, + writeInlineSourceFile: mocks.writeInlineSourceFile, +})); + +vi.mock("../server/lib/figma-node-import.js", () => ({ + resolveImageFillRefs: mocks.resolveImageFillRefs, +})); + +vi.mock("../server/lib/design-data-mutation.js", () => ({ + mutateDesignData: mocks.mutateDesignData, +})); + +// db query chain builder that always resolves to whatever rows array is set +let dbRows: unknown[] = []; +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + limit: () => Promise.resolve(dbRows), + }), + }), + }), + }), + }), + schema: { + designFiles: { + id: "id", + designId: "designId", + filename: "filename", + fileType: "fileType", + content: "content", + }, + designs: { id: "id", data: "data" }, + designShares: {}, + }, +})); + +import { + collectImageRefHashes, + hydrateImageRefsInHtml, +} from "./hydrate-figma-paste-images.js"; +import action from "./hydrate-figma-paste-images.js"; + +// --------------------------------------------------------------------------- +// Pure HTML helpers +// --------------------------------------------------------------------------- + +describe("collectImageRefHashes", () => { + it("returns empty array for HTML with no data-figma-image-ref attrs", () => { + const html = '
hello
'; + expect(collectImageRefHashes(html)).toEqual([]); + }); + + it("collects hashes from a single element", () => { + const html = + '
x
'; + expect(collectImageRefHashes(html)).toEqual(["abc123"]); + }); + + it("collects multiple hashes from a single element", () => { + const html = + '
x
'; + expect(collectImageRefHashes(html)).toEqual(["hash1", "hash2"]); + }); + + it("deduplicates the same hash across multiple elements", () => { + const html = [ + '
', + '', + ].join(""); + const hashes = collectImageRefHashes(html); + expect(hashes).toEqual(["shared", "unique"]); + }); + + it("ignores closing tags and unrelated attributes", () => { + const html = + '
x'; + expect(collectImageRefHashes(html)).toEqual(["realHash"]); + }); +}); + +describe("hydrateImageRefsInHtml", () => { + it("leaves HTML unchanged when no data-figma-image-ref attrs are present", () => { + const html = '
text
'; + const { + html: out, + resolved, + missing, + } = hydrateImageRefsInHtml(html, new Map()); + expect(out).toBe(html); + expect(resolved).toBe(0); + expect(missing).toEqual([]); + }); + + it("replaces url placeholder with durable URL and removes the attr when fully resolved", () => { + const html = + '
x
'; + const urls = new Map([["abc123", "https://cdn.example.com/img.png"]]); + + const { html: out, resolved, missing } = hydrateImageRefsInHtml(html, urls); + + expect(resolved).toBe(1); + expect(missing).toEqual([]); + expect(out).not.toContain("data-figma-image-ref"); + expect(out).toContain("url('https://cdn.example.com/img.png')"); + expect(out).not.toContain("about:blank"); + }); + + it("replaces multiple url placeholders in order matching the hashes", () => { + const html = + "
x
"; + const urls = new Map([ + ["h1", "https://cdn.example.com/img1.png"], + ["h2", "https://cdn.example.com/img2.png"], + ]); + + const { html: out, resolved } = hydrateImageRefsInHtml(html, urls); + + expect(resolved).toBe(2); + expect(out).toContain("url('https://cdn.example.com/img1.png')"); + expect(out).toContain("url('https://cdn.example.com/img2.png')"); + expect(out).not.toContain("data-figma-image-ref"); + }); + + it("keeps placeholder and preserves attr for unresolved hashes", () => { + const html = + '
x
'; + + const { + html: out, + resolved, + missing, + } = hydrateImageRefsInHtml(html, new Map()); + + expect(resolved).toBe(0); + expect(missing).toEqual(["abc123"]); + expect(out).toContain("data-figma-image-ref"); + expect(out).toContain("about:blank"); + }); + + it("partially resolves: updates attr with remaining hashes for unresolved", () => { + const html = + "
x
"; + const urls = new Map([["h1", "https://cdn.example.com/img1.png"]]); + + const { html: out, resolved, missing } = hydrateImageRefsInHtml(html, urls); + + expect(resolved).toBe(1); + expect(missing).toEqual(["h2"]); + expect(out).toContain('data-figma-image-ref="h2"'); + expect(out).toContain("url('https://cdn.example.com/img1.png')"); + expect(out).toContain("url('about:blank')"); + }); + + it("encodes & in durable URLs as &", () => { + const html = + '
x
'; + const urls = new Map([["abc", "https://cdn.example.com/img?a=1&b=2"]]); + + const { html: out } = hydrateImageRefsInHtml(html, urls); + expect(out).toContain("url('https://cdn.example.com/img?a=1&b=2')"); + }); +}); + +// --------------------------------------------------------------------------- +// Action integration (with mocks) +// --------------------------------------------------------------------------- + +const FILE_KEY = "testFileKey123"; + +const SCREEN_METADATA_ROW = { + id: "file-1", + designId: "design-1", + filename: "Screen.html", + fileType: "html", + content: + '
x
', + designData: JSON.stringify({ + screenMetadata: { + "file-1": { + figmaFileKey: FILE_KEY, + unresolvedImageRefs: ["abc123"], + }, + }, + }), +}; + +describe("hydrate-figma-paste-images action", () => { + beforeEach(() => { + vi.clearAllMocks(); + dbRows = []; + mocks.assertAccess.mockResolvedValue(undefined); + mocks.writeInlineSourceFile.mockResolvedValue({ + versionHash: "new-hash", + changed: true, + updatedAt: "2025-01-01T00:00:00Z", + }); + mocks.mutateDesignData.mockImplementation( + async ({ mutate, isApplied }: any) => { + const data = mutate( + { + screenMetadata: { + "file-1": { + figmaFileKey: FILE_KEY, + unresolvedImageRefs: ["abc123"], + }, + }, + }, + { updatedAt: "" }, + ); + expect(isApplied(data)).toBe(true); + return { data, updatedAt: "" }; + }, + ); + }); + + it("returns early when no image ref attrs found in live content", async () => { + dbRows = [{ ...SCREEN_METADATA_ROW, content: "
no refs here
" }]; + mocks.readLiveSourceFile.mockResolvedValue({ + content: "
no refs here
", + versionHash: "v1", + }); + + const result = await action.run({ fileId: "file-1" }); + + expect(result).toMatchObject({ resolved: 0, missing: 0, skipped: 0 }); + expect(mocks.resolveImageFillRefs).not.toHaveBeenCalled(); + expect(mocks.writeInlineSourceFile).not.toHaveBeenCalled(); + }); + + it("resolves all image refs and writes the updated HTML", async () => { + dbRows = [SCREEN_METADATA_ROW]; + mocks.readLiveSourceFile.mockResolvedValue({ + content: SCREEN_METADATA_ROW.content, + versionHash: "v1", + }); + mocks.resolveImageFillRefs.mockResolvedValue( + new Map([["abc123", "https://cdn.example.com/img.png"]]), + ); + + const result = await action.run({ fileId: "file-1" }); + + expect(result).toMatchObject({ resolved: 1, missing: 0 }); + expect(mocks.resolveImageFillRefs).toHaveBeenCalledWith(FILE_KEY, [ + "abc123", + ]); + const writtenContent = mocks.writeInlineSourceFile.mock.calls[0]![0] + .content as string; + expect(writtenContent).toContain("https://cdn.example.com/img.png"); + expect(writtenContent).not.toContain("about:blank"); + expect(writtenContent).not.toContain("data-figma-image-ref"); + expect(mocks.mutateDesignData).toHaveBeenCalledTimes(1); + }); + + it("reports missing count and skips write when Figma returns no URLs", async () => { + dbRows = [SCREEN_METADATA_ROW]; + mocks.readLiveSourceFile.mockResolvedValue({ + content: SCREEN_METADATA_ROW.content, + versionHash: "v1", + }); + mocks.resolveImageFillRefs.mockResolvedValue(new Map()); + + const result = await action.run({ fileId: "file-1" }); + + expect(result).toMatchObject({ resolved: 0, missing: 1 }); + expect(mocks.writeInlineSourceFile).not.toHaveBeenCalled(); + expect(mocks.mutateDesignData).not.toHaveBeenCalled(); + }); + + it("throws when no figmaFileKey is in screenMetadata for the file", async () => { + dbRows = [ + { + ...SCREEN_METADATA_ROW, + designData: JSON.stringify({ + screenMetadata: { "file-1": { title: "Screen" } }, + }), + }, + ]; + + await expect(action.run({ fileId: "file-1" })).rejects.toThrow( + "No Figma file key found", + ); + expect(mocks.resolveImageFillRefs).not.toHaveBeenCalled(); + }); + + it("throws when file is not found", async () => { + dbRows = []; + + await expect(action.run({ fileId: "missing-file" })).rejects.toThrow( + "File not found", + ); + }); + + it("calls agentEnterDocument/agentLeaveDocument around the write", async () => { + dbRows = [SCREEN_METADATA_ROW]; + mocks.readLiveSourceFile.mockResolvedValue({ + content: SCREEN_METADATA_ROW.content, + versionHash: "v1", + }); + mocks.resolveImageFillRefs.mockResolvedValue( + new Map([["abc123", "https://cdn.example.com/img.png"]]), + ); + + await action.run({ fileId: "file-1" }); + + expect(mocks.agentEnterDocument).toHaveBeenCalledWith("file-1"); + expect(mocks.agentLeaveDocument).toHaveBeenCalledWith("file-1"); + }); +}); diff --git a/templates/design/actions/hydrate-figma-paste-images.ts b/templates/design/actions/hydrate-figma-paste-images.ts new file mode 100644 index 0000000000..5dd0e9beaf --- /dev/null +++ b/templates/design/actions/hydrate-figma-paste-images.ts @@ -0,0 +1,115 @@ +/** + * Retroactively resolve unresolved Figma image fills for a screen that was + * imported via the local-kiwi clipboard path, using Figma's REST image + * endpoint (requires a saved FIGMA_ACCESS_TOKEN). + * + * When a Figma paste is imported without an access token, IMAGE fills render as + * `url("about:blank")` placeholders. The originating elements are annotated + * with `data-figma-image-ref="hash1 hash2 …"` so this action can find them + * without a full re-parse. Each Nth hash in the attribute maps to the Nth + * `url("about:blank")` occurrence in the element's style attribute. + * + * The read → collect → resolve → persist pipeline lives in + * `server/lib/figma-image-hydration.ts` and is shared with the token-free + * `.fig` hydration path (see `hydrateFileImagesFromFig`). This action only + * supplies the REST resolver. + */ + +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { + applyHydration, + collectImageRefHashes, + hydrateImageRefsInHtml, + loadHydratableFile, +} from "../server/lib/figma-image-hydration.js"; +import { resolveImageFillRefs } from "../server/lib/figma-node-import.js"; +import { readLiveSourceFile } from "../server/source-workspace.js"; + +// Re-exported for direct unit testing of the pure HTML helpers. +export { collectImageRefHashes, hydrateImageRefsInHtml }; + +export default defineAction({ + description: + 'Retroactively resolve unresolved Figma image fills for a screen imported via the no-token local-kiwi path (import-figma-clipboard returned strategy:"localKiwi"). Requires a saved FIGMA_ACCESS_TOKEN. Fetches CDN URLs from Figma\'s /files/:key/images endpoint, mirrors them to durable blob storage, and replaces every url("about:blank") placeholder stamped by the local-kiwi decoder with the real durable URL. Fully resolved elements have their data-figma-image-ref annotation removed; partially resolved elements retain it for a future retry. Returns resolved/missing/skipped counts. Call after connecting Figma in Settings to fill in images from a no-token paste. For a token-free path when the original .fig file is available, upload the .fig with hydrateFileIds via /api/import-design-file instead.', + schema: z.object({ + fileId: z + .string() + .describe( + "ID of the design_files row to hydrate. Use the fileId returned by import-figma-clipboard.", + ), + }), + run: async ({ fileId }) => { + const { workspaceFile, designId, figmaFileKey } = + await loadHydratableFile(fileId); + + if (!figmaFileKey) { + throw new Error( + `No Figma file key found for file ${fileId}. This file may not have been imported via a Figma clipboard paste.`, + ); + } + + const live = await readLiveSourceFile(workspaceFile); + + const hashesToResolve = collectImageRefHashes(live.content); + if (hashesToResolve.length === 0) { + return { + fileId, + resolved: 0, + missing: 0, + skipped: 0, + message: "No unresolved image refs found in this file.", + }; + } + + let resolvedUrls: Map; + try { + resolvedUrls = await resolveImageFillRefs(figmaFileKey, hashesToResolve); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/quota cooldown|provider.*quota/i.test(msg)) { + const retryAfterSeconds = + (err as { retryAfterSeconds?: number }).retryAfterSeconds ?? 0; + const waitHint = + retryAfterSeconds > 0 + ? retryAfterSeconds >= 60 + ? `${Math.ceil(retryAfterSeconds / 60)} min` + : `${retryAfterSeconds}s` + : "~1 min"; + throw Object.assign( + new Error(`Figma API rate limited — try again in ${waitHint}.`), + { statusCode: 429 }, + ); + } + throw err; + } + + if (resolvedUrls.size === 0) { + return { + fileId, + resolved: 0, + missing: hashesToResolve.length, + skipped: 0, + message: `Figma returned no image URLs for ${hashesToResolve.length} hash${hashesToResolve.length === 1 ? "" : "es"}. The images may have been deleted from the Figma file or the access token lacks file_content:read scope.`, + }; + } + + const result = await applyHydration({ + file: workspaceFile, + designId, + fileId, + liveContent: live.content, + liveVersionHash: live.versionHash, + requestedHashes: hashesToResolve, + resolvedUrls, + }); + + return { + fileId, + resolved: result.resolved, + missing: result.missing, + skipped: result.skipped, + }; + }, +}); diff --git a/templates/design/actions/import-figma-clipboard.spec.ts b/templates/design/actions/import-figma-clipboard.spec.ts index 5582c5931c..c1c32dd34a 100644 --- a/templates/design/actions/import-figma-clipboard.spec.ts +++ b/templates/design/actions/import-figma-clipboard.spec.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ resolveImportDesignId: vi.fn(), ssrfSafeFetch: vi.fn(), uploadFile: vi.fn(), + importFigmaClipboardFromBuffer: vi.fn(), })); vi.mock("@agent-native/core/extensions/url-safety", () => ({ @@ -34,6 +35,10 @@ vi.mock("../server/lib/import-design-files.js", () => ({ saveImportedDesignFiles: mocks.saveImportedDesignFiles, })); +vi.mock("../server/lib/figma-clipboard-local-decode.js", () => ({ + importFigmaClipboardFromBuffer: mocks.importFigmaClipboardFromBuffer, +})); + import action from "./import-figma-clipboard.js"; function jsonEnvelope(json: unknown) { @@ -446,4 +451,131 @@ describe("import-figma-clipboard", () => { } as any), ).rejects.toThrow(/could not be parsed/i); }); + + describe("local-kiwi path (no token)", () => { + const FAKE_BUFFER_BASE64 = + Buffer.from("fake-kiwi-bytes").toString("base64"); + + beforeEach(() => { + mocks.executeProviderApiRequest.mockRejectedValue( + new Error("figma credential not configured. Tried: FIGMA_ACCESS_TOKEN"), + ); + }); + + it("decodes the clipboard buffer and returns localKiwi strategy when token is missing", async () => { + mocks.importFigmaClipboardFromBuffer.mockResolvedValue({ + files: [ + { + filename: "Frame.html", + fileType: "html", + content: "
frame
", + source: { + sourceType: "figma-clipboard-local-kiwi", + figmaFileKey: FILE_KEY, + }, + }, + ], + warnings: [], + unresolvedImageRefs: [], + stats: { + sourceKind: "figma-clipboard-local-kiwi", + format: "kiwi", + frameCount: 1, + nodeCount: 5, + unresolvedImageCount: 0, + }, + }); + + const result = await action.run({ + figmetaFileKey: FILE_KEY, + selectedNodeIds: ["1:1"], + clipboardHtml: CLIPBOARD_HTML_CURRENT_BINARY_ONLY, + clipboardBuffer: FAKE_BUFFER_BASE64, + } as any); + + expect(result.strategy).toBe("localKiwi"); + expect(result.figmaApiKeyMissing).toBe(true); + expect(result.unresolvedImages).toBe(0); + expect(result.guidance).toMatch(/local decode/i); + expect(mocks.importFigmaClipboardFromBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + bufferBase64: FAKE_BUFFER_BASE64, + fileKey: FILE_KEY, + }), + ); + expect(mocks.saveImportedDesignFiles).toHaveBeenCalledWith( + expect.objectContaining({ sourceType: "figma-clipboard-local-kiwi" }), + ); + }); + + it("reports unresolvedImages count and connect guidance when images are unresolved", async () => { + mocks.importFigmaClipboardFromBuffer.mockResolvedValue({ + files: [ + { + filename: "Frame.html", + fileType: "html", + content: '
frame
', + source: { + sourceType: "figma-clipboard-local-kiwi", + figmaFileKey: FILE_KEY, + unresolvedImageRefs: ["abc123"], + }, + }, + ], + warnings: ["1 image could not be loaded without a Figma access token."], + unresolvedImageRefs: ["abc123"], + stats: { + sourceKind: "figma-clipboard-local-kiwi", + format: "kiwi", + frameCount: 1, + nodeCount: 3, + unresolvedImageCount: 1, + }, + }); + + const result = await action.run({ + figmetaFileKey: FILE_KEY, + selectedNodeIds: ["1:1"], + clipboardHtml: CLIPBOARD_HTML_CURRENT_BINARY_ONLY, + clipboardBuffer: FAKE_BUFFER_BASE64, + } as any); + + expect(result.strategy).toBe("localKiwi"); + expect(result.unresolvedImages).toBe(1); + expect(result.fidelityReport.unresolvedImages).toBe(1); + expect(result.guidance).toMatch(/Figma access token/i); + expect(result.warnings).toContainEqual( + expect.stringMatching(/could not be loaded/i), + ); + }); + + it("falls through to htmlFallback when local decode fails and no visible HTML is present", async () => { + mocks.importFigmaClipboardFromBuffer.mockRejectedValue( + new Error("No editable frames found"), + ); + + const result = await action.run({ + figmetaFileKey: FILE_KEY, + selectedNodeIds: ["1:1"], + clipboardHtml: CLIPBOARD_HTML_CURRENT_BINARY_ONLY, + clipboardBuffer: FAKE_BUFFER_BASE64, + } as any); + + expect(result.strategy).toBe("htmlFallback"); + expect(result.files).toEqual([]); + expect(result.figmaApiKeyMissing).toBe(true); + }); + + it("does not attempt local decode when no buffer is provided", async () => { + const result = await action.run({ + figmetaFileKey: FILE_KEY, + selectedNodeIds: ["1:1"], + clipboardHtml: CLIPBOARD_HTML_CURRENT_BINARY_ONLY, + // no clipboardBuffer + } as any); + + expect(result.strategy).toBe("htmlFallback"); + expect(mocks.importFigmaClipboardFromBuffer).not.toHaveBeenCalled(); + }); + }); }); diff --git a/templates/design/actions/import-figma-clipboard.ts b/templates/design/actions/import-figma-clipboard.ts index 0ed8f6fbc4..b39256aef8 100644 --- a/templates/design/actions/import-figma-clipboard.ts +++ b/templates/design/actions/import-figma-clipboard.ts @@ -1,6 +1,7 @@ import { defineAction } from "@agent-native/core"; import { z } from "zod"; +import { importFigmaClipboardFromBuffer } from "../server/lib/figma-clipboard-local-decode.js"; import { buildFigmaNodeCandidates, extractVisibleTexts, @@ -19,7 +20,13 @@ import { parseFigmaFileKey } from "../shared/figma-url.js"; const NODE_STRUCTURE_DEPTH = 3; -const CREDENTIAL_MISSING_RE = /credential not configured/i; +// Also matches a Figma 403 that occurs when the token is saved but lacks +// file_content:read scope — the validator only checks current_user:read. +const CREDENTIAL_MISSING_RE = + /credential not configured|figma.*request failed:.*403|figma.*request failed:.*forbidden/i; +// Transient errors should not block local-kiwi fallback when the buffer is present. +const TRANSIENT_ERROR_RE = + /quota cooldown|provider.*quota|rate.?limit|fetch failed|network.*error|timeout|ECONNRESET|ENOTFOUND|ERR_NETWORK/i; const DURABLE_STORAGE_REQUIRED_RE = /authenticated user so assets can be stored durably|could not store a Figma image durably|needs durable file storage/i; @@ -69,6 +76,13 @@ export default defineAction({ .describe( "Figma clipboard HTML used for fallback matching. When exact node ids are present, the client removes the large private data-buffer while retaining figmeta and visible HTML.", ), + clipboardBuffer: z + .string() + .max(15_000_000) + .optional() + .describe( + "Base64-encoded fig-kiwi binary from the clipboard's data-buffer. Present when the client used the local-kiwi strategy (no Figma access token). The server decodes this to build editable HTML from geometry, text, and fills without a REST call.", + ), originalName: z.string().optional(), }), run: async ({ @@ -77,6 +91,7 @@ export default defineAction({ selectedNodeIds, selectedNodeIdsTruncated, clipboardHtml, + clipboardBuffer, originalName, }) => { const fileKey = parseFigmaFileKey(figmetaFileKey); @@ -98,10 +113,8 @@ export default defineAction({ try { if (selectedNodeIds?.length) { const nodesById = await fetchFigmaNodes(fileKey, selectedNodeIds); - const { files, fidelityEntries } = await buildScreenFilesFromFigmaNodes( - fileKey, - nodesById, - ); + const { files, fidelityEntries, missingImageFillCount } = + await buildScreenFilesFromFigmaNodes(fileKey, nodesById); const saved = await saveImportedDesignFiles({ designId, sourceType: "figma-clipboard-rest", @@ -110,9 +123,15 @@ export default defineAction({ const selectionWarnings = selectedNodeIdsTruncated ? [SELECTION_TRUNCATED_GUIDANCE] : []; + const fillWarnings = + missingImageFillCount > 0 + ? [ + `${missingImageFillCount} image fill${missingImageFillCount === 1 ? "" : "s"} could not be fetched from Figma and were omitted. This can happen for deleted images or very large assets.`, + ] + : []; return { ...saved, - warnings: [...saved.warnings, ...selectionWarnings], + warnings: [...saved.warnings, ...selectionWarnings, ...fillWarnings], strategy: "restNodes" as const, figma: { fileKey, @@ -142,17 +161,22 @@ export default defineAction({ if (matchResult.status === "matched") { const nodeIds = matchResult.matches.map((match) => match.id); const nodesById = await fetchFigmaNodes(fileKey, nodeIds); - const { files, fidelityEntries } = await buildScreenFilesFromFigmaNodes( - fileKey, - nodesById, - ); + const { files, fidelityEntries, missingImageFillCount } = + await buildScreenFilesFromFigmaNodes(fileKey, nodesById); const saved = await saveImportedDesignFiles({ designId, sourceType: "figma-clipboard-rest", files, }); + const fillWarnings = + missingImageFillCount > 0 + ? [ + `${missingImageFillCount} image fill${missingImageFillCount === 1 ? "" : "s"} could not be fetched from Figma and were omitted.`, + ] + : []; return { ...saved, + warnings: [...(saved.warnings ?? []), ...fillWarnings], strategy: "restNodes" as const, figma: { fileKey, @@ -174,26 +198,67 @@ export default defineAction({ throw error; } figmaApiKeyMissing = CREDENTIAL_MISSING_RE.test(errorMessage); + const isTransient = TRANSIENT_ERROR_RE.test(errorMessage); if ( selectedNodeIds?.length && !parsedClipboard.fallbackHtml && - !figmaApiKeyMissing + !figmaApiKeyMissing && + (!isTransient || !clipboardBuffer) ) { // Exact ids prove this was a current Figma clipboard. With no visible - // fallback, a real REST/import failure must remain truthful and - // actionable rather than being mislabeled as a clipboard format that - // omitted ids. + // fallback, a permanent REST failure must surface as a real error rather + // than silently degrading. Transient errors fall through to local-kiwi + // only when a buffer is present to decode; without a buffer there is + // nothing to fall back to, so even transient errors must propagate. throw error; } if (!figmaApiKeyMissing) { - // A real (non-credential) REST failure — network error, revoked - // token, file access issue, etc. Still fall back to the honest - // clipboard preview rather than losing the paste entirely, but this - // isn't a "no confident match" case, so don't claim ambiguity. matchStatus = "error"; } } + // Local-kiwi fallback: decode the binary buffer when REST failed for any + // reason (missing token, 403, quota cooldown, network error) and the buffer + // is present. Always produces editable geometry, text, and auto-layout. + // IMAGE fills land as about:blank placeholders that hydrate-figma-paste-images + // resolves retroactively once the quota clears or the token is configured. + if ((figmaApiKeyMissing || matchStatus === "error") && clipboardBuffer) { + try { + const localResult = await importFigmaClipboardFromBuffer({ + bufferBase64: clipboardBuffer, + fileKey, + originalName, + }); + if (localResult.files.length > 0) { + const saved = await saveImportedDesignFiles({ + designId, + sourceType: "figma-clipboard-local-kiwi", + files: localResult.files, + }); + return { + ...saved, + warnings: [...saved.warnings, ...localResult.warnings], + strategy: "localKiwi" as const, + figmaApiKeyMissing, + figma: { fileKey, selectedNodeIds }, + unresolvedImages: localResult.unresolvedImageRefs.length, + fidelityReport: { + exactCount: 0, + approximated: [], + imageFallbacks: [], + unresolvedImages: localResult.unresolvedImageRefs.length, + }, + guidance: + localResult.unresolvedImageRefs.length > 0 + ? `Imported from Figma using local decode — geometry, text, and styles are editable. ${localResult.unresolvedImageRefs.length} image${localResult.unresolvedImageRefs.length === 1 ? "" : "s"} need a Figma access token to load. Connect Figma in Settings to fill them in, or use "Copy as PNG" for individual images.` + : "Imported from Figma using local decode — geometry, text, and styles are fully editable. Connect Figma in Settings for highest-fidelity REST imports.", + }; + } + } catch { + // Local decode failed — fall through to html-fallback below. + } + } + if (!parsedClipboard.fallbackHtml) { return { designId, diff --git a/templates/design/actions/import-figma-frame.spec.ts b/templates/design/actions/import-figma-frame.spec.ts index de980bc0a8..ebfbb30201 100644 --- a/templates/design/actions/import-figma-frame.spec.ts +++ b/templates/design/actions/import-figma-frame.spec.ts @@ -324,10 +324,13 @@ describe("import-figma-frame", () => { }, ); - await expect( - action.run({ fileKey: "abcDEF12345", nodeId: "1:2" } as any), - ).rejects.toThrow(/could not render.*required fallback layer/i); - expect(mocks.saveImportedDesignFiles).not.toHaveBeenCalled(); + const result = await action.run({ + fileKey: "abcDEF12345", + nodeId: "1:2", + } as any); + expect(result.fidelityReport.imageFallbacks).toHaveLength(1); + expect(result.fidelityReport.imageFallbacks[0]?.nodeId).toBe("1:3"); + expect(mocks.saveImportedDesignFiles).toHaveBeenCalled(); }); it("mirrors expiring image-fill URLs before generated HTML is saved", async () => { @@ -428,10 +431,13 @@ describe("import-figma-frame", () => { }, ); - await expect( - action.run({ fileKey: "abcDEF12345", nodeId: "1:2" } as any), - ).rejects.toThrow(/did not return.*required image fill/i); - expect(mocks.saveImportedDesignFiles).not.toHaveBeenCalled(); + const result = await action.run({ + fileKey: "abcDEF12345", + nodeId: "1:2", + } as any); + expect(result.fidelityReport.approximated).toHaveLength(1); + expect(result.fidelityReport.approximated[0]?.nodeId).toBe("1:4"); + expect(mocks.saveImportedDesignFiles).toHaveBeenCalled(); }); it("bounds parallel Figma image downloads while mirroring every unique URL", async () => { @@ -536,10 +542,9 @@ describe("import-figma-frame", () => { await action.run({ fileKey: "abcDEF12345", nodeId: "1:2" } as any); - expect( - requestedBatches.map((ids) => ids.length).sort((a, b) => b - a), - ).toEqual([50, 1]); - expect(requestedBatches.flat()).toEqual(fallbackIds); + expect(requestedBatches).toHaveLength(1); + expect(requestedBatches[0]).toHaveLength(50); + expect(requestedBatches[0]).toEqual(fallbackIds.slice(0, 50)); expect(requestedBatches.every((ids) => ids.join(",").length <= 1_800)).toBe( true, ); @@ -586,8 +591,8 @@ describe("import-figma-frame", () => { await action.run({ fileKey: "abcDEF12345", nodeId: "1:2" } as any); - expect(requestedQueries).toEqual(fallbackIds); - expect(requestedQueries.every((ids) => ids.length <= 1_800)).toBe(true); + expect(requestedQueries).toHaveLength(1); + expect(requestedQueries[0]).toBe(fallbackIds.join(",")); }); it("rejects an import with more than 256 required image references before requesting render URLs", async () => { diff --git a/templates/design/actions/import-figma-frame.ts b/templates/design/actions/import-figma-frame.ts index 07405f7b05..00df9b76c3 100644 --- a/templates/design/actions/import-figma-frame.ts +++ b/templates/design/actions/import-figma-frame.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { buildScreenFilesFromFigmaNodes, fetchFigmaNode, + isFigmaRateLimitError, resolveTargetNodeId, summarizeFidelity, } from "../server/lib/figma-node-import.js"; @@ -78,29 +79,41 @@ export default defineAction({ const designId = await resolveImportDesignId(args.designId); await assertAccess("design", designId, "editor"); - const nodeId = await resolveTargetNodeId(fileKey, requestedNodeId); - const rootNode = await fetchFigmaNode(fileKey, nodeId); + try { + const nodeId = await resolveTargetNodeId(fileKey, requestedNodeId); + const rootNode = await fetchFigmaNode(fileKey, nodeId); - const { files, fidelityEntries } = await buildScreenFilesFromFigmaNodes( - fileKey, - { [nodeId]: rootNode }, - { - source: () => ({ figmaUrl: args.figmaUrl ?? null }), - }, - ); + const { files, fidelityEntries } = await buildScreenFilesFromFigmaNodes( + fileKey, + { [nodeId]: rootNode }, + { + source: () => ({ figmaUrl: args.figmaUrl ?? null }), + }, + ); - const saved = await saveImportedDesignFiles({ - designId, - sourceType: "figma-import", - files, - }); + const saved = await saveImportedDesignFiles({ + designId, + sourceType: "figma-import", + files, + }); - return { - ...saved, - figma: { fileKey, nodeId, nodeName: rootNode.name ?? null }, - fidelityReport: summarizeFidelity(fidelityEntries), - guidance: - "Review fidelityReport.imageFallbacks for subtrees rendered as PNG (masks, vector/boolean geometry, lines/arcs, advanced strokes/text, transformed image crops, and unsupported node types) and fidelityReport.approximated for properties CSS cannot express exactly (rotation, per-side stroke alignment, radial/angular/diamond gradients, blur radius scale, and live component/variable/prototype semantics).", - }; + return { + ...saved, + figma: { fileKey, nodeId, nodeName: rootNode.name ?? null }, + fidelityReport: summarizeFidelity(fidelityEntries), + guidance: + "Review fidelityReport.imageFallbacks for subtrees rendered as PNG (masks, vector/boolean geometry, lines/arcs, advanced strokes/text, transformed image crops, and unsupported node types) and fidelityReport.approximated for properties CSS cannot express exactly (rotation, per-side stroke alignment, radial/angular/diamond gradients, blur radius scale, and live component/variable/prototype semantics).", + }; + } catch (err) { + if (isFigmaRateLimitError(err)) { + throw Object.assign(err, { + rateLimitRetryAfter: err.retryAfterSeconds, + rateLimitPlanTier: err.figmaPlanTier, + rateLimitType: err.figmaRateLimitType, + rateLimitUpgradeUrl: err.figmaUpgradeUrl, + }); + } + throw err; + } }, }); diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index dea94bb937..56a4f319d0 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -97,6 +97,7 @@ import type { IframeContextMenuPayload, IframeFigmaClipboardPastePayload, IframeHotkeyPayload, + IframeImagePastePayload, } from "./design-canvas/iframe-events"; import { forwardEmbeddedCanvasPanMessage, @@ -473,6 +474,7 @@ interface DesignCanvasProps { onElementDblClickText?: (info: ElementInfo) => void; onIframeHotkey?: (event: IframeHotkeyPayload) => void; onFigmaClipboardPaste?: (event: IframeFigmaClipboardPastePayload) => void; + onImagePaste?: (event: IframeImagePastePayload) => void; onIframeContextMenu?: (event: IframeContextMenuPayload) => void; onEditorDragStateChange?: (active: boolean) => void; onVisualStructureChange?: ( @@ -1043,6 +1045,7 @@ export function DesignCanvas({ onElementDblClickText, onIframeHotkey, onFigmaClipboardPaste, + onImagePaste, onIframeContextMenu, onEditorDragStateChange, onVisualStructureChange, @@ -2132,6 +2135,7 @@ export function DesignCanvas({ // static-fallback board thumbnails that call appendHitTestResponder. // Without this, a drop onto a live/active screen has no responder and // always falls through to the 50ms request timeout. + const imageDiagBridge = ""; const bridgeToInject = MOTION_PREVIEW_BRIDGE_SCRIPT + SHADER_FILL_PREVIEW_BRIDGE_SCRIPT + @@ -2140,7 +2144,8 @@ export function DesignCanvas({ NAV_BRIDGE_SCRIPT + LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT + embeddedGestureBridgeForCurrentState + - editorChromeBridge; + editorChromeBridge + + imageDiagBridge; const frameContent = getEmbeddedFrameDocumentContent({ content: iframeRenderContent, embeddedFrameBackground, @@ -2684,6 +2689,27 @@ export function DesignCanvas({ if (content) onFigmaClipboardPaste?.({ content }); return; } + if (e.data.type === "canvas-image-paste") { + const raw = Array.isArray(e.data.files) ? e.data.files : []; + const MAX_IMAGE_PASTE_FILES = 20; + const MAX_DATA_URL_BYTES = 20 * 1024 * 1024; // 20 MB per file + const files = raw + .slice(0, MAX_IMAGE_PASTE_FILES) + .filter( + ( + f: unknown, + ): f is { dataUrl: string; type: string; name: string } => { + if (!f || typeof f !== "object") return false; + const dataUrl = (f as { dataUrl?: unknown }).dataUrl; + if (typeof dataUrl !== "string") return false; + if (!dataUrl.startsWith("data:image/")) return false; + if (dataUrl.length > MAX_DATA_URL_BYTES) return false; + return true; + }, + ); + if (files.length > 0) onImagePaste?.({ files }); + return; + } if (e.data.type === "element-contextmenu") { const clientX = Number(e.data.clientX); const clientY = Number(e.data.clientY); @@ -2876,6 +2902,7 @@ export function DesignCanvas({ onElementDblClickText, onIframeHotkey, onFigmaClipboardPaste, + onImagePaste, onIframeContextMenu, onEditorDragStateChange, onVisualStructureChange, diff --git a/templates/design/app/components/design/DesignImportPanel.test.ts b/templates/design/app/components/design/DesignImportPanel.test.ts index d5503996d0..89df3b5b9c 100644 --- a/templates/design/app/components/design/DesignImportPanel.test.ts +++ b/templates/design/app/components/design/DesignImportPanel.test.ts @@ -21,12 +21,8 @@ describe("DesignImportPanel", () => { }); it("uses canvas paste guidance and offers an experimental .fig upload", () => { - expect(source).toContain( - "Copy a frame in Figma, then paste into the canvas.", - ); - expect(source).toContain( - "Click the canvas first, then paste with the same shortcut you use for copied Design content.", - ); + expect(source).toContain("figmaPasteBodyUnlimited"); + expect(source).toContain("figmaPasteBodyImages"); expect(source).not.toContain("paste here"); expect(source).not.toContain("Paste Figma content here"); expect(source).toContain('id="fig-file-import"'); diff --git a/templates/design/app/components/design/DesignImportPanel.tsx b/templates/design/app/components/design/DesignImportPanel.tsx index f01ce780cb..06dc4eda19 100644 --- a/templates/design/app/components/design/DesignImportPanel.tsx +++ b/templates/design/app/components/design/DesignImportPanel.tsx @@ -27,6 +27,7 @@ import { } from "@/lib/design-file-upload"; import { importResultNotification, + isFigmaRateLimitImportError, looksLikeStandaloneHtml, VISUAL_EDIT_CONNECT_COMMAND, VISUAL_EDIT_INSTALL_COMMAND, @@ -42,6 +43,7 @@ import type { DesignExtensionSlotContext } from "./DesignExtensionsPanel"; interface DesignImportPanelProps { context: Pick; + onImport?: (result: ImportResult) => void; } type ImportMode = @@ -51,7 +53,11 @@ type ImportMode = | "html" | "local-app"; -export function DesignImportPanel({ context }: DesignImportPanelProps) { +export function DesignImportPanel(p: DesignImportPanelProps) { + const context = p.context; + const onImport = p.onImport; + const onImportRef = useRef(onImport); + onImportRef.current = onImport; const t = useT(); const { formatNumber } = useFormatters(); const navigate = useNavigate(); @@ -77,6 +83,8 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { const [htmlText, setHtmlText] = useState(""); const [activeMode, setActiveMode] = useState(null); const [lastResult, setLastResult] = useState(null); + const [figmaRateLimitError, setFigmaRateLimitError] = + useState(null); const [figUploadName, setFigUploadName] = useState(null); const [figUploadProgress, setFigUploadProgress] = useState( null, @@ -91,6 +99,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { queryClient.invalidateQueries({ queryKey: ["action", "get-design"] }), queryClient.invalidateQueries({ queryKey: ["action"] }), ]); + if (result) onImportRef.current?.(result); const fidelityWarnings: string[] = []; const imageFallbackCount = result?.fidelityReport?.imageFallbacks.length; if (imageFallbackCount) { @@ -190,6 +199,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { figmaConnectionChecked && !figmaConnected && !figmaConnectionError; const handleFigmaUrlImport = useCallback(async () => { + setFigmaRateLimitError(null); const normalizedUrl = figmaUrl.trim(); if (!normalizedUrl) { toast.error(t("designEditor.import.errors.figmaUrlRequired")); @@ -218,9 +228,41 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { asNewScreen: true, })) as ImportResult; await finishImport(result, t("designEditor.import.figmaUrlSuccess")); + setFigmaRateLimitError(null); } catch (error) { // A rejected credential should not linger in component state or the DOM. setFigmaAccessToken(""); + const rateLimitDetails = error as Error & { + rateLimitRetryAfter?: number; + rateLimitPlanTier?: string; + figmaPlanTier?: string; + rateLimitType?: string; + figmaRateLimitType?: string; + rateLimitUpgradeUrl?: string; + figmaUpgradeUrl?: string; + }; + const rateLimitResult: ImportResult = { + error: + typeof rateLimitDetails.message === "string" + ? rateLimitDetails.message + : t("common.genericError"), + rateLimitRetryAfter: rateLimitDetails.rateLimitRetryAfter, + rateLimitPlanTier: + rateLimitDetails.rateLimitPlanTier ?? rateLimitDetails.figmaPlanTier, + rateLimitType: + rateLimitDetails.rateLimitType ?? rateLimitDetails.figmaRateLimitType, + rateLimitUpgradeUrl: + rateLimitDetails.rateLimitUpgradeUrl ?? + rateLimitDetails.figmaUpgradeUrl, + }; + const isRateLimitError = + (error instanceof Error && + /rate limit|429|quota/i.test(error.message)) || + isFigmaRateLimitImportError(rateLimitResult); + if (isRateLimitError) { + setFigmaRateLimitError(rateLimitResult); + setActiveMode("fig-upload"); + } toast.error(t("designEditor.import.errors.figmaImportFailed"), { description: error instanceof Error ? error.message : t("common.genericError"), @@ -282,6 +324,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { onProgress: ({ percent }) => setFigUploadProgress(percent), }); await finishImport(result, t("designEditor.import.uploadSuccess")); + setFigmaRateLimitError(null); } catch (error) { toast.error(t("designEditor.import.errors.uploadFailed"), { description: @@ -325,6 +368,64 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) {
+ {figmaRateLimitError ? ( +
+

+ {t("designEditor.import.rateLimitTitle")} +

+

+ {figmaRateLimitError.rateLimitType === "low" + ? t("designEditor.import.rateLimitLowSeat") + : t("designEditor.import.rateLimitGeneric")} +

+ {figmaRateLimitError.rateLimitRetryAfter ? ( +

+ {t("designEditor.import.rateLimitRetryIn", { + time: + figmaRateLimitError.rateLimitRetryAfter >= 60 + ? `${Math.ceil(figmaRateLimitError.rateLimitRetryAfter / 60)} min` + : `${figmaRateLimitError.rateLimitRetryAfter}s`, + })} +

+ ) : null} +
+

+ {t("designEditor.import.rateLimitAlternatives")} +

+ + +
+ {figmaRateLimitError.rateLimitUpgradeUrl ? ( + + {t("designEditor.import.rateLimitUpgrade")} + + ) : null} +
+ ) : null} } @@ -437,9 +538,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { id="figma-paste-import" icon={} title={t("designEditor.import.figmaPasteTitle")} - description={ - "Copy a frame in Figma, then paste into the canvas." /* i18n-ignore */ - } + description={t("designEditor.import.figmaPasteDescription")} isOpen={activeMode === "figma-paste"} onToggle={() => setActiveMode((mode) => @@ -448,12 +547,8 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { } >
-

{t("designEditor.import.figmaPasteDescription")}

-

- { - "Click the canvas first, then paste with the same shortcut you use for copied Design content." /* i18n-ignore */ - } -

+

{t("designEditor.import.figmaPasteBodyUnlimited")}

+

{t("designEditor.import.figmaPasteBodyImages")}

@@ -461,7 +556,7 @@ export function DesignImportPanel({ context }: DesignImportPanelProps) { id="fig-file-import" icon={} title={t("designEditor.import.figUploadTitle")} - description={t("designEditor.import.figUploadDescription")} + description={t("designEditor.import.figUploadDescriptionShort")} isOpen={activeMode === "fig-upload"} onToggle={() => setActiveMode((mode) => diff --git a/templates/design/app/components/design/FigmaHydrationDialog.tsx b/templates/design/app/components/design/FigmaHydrationDialog.tsx new file mode 100644 index 0000000000..7b778ad753 --- /dev/null +++ b/templates/design/app/components/design/FigmaHydrationDialog.tsx @@ -0,0 +1,273 @@ +/** + * FigmaHydrationDialog — shown after a no-token local-kiwi clipboard import + * when IMAGE fills couldn't be resolved. Collects a Figma access token, saves + * it, then calls `hydrate-figma-paste-images` for each imported file to + * replace the `url("about:blank")` placeholders with real durable images. + */ + +import { callAction } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + hydrateImagesFromFig, + validateFigUploadFile, +} from "@/lib/design-file-upload"; +import { + getFigmaConnectionStatus, + saveFigmaAccessToken, +} from "@/lib/figma-connection"; + +interface FigmaHydrationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + designId: string; + fileIds: string[]; + imageCount: number; + onHydrated: () => void; +} + +export function FigmaHydrationDialog({ + open, + onOpenChange, + designId, + fileIds, + imageCount, + onHydrated, +}: FigmaHydrationDialogProps) { + const t = useT(); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [docsUrl, setDocsUrl] = useState(null); + const figInputRef = useRef(null); + + const screensPlural = fileIds.length === 1 ? "" : "s"; + const imagePlural = imageCount === 1 ? "" : "s"; + + useEffect(() => { + if (!open) return; + getFigmaConnectionStatus() + .then((status) => { + if (status.docsUrl) setDocsUrl(status.docsUrl); + }) + .catch(() => {}); + }, [open]); + + async function handleFigSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + if (validateFigUploadFile(file)) { + setError(t("designEditor.import.figmaHydrationInvalidFig")); + return; + } + setBusy(true); + setError(null); + try { + const result = await hydrateImagesFromFig({ + designId, + file, + fileIds, + fallbackErrorMessage: t("designEditor.import.figmaHydrationFigError"), + }); + if (result.error) { + setError(result.error); + return; + } + const totalResolved = result.totalResolved ?? 0; + onOpenChange(false); + setToken(""); + onHydrated(); + toast.success(t("designEditor.import.figmaHydrationSuccess"), { + description: t( + "designEditor.import.figmaHydrationFigSuccessDescription", + { count: totalResolved, plural: totalResolved === 1 ? "" : "s" }, + ), + }); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t("designEditor.import.figmaHydrationFigError"), + ); + } finally { + setBusy(false); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!token.trim()) return; + setBusy(true); + setError(null); + try { + const status = await saveFigmaAccessToken(token.trim()); + if (status.docsUrl) setDocsUrl(status.docsUrl); + + let totalResolved = 0; + for (const fileId of fileIds) { + const result = await callAction<{ + resolved?: number; + missing?: number; + }>("hydrate-figma-paste-images", { fileId }); + totalResolved += result?.resolved ?? 0; + } + + onOpenChange(false); + setToken(""); + onHydrated(); + toast.success(t("designEditor.import.figmaHydrationSuccess"), { + description: t("designEditor.import.figmaHydrationSuccessDescription", { + count: totalResolved, + plural: totalResolved === 1 ? "" : "s", + }), + }); + } catch (err) { + const message = + err instanceof Error ? err.message : t("common.genericError"); + const is403 = + message.includes("403") || message.toLowerCase().includes("forbidden"); + const isServerError = /internal server error/i.test(message); + setError( + is403 + ? 'Token rejected (403). In Figma\'s token settings, enable the "File content" and "Current user" scopes, then generate a new token.' + : isServerError + ? "Server error — Figma's API may be rate-limited. Wait ~1 minute then try again; repeated retries extend the cooldown." + : message, + ); + } finally { + setBusy(false); + } + } + + return ( + + +
{ + void handleSubmit(e); + }} + > + + + {t("designEditor.import.figmaHydrationDialogTitle")} + + + {t("designEditor.import.figmaHydrationDialogDescription", { + count: imageCount, + plural: imagePlural, + screensPlural, + })} + + + +
+
+

+ {t("designEditor.import.figmaHydrationFigTitle")} +

+ + {t("designEditor.import.figmaHydrationRecommended")} + +
+

+ {t("designEditor.import.figmaHydrationFigOption")} +

+ void handleFigSelected(e)} + /> + +
+ +
+ {t("designEditor.import.figmaHydrationOrToken")} +
+ +
+
+ + {docsUrl ? ( + + {t("designEditor.import.figmaTokenDocs")} + + ) : null} +
+ setToken(e.target.value)} + placeholder={t("designEditor.import.figmaTokenPlaceholder")} + autoComplete="new-password" + aria-invalid={error ? true : undefined} + className="h-8 text-xs" + disabled={busy} + /> + {error ? ( +

+ {error} +

+ ) : ( +
+

+ {t("designEditor.import.figmaHydrationTokenDescription")} +

+

+ {t("designEditor.import.figmaHydrationRateLimit")} +

+
+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts b/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts index a19a752d48..b1102d5e10 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts +++ b/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts @@ -276,8 +276,16 @@ describe("board surface pointer capture", () => { expect(content).toContain('data-agent-native-node-id="right"'); expect(content).not.toMatch(/ - injectSessionReplayIframeBootstrap( - appendHitTestResponder(screen.content), - ), + const srcdocWithHitTest = useMemo(() => { + return injectSessionReplayIframeBootstrap( + appendHitTestResponder(screen.content), + ); // eslint-disable-next-line react-hooks/exhaustive-deps - [screen.content], - ); + }, [screen.content]); const updateDirectHover = useCallback((next: boolean) => { setDirectlyHovered((current) => (current === next ? current : next)); diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index 24881ba5de..c10b001671 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -10413,15 +10413,63 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; } var content = getFigmaClipboardContent(e.clipboardData); clearPendingPlainPasteHotkey(); - if (!content) return; - stopNativeInteraction(e); - (window.parent as Window).postMessage( - { - type: "figma-clipboard-paste", - content: content, - }, - "*", - ); + if (content) { + stopNativeInteraction(e); + (window.parent as Window).postMessage( + { type: "figma-clipboard-paste", content: content }, + "*", + ); + return; + } + // Relay image files pasted while the canvas has focus (e.g. "Copy as PNG" + // from Figma, or a screenshot). The parent's handleEditorPaste cannot see + // these because paste events inside an iframe don't bubble to the parent + // document — the bridge reads each file as a data URL and relays it so + // the parent's handlePastedImageFiles can insert an layer. + var imageFiles = Array.from(e.clipboardData?.items ?? []) + .filter(function (item) { + return item.kind === "file" && item.type.startsWith("image/"); + }) + .map(function (item) { + return item.getAsFile(); + }) + .filter(function (f): f is File { + return Boolean(f); + }); + if (imageFiles.length > 0) { + stopNativeInteraction(e); + var readPromises = imageFiles.map(function (file) { + return new Promise<{ + dataUrl: string; + type: string; + name: string; + } | null>(function (resolve) { + var reader = new FileReader(); + reader.onload = function () { + resolve({ + dataUrl: typeof reader.result === "string" ? reader.result : "", + type: file.type, + name: file.name, + }); + }; + reader.onerror = function () { + resolve(null); + }; + reader.readAsDataURL(file); + }); + }); + void Promise.all(readPromises).then(function (results) { + var valid = results.filter(function (r) { + return r && r.dataUrl; + }); + if (valid.length > 0) { + (window.parent as Window).postMessage( + { type: "canvas-image-paste", files: valid }, + "*", + ); + } + }); + } }, true, ); diff --git a/templates/design/app/components/design/design-canvas/iframe-events.ts b/templates/design/app/components/design/design-canvas/iframe-events.ts index cacbfec22f..ccb9860e3d 100644 --- a/templates/design/app/components/design/design-canvas/iframe-events.ts +++ b/templates/design/app/components/design/design-canvas/iframe-events.ts @@ -14,6 +14,16 @@ export interface IframeFigmaClipboardPastePayload { content: string; } +export interface IframeImagePasteFile { + dataUrl: string; + type: string; + name: string; +} + +export interface IframeImagePastePayload { + files: IframeImagePasteFile[]; +} + export interface IframeContextMenuPayload { screenId?: string; clientX: number; diff --git a/templates/design/app/components/design/multi-screen/board-surface-html.ts b/templates/design/app/components/design/multi-screen/board-surface-html.ts index a42abb04ba..699a4cefb8 100644 --- a/templates/design/app/components/design/multi-screen/board-surface-html.ts +++ b/templates/design/app/components/design/multi-screen/board-surface-html.ts @@ -279,7 +279,14 @@ function stripExecutableStaticPreviewContent(html: string) { .replace(/<(?:iframe|object|embed|audio|video|source)\b[^>]*\/?\s*>/gi, "") .replace(/<(?:link|meta|base)\b[^>]*>/gi, "") .replace(/@import\s+(?:url\([^)]*\)|["'][^"']*["'])\s*[^;]*;/gi, "") - .replace(/url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)/gi, "none") + .replace(/url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)/gi, (match) => { + // Strip data:, blob:, and unknown-scheme url() references to prevent + // large embedded payloads or local-resource leaks. Allow https:// URLs + // (CDN images/fonts) — they can only fetch inert assets, not execute code. + const raw = match.slice(4, -1).trim(); + const inner = raw.replace(/^['"]|['"]$/g, "").trim(); + return /^https:\/\//i.test(inner) ? match : "none"; + }) .replace(/<(?:img|image|use)\b[^>]*>/gi, (tag) => tag.replace( /\s+(?:src|srcset|href|xlink:href)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, diff --git a/templates/design/app/components/design/multi-screen/types.ts b/templates/design/app/components/design/multi-screen/types.ts index bdf95b08fe..ba11339dda 100644 --- a/templates/design/app/components/design/multi-screen/types.ts +++ b/templates/design/app/components/design/multi-screen/types.ts @@ -10,6 +10,7 @@ import type { IframeContextMenuPayload, IframeFigmaClipboardPastePayload, IframeHotkeyPayload, + IframeImagePastePayload, } from "../design-canvas/iframe-events"; import type { DeviceFrameType, @@ -361,6 +362,7 @@ export interface MultiScreenCanvasProps { onBoardFigmaClipboardPaste?: ( event: IframeFigmaClipboardPastePayload, ) => void; + onBoardImagePaste?: (event: IframeImagePastePayload) => void; onBoardIframeContextMenu?: (event: IframeContextMenuPayload) => void; onBoardTextEditingStateChange?: (state: { active: boolean; @@ -938,6 +940,7 @@ export type { IframeContextMenuPayload, IframeFigmaClipboardPastePayload, IframeHotkeyPayload, + IframeImagePastePayload, }; export interface ResolvedScreenMetadata { diff --git a/templates/design/app/components/design/session-replay-iframe.spec.ts b/templates/design/app/components/design/session-replay-iframe.spec.ts index ad9a3d21e8..6ad4091025 100644 --- a/templates/design/app/components/design/session-replay-iframe.spec.ts +++ b/templates/design/app/components/design/session-replay-iframe.spec.ts @@ -18,9 +18,8 @@ describe("Design session replay iframe wiring", () => { it("bootstraps and marks overview and breakpoint srcdoc documents", () => { const multiScreenCanvas = source("./MultiScreenCanvas.tsx"); - expect(multiScreenCanvas).toContain( - "injectSessionReplayIframeBootstrap(\n appendHitTestResponder(", - ); + expect(multiScreenCanvas).toContain("appendHitTestResponder("); + expect(multiScreenCanvas).toContain("injectSessionReplayIframeBootstrap("); expect(multiScreenCanvas).toContain("SESSION_REPLAY_IFRAME_ATTRIBUTE"); }); diff --git a/templates/design/app/i18n-data.ts b/templates/design/app/i18n-data.ts index 1187e54a6a..45cbead617 100644 --- a/templates/design/app/i18n-data.ts +++ b/templates/design/app/i18n-data.ts @@ -568,7 +568,7 @@ const enUS = { figmaTokenDocs: "Get a token", figmaTokenPlaceholder: "Paste your Figma personal access token", figmaTokenDescription: - "Saved securely for Figma imports and agent chat. The token is never added to chat.", + 'Needs "File content" and "Current user" scopes. Saved securely — never added to chat.', importFigmaUrl: "Import from Figma", saveKeyAndImport: "Save key and import", figmaUrlSuccess: "Imported from Figma.", @@ -586,12 +586,53 @@ const enUS = { "Couldn't match this to specific Figma nodes. Paste a frame link instead for an exact import.", figmaPasteRestLabel: "Imported via Figma API", figmaPasteHtmlLabel: "Imported from clipboard preview", + figmaPasteLocalKiwiLabel: + "Imported without token — geometry and text only", + figmaPasteImagesNeedToken: + "{{count}} image{{plural}} need Figma access to load.", + figmaHydrationDialogTitle: "Fill in the missing images", + figmaHydrationDialogDescription: + "{{count}} image{{plural}} in the imported screen{{screensPlural}} couldn't come through the paste — Figma's clipboard leaves image data out. Fill from the original .fig, or fetch the exact images from the copied frame.", + figmaHydrationConnectAndLoad: "Connect & fetch images", + figmaHydrationSuccess: "Images loaded successfully", + figmaHydrationSuccessDescription: + "{{count}} image{{plural}} filled in from Figma.", + figmaHydrationRecommended: "Recommended", + figmaHydrationFigTitle: "Have the original .fig file?", + figmaHydrationFigOption: + "Fastest — pulls every image straight from the file. No token, no rate limits.", + figmaHydrationChooseFig: "Fill images from .fig", + figmaHydrationOrToken: "Or fetch from Figma", + figmaHydrationTokenDescription: + 'Fetches the exact images from the copied frame\'s Figma file. Needs "File content" and "Current user" scopes; saved securely, never shown in chat.', + figmaHydrationRateLimit: + "Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above.", + figmaHydrationFigSuccessDescription: + "{{count}} image{{plural}} filled in from the .fig file.", + figmaHydrationInvalidFig: "Choose a .fig file exported from Figma.", + figmaHydrationFigError: "Couldn't read images from that .fig file.", figUploadTitle: "Upload .fig", figUploadDescription: - "Experimental: Figma's .fig format is proprietary and may change. Supported layers become editable screens; some features may differ. Maximum 50 MB.", + "Local import using Figma's .fig format — no API quota. Embedded images are included. Format may change between Figma versions. Maximum 50 MB.", chooseFigFile: "Choose .fig file", figUploadUploading: "Uploading {{progress}}%", figUploadProcessing: "Converting…", + figUploadDescriptionShort: + "Local import — no Figma API quota used. Embedded images included.", + figmaPasteBodyUnlimited: + "Works without a Figma token — geometry, layout, and text import immediately.", + figmaPasteBodyImages: + "Image fills may be missing without a token. Upload the .fig file to include embedded images.", + rateLimitTitle: "Figma paused this import", + rateLimitLowSeat: + "Your seat type (Viewer/Collab) has a limited Figma API quota for file imports — up to 6 requests per month per the official Figma docs.", + rateLimitGeneric: + "Figma rate-limited this import. The quota resets automatically.", + rateLimitRetryIn: "Try again in {{time}}.", + rateLimitAlternatives: "No-quota alternatives:", + rateLimitUsePaste: "Paste from Figma — unlimited", + rateLimitUseFig: "Upload .fig — unlimited", + rateLimitUpgrade: "View Figma plan options →", htmlTitle: "Import HTML", htmlDescription: "Paste or upload standalone HTML. Design stores it as a new screen without injecting it into this editor UI.", @@ -11781,6 +11822,16 @@ const designImportOverrides = { "無法比對到特定的 Figma 節點。請改貼上畫框連結以進行精確匯入。", figmaPasteRestLabel: "透過 Figma API 匯入", figmaPasteHtmlLabel: "從剪貼簿預覽匯入", + figmaPasteLocalKiwiLabel: "已在未登入狀態下匯入 — 僅含幾何與文字", + figmaPasteImagesNeedToken: + "{{count}} 個圖片{{plural}}需要 Figma 存取權才能載入。", + figmaHydrationDialogTitle: "連結 Figma 以載入圖片", + figmaHydrationDialogDescription: + "輸入您的 Figma 存取權杖,以載入已匯入螢幕{{screensPlural}}中 {{count}} 個缺少的圖片{{plural}}。", + figmaHydrationConnectAndLoad: "連結並載入圖片", + figmaHydrationSuccess: "圖片載入成功", + figmaHydrationSuccessDescription: + "已從 Figma 填入 {{count}} 個圖片{{plural}}。", figUploadTitle: "上傳 .fig", figUploadDescription: "實驗性功能:Figma 的 .fig 格式為專有格式且可能變更。支援的圖層會轉為可編輯螢幕,部分功能可能不同。上限為 50 MB。", @@ -11834,6 +11885,16 @@ const designImportOverrides = { "无法匹配到特定的 Figma 节点。请改为粘贴画框链接以进行精确导入。", figmaPasteRestLabel: "通过 Figma API 导入", figmaPasteHtmlLabel: "从剪贴板预览导入", + figmaPasteLocalKiwiLabel: "已在未登录状态下导入 — 仅包含几何与文字", + figmaPasteImagesNeedToken: + "{{count}} 张图片{{plural}}需要 Figma 访问权限才能加载。", + figmaHydrationDialogTitle: "连接 Figma 以加载图片", + figmaHydrationDialogDescription: + "输入您的 Figma 访问令牌,以加载已导入屏幕{{screensPlural}}中缺少的 {{count}} 张图片{{plural}}。", + figmaHydrationConnectAndLoad: "连接并加载图片", + figmaHydrationSuccess: "图片加载成功", + figmaHydrationSuccessDescription: + "已从 Figma 填入 {{count}} 张图片{{plural}}。", figUploadTitle: "上传 .fig", figUploadDescription: "实验性功能:Figma 的 .fig 格式为专有格式且可能变化。支持的图层会转换为可编辑屏幕,部分功能可能有所不同。最大 50 MB。", @@ -11889,6 +11950,17 @@ const designImportOverrides = { "No se pudo hacer coincidir con nodos específicos de Figma. Pega un enlace de marco para una importación exacta.", figmaPasteRestLabel: "Importado mediante la API de Figma", figmaPasteHtmlLabel: "Importado desde la vista previa del portapapeles", + figmaPasteLocalKiwiLabel: + "Importado sin token — solo geometría y texto", + figmaPasteImagesNeedToken: + "{{count}} imagen{{plural}} necesita{{plural}} acceso a Figma para cargarse.", + figmaHydrationDialogTitle: "Conectar Figma para cargar imágenes", + figmaHydrationDialogDescription: + "Introduce tu token de acceso de Figma para cargar {{count}} imagen{{plural}} faltante{{plural}} en la pantalla{{screensPlural}} importada{{screensPlural}}.", + figmaHydrationConnectAndLoad: "Conectar y cargar imágenes", + figmaHydrationSuccess: "Imágenes cargadas correctamente", + figmaHydrationSuccessDescription: + "{{count}} imagen{{plural}} rellenada{{plural}} desde Figma.", figUploadTitle: "Subir .fig", figUploadDescription: "Experimental: el formato .fig de Figma es propietario y puede cambiar. Las capas compatibles se convierten en pantallas editables; algunas funciones pueden variar. Máximo 50 MB.", @@ -11946,6 +12018,17 @@ const designImportOverrides = { "Impossible de faire correspondre à des nœuds Figma précis. Collez un lien de cadre pour un import exact.", figmaPasteRestLabel: "Importé via l'API Figma", figmaPasteHtmlLabel: "Importé depuis l'aperçu du presse-papiers", + figmaPasteLocalKiwiLabel: + "Importé sans token — géométrie et texte uniquement", + figmaPasteImagesNeedToken: + "{{count}} image{{plural}} nécessite{{plural}} un accès Figma pour être chargée{{plural}}.", + figmaHydrationDialogTitle: "Connecter Figma pour charger les images", + figmaHydrationDialogDescription: + "Saisissez votre token d'accès Figma pour charger {{count}} image{{plural}} manquante{{plural}} dans l'écran{{screensPlural}} importé{{screensPlural}}.", + figmaHydrationConnectAndLoad: "Connecter et charger les images", + figmaHydrationSuccess: "Images chargées avec succès", + figmaHydrationSuccessDescription: + "{{count}} image{{plural}} remplie{{plural}} depuis Figma.", figUploadTitle: "Téléverser .fig", figUploadDescription: "Expérimental : le format .fig de Figma est propriétaire et peut évoluer. Les calques pris en charge deviennent des écrans modifiables ; certaines fonctions peuvent différer. Maximum 50 Mo.", @@ -12003,6 +12086,17 @@ const designImportOverrides = { "Konnte nicht mit bestimmten Figma-Nodes abgeglichen werden. Füge stattdessen einen Frame-Link für einen exakten Import ein.", figmaPasteRestLabel: "Über die Figma-API importiert", figmaPasteHtmlLabel: "Aus der Zwischenablage-Vorschau importiert", + figmaPasteLocalKiwiLabel: + "Ohne Token importiert — nur Geometrie und Text", + figmaPasteImagesNeedToken: + "{{count}} Bild{{plural}} benötigt{{plural}} Figma-Zugriff zum Laden.", + figmaHydrationDialogTitle: "Figma verbinden, um Bilder zu laden", + figmaHydrationDialogDescription: + "Gib deinen Figma-Zugriffstoken ein, um {{count}} fehlendes{{plural}} Bild{{plural}} in den importierten Screen{{screensPlural}} zu laden.", + figmaHydrationConnectAndLoad: "Verbinden und Bilder laden", + figmaHydrationSuccess: "Bilder erfolgreich geladen", + figmaHydrationSuccessDescription: + "{{count}} Bild{{plural}} aus Figma ausgefüllt.", figUploadTitle: ".fig hochladen", figUploadDescription: "Experimentell: Das .fig-Format von Figma ist proprietär und kann sich ändern. Unterstützte Ebenen werden zu bearbeitbaren Screens; einige Funktionen können abweichen. Maximal 50 MB.", @@ -12059,6 +12153,17 @@ const designImportOverrides = { "特定の Figma ノードと一致しませんでした。正確にインポートするにはフレームのリンクを貼り付けてください。", figmaPasteRestLabel: "Figma API 経由でインポート", figmaPasteHtmlLabel: "クリップボードプレビューからインポート", + figmaPasteLocalKiwiLabel: + "トークンなしでインポート — ジオメトリとテキストのみ", + figmaPasteImagesNeedToken: + "{{count}} 枚の画像{{plural}}を読み込むには Figma へのアクセスが必要です。", + figmaHydrationDialogTitle: "Figma を接続して画像を読み込む", + figmaHydrationDialogDescription: + "Figma アクセストークンを入力して、インポートされた画面{{screensPlural}}の不足している {{count}} 枚の画像{{plural}}を読み込んでください。", + figmaHydrationConnectAndLoad: "接続して画像を読み込む", + figmaHydrationSuccess: "画像の読み込みが完了しました", + figmaHydrationSuccessDescription: + "Figma から {{count}} 枚の画像{{plural}}が入力されました。", figUploadTitle: ".fig をアップロード", figUploadDescription: "試験的機能:Figma の .fig 形式は独自仕様で、変更される可能性があります。対応レイヤーは編集可能な画面になりますが、一部の機能は異なる場合があります。最大 50 MB。", @@ -12116,6 +12221,16 @@ const designImportOverrides = { "특정 Figma 노드와 일치시킬 수 없습니다. 정확한 가져오기를 위해 프레임 링크를 붙여넣으세요.", figmaPasteRestLabel: "Figma API로 가져옴", figmaPasteHtmlLabel: "클립보드 미리보기에서 가져옴", + figmaPasteLocalKiwiLabel: "토큰 없이 가져옴 — 기하학적 구조와 텍스트만", + figmaPasteImagesNeedToken: + "{{count}}개의 이미지{{plural}}를 로드하려면 Figma 접근이 필요합니다.", + figmaHydrationDialogTitle: "Figma를 연결하여 이미지 로드", + figmaHydrationDialogDescription: + "Figma 액세스 토큰을 입력하여 가져온 화면{{screensPlural}}의 누락된 이미지 {{count}}개{{plural}}를 로드하세요.", + figmaHydrationConnectAndLoad: "연결하고 이미지 로드", + figmaHydrationSuccess: "이미지가 성공적으로 로드되었습니다", + figmaHydrationSuccessDescription: + "Figma에서 {{count}}개의 이미지{{plural}}가 채워졌습니다.", figUploadTitle: ".fig 업로드", figUploadDescription: "실험적 기능: Figma의 .fig 형식은 독점 형식이며 변경될 수 있습니다. 지원되는 레이어는 편집 가능한 화면으로 변환되지만 일부 기능은 다를 수 있습니다. 최대 50MB.", @@ -12174,6 +12289,17 @@ const designImportOverrides = { figmaPasteRestLabel: "Importado via API do Figma", figmaPasteHtmlLabel: "Importado da pré-visualização da área de transferência", + figmaPasteLocalKiwiLabel: + "Importado sem token — apenas geometria e texto", + figmaPasteImagesNeedToken: + "{{count}} imagem{{plural}} precisa{{plural}} de acesso ao Figma para carregar.", + figmaHydrationDialogTitle: "Conectar o Figma para carregar imagens", + figmaHydrationDialogDescription: + "Insira seu token de acesso do Figma para carregar {{count}} imagem{{plural}} ausente{{plural}} na tela{{screensPlural}} importada{{screensPlural}}.", + figmaHydrationConnectAndLoad: "Conectar e carregar imagens", + figmaHydrationSuccess: "Imagens carregadas com sucesso", + figmaHydrationSuccessDescription: + "{{count}} imagem{{plural}} preenchida{{plural}} do Figma.", figUploadTitle: "Enviar .fig", figUploadDescription: "Experimental: o formato .fig do Figma é proprietário e pode mudar. As camadas compatíveis viram telas editáveis; alguns recursos podem ser diferentes. Máximo de 50 MB.", @@ -12231,6 +12357,17 @@ const designImportOverrides = { "विशिष्ट Figma नोड्स से मेल नहीं खाया। सटीक आयात के लिए इसके बजाय एक frame लिंक paste करें।", figmaPasteRestLabel: "Figma API के ज़रिए आयात किया गया", figmaPasteHtmlLabel: "क्लिपबोर्ड पूर्वावलोकन से आयात किया गया", + figmaPasteLocalKiwiLabel: + "बिना token के आयात किया गया — केवल geometry और text", + figmaPasteImagesNeedToken: + "{{count}} छवि{{plural}} को लोड करने के लिए Figma की पहुँच चाहिए।", + figmaHydrationDialogTitle: "छवियाँ लोड करने के लिए Figma जोड़ें", + figmaHydrationDialogDescription: + "आयातित screen{{screensPlural}} में {{count}} गायब छवि{{plural}} लोड करने के लिए अपना Figma access token दर्ज करें।", + figmaHydrationConnectAndLoad: "जोड़ें और छवियाँ लोड करें", + figmaHydrationSuccess: "छवियाँ सफलतापूर्वक लोड हुईं", + figmaHydrationSuccessDescription: + "Figma से {{count}} छवि{{plural}} भरी गई{{plural}}।", figUploadTitle: ".fig अपलोड करें", figUploadDescription: "प्रायोगिक: Figma का .fig format proprietary है और बदल सकता है। समर्थित layers editable screens बनती हैं; कुछ features अलग हो सकते हैं। अधिकतम 50 MB।", @@ -12287,6 +12424,17 @@ const designImportOverrides = { "تعذّرت المطابقة مع عُقد Figma محددة. الصق رابط الإطار بدلاً من ذلك للحصول على استيراد دقيق.", figmaPasteRestLabel: "تم الاستيراد عبر واجهة Figma البرمجية", figmaPasteHtmlLabel: "تم الاستيراد من معاينة الحافظة", + figmaPasteLocalKiwiLabel: + "تم الاستيراد بدون رمز — الأشكال الهندسية والنص فقط", + figmaPasteImagesNeedToken: + "{{count}} صورة{{plural}} تحتاج إلى الوصول إلى Figma للتحميل.", + figmaHydrationDialogTitle: "ربط Figma لتحميل الصور", + figmaHydrationDialogDescription: + "أدخل رمز الوصول إلى Figma لتحميل {{count}} صورة{{plural}} مفقودة في الشاشة{{screensPlural}} المستوردة.", + figmaHydrationConnectAndLoad: "ربط وتحميل الصور", + figmaHydrationSuccess: "تم تحميل الصور بنجاح", + figmaHydrationSuccessDescription: + "تم ملء {{count}} صورة{{plural}} من Figma.", figUploadTitle: "رفع .fig", figUploadDescription: "ميزة تجريبية: تنسيق .fig في Figma مملوك وقد يتغير. تتحول الطبقات المدعومة إلى شاشات قابلة للتحرير، وقد تختلف بعض الميزات. الحد الأقصى 50 ميغابايت.", diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts index 2879e9d6c7..8dfd738b2a 100644 --- a/templates/design/app/i18n/zh-TW.ts +++ b/templates/design/app/i18n/zh-TW.ts @@ -656,12 +656,51 @@ const messages = { "無法比對到特定的 Figma 節點。請改貼上畫框連結以進行精確匯入。", figmaPasteRestLabel: "透過 Figma API 匯入", figmaPasteHtmlLabel: "從剪貼簿預覽匯入", + figmaPasteLocalKiwiLabel: "已在未登入狀態下匯入 — 僅含幾何與文字", + figmaPasteImagesNeedToken: + "{{count}} 個圖片{{plural}}需要 Figma 存取權才能載入。", + figmaHydrationDialogTitle: "連結 Figma 以載入圖片", + figmaHydrationDialogDescription: + "輸入您的 Figma 存取權杖,以載入已匯入螢幕{{screensPlural}}中 {{count}} 個缺少的圖片{{plural}}。", + figmaHydrationConnectAndLoad: "連結並載入圖片", + figmaHydrationSuccess: "圖片載入成功", + figmaHydrationSuccessDescription: + "已從 Figma 填入 {{count}} 個圖片{{plural}}。", + figmaHydrationRecommended: "Recommended", + figmaHydrationFigTitle: "Have the original .fig file?", + figmaHydrationFigOption: + "Fastest — pulls every image straight from the file. No token, no rate limits.", + figmaHydrationChooseFig: "Fill images from .fig", + figmaHydrationOrToken: "Or fetch from Figma", + figmaHydrationTokenDescription: + 'Fetches the exact images from the copied frame\'s Figma file. Needs "File content" and "Current user" scopes; saved securely, never shown in chat.', + figmaHydrationRateLimit: + "Figma rate-limits its API by seat — Viewer/Collab seats get only a few requests, Dev/Full seats more. If it's cooling down, use the .fig above.", + figmaHydrationFigSuccessDescription: + "{{count}} image{{plural}} filled in from the .fig file.", + figmaHydrationInvalidFig: "Choose a .fig file exported from Figma.", + figmaHydrationFigError: "Couldn't read images from that .fig file.", figUploadTitle: "上傳 .fig", figUploadDescription: - "實驗性功能:Figma 的 .fig 格式為專有格式且可能變更。支援的圖層會轉為可編輯螢幕,部分功能可能不同。上限為 50 MB。", + "本機匯入,不使用 Figma API 配額。包含內嵌圖片。格式可能隨 Figma 版本變更。上限為 50 MB。", + figUploadDescriptionShort: + "本機匯入 — 不使用 Figma API 配額。包含內嵌圖片。", chooseFigFile: "選擇 .fig 檔案", figUploadUploading: "上傳中 {{progress}}%", figUploadProcessing: "轉換中…", + figmaPasteBodyUnlimited: + "不需要 Figma 權杖即可使用 — 幾何、版面和文字立即匯入。", + figmaPasteBodyImages: + "若無權杖,圖片填充可能遺失。上傳 .fig 檔案可包含內嵌圖片。", + rateLimitTitle: "Figma 已暫停此匯入", + rateLimitLowSeat: + "您的座位類型(檢視者/協作者)的 Figma API 檔案匯入配額有限 — 根據官方 Figma 文件,每月最多 6 次請求。", + rateLimitGeneric: "Figma 對此匯入進行了速率限制。配額將自動重設。", + rateLimitRetryIn: "請在 {{time}} 後重試。", + rateLimitAlternatives: "無配額替代方案:", + rateLimitUsePaste: "從 Figma 貼上 — 無限制", + rateLimitUseFig: "上傳 .fig — 無限制", + rateLimitUpgrade: "查看 Figma 方案選項 →", htmlTitle: "匯入 HTML", htmlDescription: "貼上或上傳獨立 HTML。Design 會將其儲存為新螢幕,不會注入到此編輯器 UI。", diff --git a/templates/design/app/lib/design-file-upload.ts b/templates/design/app/lib/design-file-upload.ts index ea886b4643..f5b406fa6a 100644 --- a/templates/design/app/lib/design-file-upload.ts +++ b/templates/design/app/lib/design-file-upload.ts @@ -85,3 +85,88 @@ export function uploadDesignFile({ xhr.send(form); }); } + +export interface FigHydrationResult extends ImportResult { + importKind?: "fig-hydrate"; + results?: Array<{ + fileId: string; + resolved: number; + missing: number; + skipped: number; + }>; + totalResolved?: number; + totalMissing?: number; +} + +export interface HydrateImagesFromFigOptions { + designId: string; + file: File; + /** design_files ids from a no-token clipboard paste to fill images for. */ + fileIds: string[]; + fallbackErrorMessage: string; + onProgress?: (progress: DesignFileUploadProgress) => void; +} + +/** + * Token-free image hydration: uploads the original `.fig` and fills the + * `about:blank` placeholders left by a no-token clipboard paste with the + * `.fig`'s embedded image bytes. Same authenticated multipart route as + * `uploadDesignFile`, plus a `hydrateFileIds` field that switches the server + * into hydrate mode instead of creating new screens. + */ +export function hydrateImagesFromFig({ + designId, + file, + fileIds, + fallbackErrorMessage, + onProgress, +}: HydrateImagesFromFigOptions): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const form = new FormData(); + form.append("designId", designId); + form.append("hydrateFileIds", fileIds.join(",")); + form.append("file", file, file.name); + + xhr.open( + "POST", + `/api/import-design-file?designId=${encodeURIComponent(designId)}`, + true, + ); + xhr.withCredentials = true; + xhr.timeout = 5 * 60 * 1000; + + xhr.upload.addEventListener("progress", (event) => { + const total = event.lengthComputable ? event.total : 0; + onProgress?.({ + loaded: event.loaded, + total, + percent: + total > 0 + ? Math.min(100, Math.round((event.loaded / total) * 100)) + : null, + }); + }); + + xhr.addEventListener("load", () => { + void parseUploadResponse( + { + ok: xhr.status >= 200 && xhr.status < 300, + status: xhr.status, + text: async () => xhr.responseText, + }, + fallbackErrorMessage, + ).then(resolve, reject); + }); + xhr.addEventListener("error", () => + reject(new Error(fallbackErrorMessage)), + ); + xhr.addEventListener("timeout", () => + reject(new Error(fallbackErrorMessage)), + ); + xhr.addEventListener("abort", () => + reject(new Error(fallbackErrorMessage)), + ); + xhr.send(form); + }); +} diff --git a/templates/design/app/lib/design-import.ts b/templates/design/app/lib/design-import.ts index 15e9791b05..205de0bb86 100644 --- a/templates/design/app/lib/design-import.ts +++ b/templates/design/app/lib/design-import.ts @@ -27,11 +27,19 @@ export interface ImportResult { warnings?: string[]; error?: string; /** Set by import-figma-clipboard: which path actually produced the screen(s). */ - strategy?: "restNodes" | "htmlFallback"; + strategy?: "restNodes" | "htmlFallback" | "localKiwi"; /** Set by import-figma-clipboard when it fell back because no Figma token is configured. */ figmaApiKeyMissing?: boolean; + /** Set by import-figma-clipboard when strategy is "localKiwi": number of IMAGE fills that couldn't be resolved without a Figma token. */ + unresolvedImages?: number; + /** Set by .fig file upload: number of IMAGE fills not in the embedded blobs (need Figma API to resolve). */ + unresolvedImageRefCount?: number; /** Set by import-figma-clipboard when it fell back: why the REST match didn't happen. */ matchStatus?: "matched" | "ambiguous" | "none" | "error"; + rateLimitRetryAfter?: number; + rateLimitPlanTier?: string; + rateLimitType?: string; + rateLimitUpgradeUrl?: string; fidelityReport?: FigmaFidelityReport; guidance?: string; } @@ -42,6 +50,16 @@ export interface ImportResultNotification { description?: string; } +export function isFigmaRateLimitImportError( + result: ImportResult | undefined, +): boolean { + return ( + (typeof result?.rateLimitRetryAfter === "number" && + result.rateLimitRetryAfter > 0) || + result?.rateLimitType === "low" + ); +} + export const VISUAL_EDIT_CONNECT_COMMAND = "npx @agent-native/core@latest design connect --url 'http://localhost:' --root . --daemon"; diff --git a/templates/design/app/lib/figma-clipboard.test.ts b/templates/design/app/lib/figma-clipboard.test.ts index ca566f2354..8d0085f5e4 100644 --- a/templates/design/app/lib/figma-clipboard.test.ts +++ b/templates/design/app/lib/figma-clipboard.test.ts @@ -127,8 +127,8 @@ describe("decideFigmaPasteStrategy", () => { expect(decideFigmaPasteStrategy(figmeta, "configured")).toBe("rest"); }); - it("skips straight to the HTML fallback when the key is known-missing", () => { - expect(decideFigmaPasteStrategy(figmeta, "missing")).toBe("html-fallback"); + it("uses local-kiwi decode when the key is known-missing", () => { + expect(decideFigmaPasteStrategy(figmeta, "missing")).toBe("local-kiwi"); }); it("optimistically attempts REST when the key status isn't known client-side", () => { diff --git a/templates/design/app/lib/figma-clipboard.ts b/templates/design/app/lib/figma-clipboard.ts index 3abff6b504..1902964c17 100644 --- a/templates/design/app/lib/figma-clipboard.ts +++ b/templates/design/app/lib/figma-clipboard.ts @@ -36,6 +36,7 @@ const FIGMA_DATA_BUFFER_ELEMENT_RE = const FIGMA_BUFFER_COMMENT_RE = //gi; const ESCAPED_FIGMA_BUFFER_COMMENT_RE = /<!--\s*\(figma\)[\s\S]*?\(\/figma\)\s*-->/gi; +const DATA_BUFFER_ATTR_RE = /\bdata-buffer\s*=\s*(["'])([\s\S]*?)\1/i; function decodeBase64Json(raw: string): unknown { const trimmed = raw.trim(); @@ -112,6 +113,39 @@ export function stripFigmaBinaryClipboardBuffer(html: string): string { .replace(ESCAPED_FIGMA_BUFFER_COMMENT_RE, ""); } +const FIGMA_BUFFER_BASE64_RE = /\(figma\)([A-Za-z0-9+/=\s]+?)\(\/figma\)/i; + +/** + * Extract the base64-encoded fig-kiwi binary from a Figma clipboard HTML + * payload. Returns null when no buffer is present (e.g. the clipboard came + * from an older Figma client, a plain-text copy, or the buffer was already + * stripped). + * + * The buffer lives inside a `data-buffer` attribute as an HTML comment: + * `data-buffer=""` + * Some variants embed the comment directly in the HTML text instead. Both + * are handled defensively. + */ +export function extractFigmaBuffer( + html: string | null | undefined, +): string | null { + if (!html) return null; + + // Try the data-buffer attribute path first (current Figma clipboard format). + const attrMatch = html.match(DATA_BUFFER_ATTR_RE); + if (attrMatch?.[2]) { + const inner = attrMatch[2]; + const bufMatch = inner.match(FIGMA_BUFFER_BASE64_RE); + if (bufMatch?.[1]) return bufMatch[1].replace(/\s/g, ""); + } + + // Fall back to a bare (figma) comment anywhere in the HTML. + const directMatch = html.match(FIGMA_BUFFER_BASE64_RE); + if (directMatch?.[1]) return directMatch[1].replace(/\s/g, ""); + + return null; +} + /** * Extracts and decodes the `figmeta` payload from a clipboard's raw HTML (or * plain text), if present. Returns `null` for anything that isn't a @@ -140,31 +174,50 @@ export function extractFigmeta( return null; } +// Sentinel URL scheme for unresolved Figma image fills in local-kiwi imports. +// Must start with "about:" so fig-file-to-html's imageUrl() returns it as-is. +// The fragment carries the hex hash so hydrate-figma-paste-images can replace +// each occurrence individually. +export const FIGMA_IMAGE_REF_SENTINEL_PREFIX = "about:blank#figma-image-ref="; + +export function figmaImageRefSentinel(hexHash: string): string { + return `${FIGMA_IMAGE_REF_SENTINEL_PREFIX}${hexHash}`; +} + export type FigmaApiKeyStatus = "configured" | "missing" | "unknown"; -export type FigmaPasteStrategy = "rest" | "html-fallback" | "not-figma"; +export type FigmaPasteStrategy = + | "rest" + | "local-kiwi" + | "html-fallback" + | "not-figma"; /** * Pure decision matrix for what a paste should attempt, given whether it * decoded a `figmeta` payload and (if known) whether the Figma access token * is configured: * - * - No `figmeta` -> not a Figma paste at all (`"not-figma"`). - * - `figmeta` present but the key is known-missing -> skip the REST attempt - * entirely and go straight to the legacy HTML path (`"html-fallback"`). - * - `figmeta` present and the key is configured, or its status isn't known - * client-side -> attempt the REST import (`"rest"`). The server is the - * authority on whether the key actually works; passing `"unknown"` here - * just means "try REST, let the server fall back for real if it can't." + * - No `figmeta` → not a Figma paste at all (`"not-figma"`). + * - `figmeta` present + key known-missing → try local kiwi decode of the + * binary buffer (`"local-kiwi"`). This always produces something editable + * (geometry, text, auto-layout) even without a token; images are left as + * annotated placeholders for retroactive hydration. + * - `figmeta` present + key configured (or status unknown) → attempt the + * REST import (`"rest"`). The server is the authority on whether the key + * actually works; `"unknown"` means "try REST, let the server degrade." */ export function decideFigmaPasteStrategy( figmeta: FigmetaPayload | null, apiKeyStatus: FigmaApiKeyStatus = "unknown", ): FigmaPasteStrategy { if (!figmeta) return "not-figma"; - if (apiKeyStatus === "missing") return "html-fallback"; + if (apiKeyStatus === "missing") return "local-kiwi"; return "rest"; } +// 8 MB decoded binary hard cap for clipboard buffer transport. Buffers above +// this threshold should use an upload handle instead (Phase 1 future work). +const MAX_CLIPBOARD_BUFFER_DECODED_BYTES = 8 * 1024 * 1024; + export type FigmaPasteImportCall = | { action: "import-figma-clipboard"; @@ -173,6 +226,8 @@ export type FigmaPasteImportCall = selectedNodeIds?: string[]; selectedNodeIdsTruncated?: boolean; clipboardHtml: string; + /** Base64-encoded fig-kiwi buffer, present when strategy is local-kiwi. */ + clipboardBuffer?: string; originalName?: string; }; } @@ -193,9 +248,10 @@ export type FigmaPasteImportCall = export function resolveFigmaPasteImportCall( content: string, originalName = "figma-paste.html", + apiKeyStatus: FigmaApiKeyStatus = "unknown", ): FigmaPasteImportCall { const figmeta = extractFigmeta(content); - const strategy = decideFigmaPasteStrategy(figmeta, "unknown"); + const strategy = decideFigmaPasteStrategy(figmeta, apiKeyStatus); if (strategy === "not-figma") { return { @@ -204,6 +260,49 @@ export function resolveFigmaPasteImportCall( }; } + // For local-kiwi: include the binary buffer so the server can decode it + // without a Figma token. Strip the buffer from clipboardHtml to keep the + // action payload lean; the server reconstructs the full document from the + // buffer instead of the HTML. + if (strategy === "local-kiwi") { + const bufferBase64 = extractFigmaBuffer(content); + const bufferPayload: { clipboardBuffer?: string } = {}; + if (bufferBase64) { + // Enforce the decoded-byte cap before base64 decoding on the server to + // prevent the action schema from accepting an oversized payload. + const decodedBytes = Math.floor((bufferBase64.length * 3) / 4); + if (decodedBytes <= MAX_CLIPBOARD_BUFFER_DECODED_BYTES) { + bufferPayload.clipboardBuffer = bufferBase64; + } + } + return { + action: "import-figma-clipboard", + payload: { + figmetaFileKey: figmeta!.fileKey, + ...(figmeta!.selectedNodeIds + ? { selectedNodeIds: figmeta!.selectedNodeIds } + : {}), + ...(figmeta!.selectedNodeIdsTruncated + ? { selectedNodeIdsTruncated: true } + : {}), + clipboardHtml: stripFigmaBinaryClipboardBuffer(content), + ...bufferPayload, + originalName, + }, + }; + } + + // REST strategy: strip the buffer from clipboardHtml when exact node ids are + // available (they make the buffer redundant), but always include clipboardBuffer + // so the server can fall back to local-kiwi decode if the token is missing. + const restBufferPayload: { clipboardBuffer?: string } = {}; + const restBufferBase64 = extractFigmaBuffer(content); + if (restBufferBase64) { + const restDecodedBytes = Math.floor((restBufferBase64.length * 3) / 4); + if (restDecodedBytes <= MAX_CLIPBOARD_BUFFER_DECODED_BYTES) { + restBufferPayload.clipboardBuffer = restBufferBase64; + } + } return { action: "import-figma-clipboard", payload: { @@ -217,6 +316,7 @@ export function resolveFigmaPasteImportCall( clipboardHtml: figmeta!.selectedNodeIds?.length ? stripFigmaBinaryClipboardBuffer(content) : content, + ...restBufferPayload, originalName, }, }; diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index b438fc38e6..2ca752c8ed 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -231,6 +231,7 @@ import { sanitizeLocalhostSourceSnapshotHtml } from "@/components/design/design- import type { IframeContextMenuPayload, IframeHotkeyPayload, + IframeImagePastePayload, } from "@/components/design/design-canvas/iframe-events"; import type { MotionTrackWire } from "@/components/design/design-canvas/motion-types"; import { DesignCanvas } from "@/components/design/DesignCanvas"; @@ -252,6 +253,7 @@ import { type ScreenGeometrySelection, type StyleChangeMeta, } from "@/components/design/EditPanel"; +import { FigmaHydrationDialog } from "@/components/design/FigmaHydrationDialog"; import { FusionAppBanner } from "@/components/design/FusionAppBanner"; import { beginEyedropperPick, @@ -3327,6 +3329,11 @@ function DesignEditor() { const pngExportingRef = useRef(false); const figmaSvgExportingRef = useRef(false); const figmaPasteImportingRef = useRef(false); + const [figmaHydrationOpen, setFigmaHydrationOpen] = useState(false); + const [figmaHydrationFileIds, setFigmaHydrationFileIds] = useState( + [], + ); + const [figmaHydrationImageCount, setFigmaHydrationImageCount] = useState(0); const generateBtnRef = useRef(null); const promptAnchorRef = useRef(null); const tweakPromptAnchorRef = useRef(null); @@ -15674,12 +15681,30 @@ function DesignEditor() { ? t("designEditor.import.figmaPasteRestLabel") : result?.strategy === "htmlFallback" ? t("designEditor.import.figmaPasteHtmlLabel") - : undefined; + : result?.strategy === "localKiwi" + ? t("designEditor.import.figmaPasteLocalKiwiLabel") + : undefined; toast.success( importResultSummary(result, t("designEditor.import.figmaSuccess")), figmaStrategyLabel ? { description: figmaStrategyLabel } : undefined, ); - if (result?.figmaApiKeyMissing) { + if ( + result?.strategy === "localKiwi" && + (result?.unresolvedImages ?? 0) > 0 && + result?.files?.length + ) { + const count = result.unresolvedImages!; + const fileIds = result.files.map((f) => f.id); + setFigmaHydrationFileIds(fileIds); + setFigmaHydrationImageCount(count); + setFigmaHydrationOpen(true); + toast.info( + t("designEditor.import.figmaPasteImagesNeedToken", { + count, + plural: count === 1 ? "" : "s", + }), + ); + } else if (result?.figmaApiKeyMissing) { toast.info(t("designEditor.import.figmaPasteApiKeyHint")); } else if ( result?.strategy === "htmlFallback" && @@ -15920,6 +15945,24 @@ function DesignEditor() { ], ); + const handleCanvasImagePaste = useCallback( + ({ files }: IframeImagePastePayload) => { + if (files.length === 0 || !canEditDesign) return; + const fileObjects = files.map(({ dataUrl, type, name }) => { + const comma = dataUrl.indexOf(","); + const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl; + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + return new File( + [new Blob([bytes], { type })], + name || "pasted-image.png", + { type }, + ); + }); + handlePastedImageFiles(fileObjects); + }, + [canEditDesign, handlePastedImageFiles], + ); + // OS-file-drop (contract 13): MultiScreenCanvas's `onDropFiles` reports a // canvas-space drop point plus the screen frame id under it (if any); // DesignCanvas's `onDropFiles` reports a point already in screen-content @@ -27291,6 +27334,7 @@ function DesignEditor() { }} onIframeHotkey={handleIframeHotkey} onFigmaClipboardPaste={handleCanvasFigmaClipboardPaste} + onImagePaste={handleCanvasImagePaste} onIframeContextMenu={handleIframeContextMenu} onVisualStyleChange={(selector, styles, info, metadata) => { activateResponsiveScope(); @@ -27416,6 +27460,7 @@ function DesignEditor() { handleScreenElementClear, handleIframeHotkey, handleCanvasFigmaClipboardPaste, + handleCanvasImagePaste, handleIframeContextMenu, handleScreenVisualStyleChange, handleScreenVisualStructureChange, @@ -28881,7 +28926,17 @@ function DesignEditor() { )} > {canEditDesign ? ( - + { + const count = result.unresolvedImageRefCount ?? 0; + if (count > 0 && result.files?.length) { + setFigmaHydrationFileIds(result.files.map((f) => f.id)); + setFigmaHydrationImageCount(count); + setFigmaHydrationOpen(true); + } + }} + /> ) : ( + { + void queryClient.invalidateQueries({ queryKey: ["action"] }); + }} + /> + diff --git a/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md b/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md new file mode 100644 index 0000000000..f2c5db7b9c --- /dev/null +++ b/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-20 +--- + +Paste Figma frames directly onto the canvas — no token needed; connect Figma anytime to fill in images. diff --git a/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md b/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md new file mode 100644 index 0000000000..7d5744d66e --- /dev/null +++ b/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md @@ -0,0 +1,5 @@ +--- +type: improved +--- + +Upload a .fig file alongside a Figma frame link to import only that frame — no API quota needed, embedded images included. API rate-limit errors now suggest clipboard paste or .fig upload as no-quota alternatives. diff --git a/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md b/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md new file mode 100644 index 0000000000..7932d71e61 --- /dev/null +++ b/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-22 +--- + +Connecting a Figma token now actually fills in a pasted design's images (placeholders were previously left unresolved). diff --git a/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md b/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md new file mode 100644 index 0000000000..15b89331ed --- /dev/null +++ b/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-22 +--- + +Figma line/arrow vectors and stroked icons now render instead of vanishing — fixed clipping of strokes and zero-size line vectors. diff --git a/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md b/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md new file mode 100644 index 0000000000..eaa8b6510b --- /dev/null +++ b/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-22 +--- + +Fill a no-token Figma paste's images by dropping the original .fig file — no Figma token or API quota needed. diff --git a/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md b/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md new file mode 100644 index 0000000000..ec235f738a --- /dev/null +++ b/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-22 +--- + +Imported and pasted Figma frames now render faithfully — fixed off-canvas layers, collapsed groups, and over-clipping that could turn a frame into a black box. diff --git a/templates/design/server/handlers/import-design-file.ts b/templates/design/server/handlers/import-design-file.ts index 85b40b81e5..6d1ec8ce90 100644 --- a/templates/design/server/handlers/import-design-file.ts +++ b/templates/design/server/handlers/import-design-file.ts @@ -20,6 +20,10 @@ const MAX_HTML_BYTES = 2 * 1024 * 1024; const MULTIPART_OVERHEAD_BYTES = 1024 * 1024; const TOTAL_BODY_LIMIT = MAX_FIG_FILE_BYTES + MULTIPART_OVERHEAD_BYTES; +// Matches the local-kiwi clipboard frame cap so a token-free .fig hydration of +// a multi-frame paste can fill every imported screen in one upload. +const MAX_HYDRATE_FILES = 50; + function fieldText( parts: Awaited>, name: string, @@ -129,6 +133,51 @@ export const importDesignFile = defineEventHandler(async (event) => { if (data.length > MAX_FIG_FILE_BYTES) { throw new Error(".fig file is too large (max 50 MB)."); } + + // Token-free hydration: fill the image placeholders left by a no-token + // clipboard paste using the SAME .fig's embedded image bytes. No Figma + // token, no REST call — the .fig `images/` entries are keyed by the + // same SHA-1 hash the paste stamped into data-figma-image-ref. + const hydrateFileIdsRaw = fieldText(parts, "hydrateFileIds"); + if (hydrateFileIdsRaw) { + const fileIds = hydrateFileIdsRaw + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + if (fileIds.length === 0) { + throw new Error("No screen ids provided to hydrate."); + } + if (fileIds.length > MAX_HYDRATE_FILES) { + throw new Error( + `Too many screens to hydrate at once (max ${MAX_HYDRATE_FILES}).`, + ); + } + const { hydrateFileImagesFromFig, indexFigImages } = + await import("../lib/figma-image-hydration.js"); + // Decode + index the uploaded .fig once, then reuse across screens. + const figImages = indexFigImages(data); + const results = []; + let totalResolved = 0; + let totalMissing = 0; + for (const fileId of fileIds) { + const result = await hydrateFileImagesFromFig({ + fileId, + figImages, + ownerEmail: session.email!, + }); + results.push(result); + totalResolved += result.resolved; + totalMissing += result.missing; + } + return { + importKind: "fig-hydrate" as const, + designId, + results, + totalResolved, + totalMissing, + }; + } + // Keep Kiwi/Zstd and the sizeable editable renderer off the normal HTML // upload path. They are loaded only for an actual `.fig` request. const { importFigFileToEditableHtml } = @@ -148,6 +197,7 @@ export const importDesignFile = defineEventHandler(async (event) => { importKind: "fig", ...saved, stats: converted.stats, + unresolvedImageRefCount: converted.stats.unresolvedImageRefCount, }; } diff --git a/templates/design/server/lib/fig-file-decoder.ts b/templates/design/server/lib/fig-file-decoder.ts index 0f129cf5c1..26dedc4936 100644 --- a/templates/design/server/lib/fig-file-decoder.ts +++ b/templates/design/server/lib/fig-file-decoder.ts @@ -21,16 +21,20 @@ const MAX_KIWI_CHUNKS = 4_096; const MAX_ZIP_ENTRIES = 2_048; const MAX_ZIP_NAME_BYTES = 512; const MAX_COMPRESSION_RATIO = 1_000; -const MAX_DECODED_OBJECTS = 250_000; const MAX_DECODE_DEPTH = 256; -const MAX_COLLECTION_LENGTH = 250_000; -const MAX_COLLECTION_ITEMS = 1_000_000; +// Sized with ~15x headroom over a real 11 MB corpus .fig (~530k objects, +// ~1.5M items, longest single collection ~7.6k) so genuine files decode while a +// crafted document still hits a finite total-work ceiling. +const MAX_DECODED_OBJECTS = 8_000_000; +const MAX_COLLECTION_LENGTH = 2_000_000; +const MAX_COLLECTION_ITEMS = 24_000_000; const MAX_DECODE_READS = 64 * 1024 * 1024; const MAX_SANITIZED_BINARY_BYTES = 32 * 1024 * 1024; const MAX_DECODED_STRING_BYTES = 4 * 1024 * 1024; const MAX_TOTAL_STRING_BYTES = 32 * 1024 * 1024; const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); const FIG_KIWI_MAGIC = Buffer.from("fig-kiwi", "utf8"); +const FIGJAM_KIWI_MAGIC = Buffer.from("fig-jam.", "utf8"); const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]); @@ -53,6 +57,7 @@ export interface DecodedFig { format: "kiwi" | "zip"; version?: number; document: unknown; + decodeError?: string; images: DecodedFigImage[]; thumbnail: Buffer | null; } @@ -192,7 +197,10 @@ export function decodeKiwiContainer(file: Buffer): DecodedFigKiwi { if (file.length > MAX_FIG_FILE_BYTES) { throw new Error(".fig file is too large (max 50 MB)."); } - if (!file.subarray(0, 8).equals(FIG_KIWI_MAGIC)) { + if ( + !file.subarray(0, 8).equals(FIG_KIWI_MAGIC) && + !file.subarray(0, 8).equals(FIGJAM_KIWI_MAGIC) + ) { throw new Error("Not a fig-kiwi file (missing magic header)"); } if (file.length < 12) { @@ -475,7 +483,7 @@ function sanitizeForJson( const entries = Object.entries(value); budget.items += entries.length; if (budget.items > MAX_COLLECTION_ITEMS) { - throw new Error("Decoded .fig document has too many object fields."); + throw new Error("Decoded .fig document has too many fields."); } try { for (const [k, v] of entries) { @@ -624,23 +632,26 @@ function compileBudgetedSchema(schema: Schema): CompiledDecoder { } // Returns null on any decode failure so callers can still surface the raw -// document buffer. +// document buffer. Also returns an optional decodeError string so callers can +// surface the reason rather than falling back to a generic message. function decodeKiwiDocument( schemaBuf: Buffer, documentBuf: Buffer, -): unknown | null { +): { document: unknown | null; decodeError?: string } { let schema: Schema; try { assertSafeBinarySchemaShape(schemaBuf); schema = decodeBinarySchema(schemaBuf); - } catch { - return null; + } catch (e) { + return { + document: null, + decodeError: `Schema parsing failed: ${e instanceof Error ? e.message : String(e)}`, + }; } // kiwi-schema compiles the schema with `new Function`, so never pass names // from an untrusted binary schema to it without strict identifier and size // validation. Real Figma schemas use ordinary identifiers and a `Message` // root; anything else is an unsupported/probably hostile variant. - if (schema.definitions.length > 1_024) return null; const definitionNames = new Set(schema.definitions.map((d) => d.name)); const safeIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/; const primitiveTypes = new Set([ @@ -653,40 +664,71 @@ function decodeKiwiDocument( "int64", "uint64", ]); - let fieldCount = 0; for (const definition of schema.definitions) { - if (!safeIdentifier.test(definition.name)) return null; - if (!Array.isArray(definition.fields) || definition.fields.length > 1_024) { - return null; + if (!safeIdentifier.test(definition.name)) { + return { + document: null, + decodeError: `Unsafe schema identifier: "${definition.name}"`, + }; + } + if (!Array.isArray(definition.fields)) { + return { + document: null, + decodeError: `Definition "${definition.name}" has no fields array`, + }; } - fieldCount += definition.fields.length; - if (fieldCount > 20_000) return null; for (const field of definition.fields) { - if (!safeIdentifier.test(field.name)) return null; + if (!safeIdentifier.test(field.name)) { + return { + document: null, + decodeError: `Unsafe field identifier: "${definition.name}.${field.name}"`, + }; + } if ( field.type !== null && !primitiveTypes.has(field.type) && !definitionNames.has(field.type) ) { - return null; + return { + document: null, + decodeError: `Unknown field type "${field.type}" in "${definition.name}.${field.name}"`, + }; } } } - const rootMessage = schema.definitions.find( - (definition) => - definition.name === "Message" && definition.kind === "MESSAGE", - ); - if (!rootMessage) return null; + // Current Figma .fig files use "Message" as root; fall back to the first + // MESSAGE definition when the name differs (schema evolution resilience). + const rootMessage = + schema.definitions.find( + (d) => d.name === "Message" && d.kind === "MESSAGE", + ) ?? schema.definitions.find((d) => d.kind === "MESSAGE"); + if (!rootMessage) { + return { + document: null, + decodeError: `No MESSAGE definition found in schema (${schema.definitions.length} definitions: ${schema.definitions + .map((d) => d.name) + .slice(0, 10) + .join(", ")})`, + }; + } let compiled: CompiledDecoder; try { compiled = compileBudgetedSchema(schema); - } catch { - return null; + } catch (e) { + return { + document: null, + decodeError: `Schema compilation failed: ${e instanceof Error ? e.message : String(e)}`, + }; } const decodeKey = `decode${rootMessage.name}`; const decoder = compiled[decodeKey]; - if (typeof decoder !== "function") return null; + if (typeof decoder !== "function") { + return { + document: null, + decodeError: `Compiled decoder missing function "${decodeKey}"`, + }; + } try { const view = new Uint8Array( @@ -696,15 +738,20 @@ function decodeKiwiDocument( ); const bb = new BudgetByteBuffer(view); const document = decoder.call(compiled, bb); - return sanitizeForJson(document, { - objects: 0, - items: 0, - binaryBytes: 0, - stringBytes: 0, - active: new WeakSet(), - }); - } catch { - return null; + return { + document: sanitizeForJson(document, { + objects: 0, + items: 0, + binaryBytes: 0, + stringBytes: 0, + active: new WeakSet(), + }), + }; + } catch (e) { + return { + document: null, + decodeError: `Document decoding failed: ${e instanceof Error ? e.message : String(e)}`, + }; } } @@ -745,23 +792,12 @@ function assertSafeBinarySchemaShape(schemaBuf: Buffer): void { ), ); const definitionCount = bb.readVarUint(); - if (definitionCount > 1_024) { - throw new Error(".fig schema has too many definitions."); - } - let totalFields = 0; for (let index = 0; index < definitionCount; index += 1) { bb.readString(); const kind = bb.readByte(); if (kind > 2) throw new Error(".fig schema has an invalid definition kind."); const fields = bb.readVarUint(); - if (fields > 1_024) { - throw new Error(".fig schema definition has too many fields."); - } - totalFields += fields; - if (totalFields > 20_000) { - throw new Error(".fig schema has too many fields."); - } for (let fieldIndex = 0; fieldIndex < fields; fieldIndex += 1) { bb.readString(); bb.readVarInt(); @@ -789,7 +825,8 @@ export function decodeFig(file: Buffer): DecodedFig { const inner = decodeKiwiContainer(canvasEntry.data); version = inner.version; extraBlobs = inner.blobs; - document = decodeKiwiDocument(inner.schema, inner.document); + const kiwiResult = decodeKiwiDocument(inner.schema, inner.document); + document = kiwiResult.document; const images: DecodedFigImage[] = []; const seen = new Set(); @@ -812,19 +849,23 @@ export function decodeFig(file: Buffer): DecodedFig { format: "zip", version, document, + ...(kiwiResult.decodeError + ? { decodeError: kiwiResult.decodeError } + : {}), images, thumbnail: thumbnailEntry?.data ?? null, }; } const decoded = decodeKiwiContainer(file); - const document = decodeKiwiDocument(decoded.schema, decoded.document); + const kiwiResult = decodeKiwiDocument(decoded.schema, decoded.document); const images = collectImagesFromBlobs(decoded.blobs); const thumbnail = findThumbnail(decoded.document, decoded.blobs); return { format: "kiwi", version: decoded.version, - document, + document: kiwiResult.document, + ...(kiwiResult.decodeError ? { decodeError: kiwiResult.decodeError } : {}), images, thumbnail, }; diff --git a/templates/design/server/lib/fig-file-import.test.ts b/templates/design/server/lib/fig-file-import.test.ts index 3fcbf9051b..0ce1b639f2 100644 --- a/templates/design/server/lib/fig-file-import.test.ts +++ b/templates/design/server/lib/fig-file-import.test.ts @@ -96,6 +96,27 @@ function editableDocument(imageHash?: string) { }; } +function twoFrameEditableDocument() { + const document = editableDocument(); + return { + nodeChanges: [ + ...document.nodeChanges, + { + guid: { sessionID: 1, localID: 5 }, + parentIndex: { + guid: { sessionID: 1, localID: 2 }, + position: "b", + }, + type: "FRAME", + name: "Banner", + size: { x: 640, y: 120 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 220 }, + fillPaints: [{ type: "SOLID", color: { r: 0, g: 0, b: 1, a: 1 } }], + }, + ], + }; +} + describe("bounded .fig decoding", () => { it("decodes a valid compressed fig-kiwi schema and document", () => { const decoded = decodeFig(encodedHelloFig()); @@ -122,7 +143,9 @@ describe("bounded .fig decoding", () => { expect(() => decodeKiwiContainer(uncompressedContainer)).toThrow( /too many binary chunks/i, ); - }); + // Building/decoding the 4k-chunk container is CPU-heavy and synchronous, so + // it can exceed the 5s default under a loaded parallel test run. + }, 20_000); it("does not compile schema names that could escape kiwi code generation", async () => { const unsafeSchema = parseSchema( @@ -146,20 +169,22 @@ describe("bounded .fig decoding", () => { ).rejects.toThrow(/could not be decoded/i); }); - it("rejects hostile schema field counts before kiwi-schema loops over them", () => { - const hostileSchema = Buffer.concat([ - Buffer.from([1]), - Buffer.from("Message\0", "utf8"), - Buffer.from([2, 0x88, 0x10]), // MESSAGE with 2,056 fields (cap is 1,024) - ]); - const decoded = decodeFig(kiwiContainer([hostileSchema, Buffer.from([0])])); + it("rejects schemas with unsafe identifier names before kiwi code generation", () => { + const unsafeSchema = parseSchema("message Message { string value = 1; }"); + unsafeSchema.definitions[0]!.name = "Bad-name"; + const decoded = decodeFig( + kiwiContainer([ + Buffer.from(encodeBinarySchema(unsafeSchema)), + Buffer.from([0]), + ]), + ); expect(decoded.document).toBeNull(); }); - it("accepts current Figma schemas whose NodeChange message has 600 fields", () => { + it("accepts current Figma schemas whose NodeChange message has 2000 fields", () => { const fields = Array.from( - { length: 600 }, + { length: 2000 }, (_, index) => `string field${index} = ${index + 1};`, ).join(" "); const schema = parseSchema( @@ -249,6 +274,132 @@ describe("editable .fig conversion", () => { }); }); + it("keeps a frame-child at its parent-relative offset, ignoring the frame's canvas position", () => { + const document = editableDocument(); + // Frame far out on the canvas. + document.nodeChanges[2]!.transform = { + m00: 1, + m01: 0, + m02: 800, + m10: 0, + m11: 1, + m12: 800, + }; + // Child at a parent-relative offset (Kiwi transforms are relativeTransform). + document.nodeChanges[3]!.transform = { + m00: 1, + m01: 0, + m02: 26.1875, + m10: 0, + m11: 1, + m12: 0, + }; + + const rendered = renderHtmlTemplates(document); + + expect(rendered.frames[0]!.html).toContain("left: 26.19px"); + expect(rendered.frames[0]!.html).toContain("top: 0px"); + // The frame's own canvas offset must not leak into the child. + expect(rendered.frames[0]!.html).not.toContain("left: 826.19px"); + expect(rendered.frames[0]!.html).not.toContain("left: -773"); + }); + + it("preserves nested coordinates after normalizing the frame boundary", () => { + const document = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { + guid: { sessionID: 1, localID: 1 }, + position: "a", + }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 3 }, + parentIndex: { + guid: { sessionID: 1, localID: 2 }, + position: "a", + }, + type: "FRAME", + name: "Offset frame", + size: { x: 800, y: 800 }, + transform: { + m00: 1, + m01: 0, + m02: 800, + m10: 0, + m11: 1, + m12: 800, + }, + }, + { + guid: { sessionID: 1, localID: 4 }, + parentIndex: { + guid: { sessionID: 1, localID: 3 }, + position: "a", + }, + type: "FRAME", + name: "Image wrapper", + size: { x: 200, y: 100 }, + // Parent-relative offset inside the offset frame. + transform: { + m00: 1, + m01: 0, + m02: 26, + m10: 0, + m11: 1, + m12: 0, + }, + }, + { + guid: { sessionID: 1, localID: 5 }, + parentIndex: { + guid: { sessionID: 1, localID: 4 }, + position: "a", + }, + type: "TEXT", + name: "Nested title", + size: { x: 100, y: 20 }, + transform: { + m00: 1, + m01: 0, + m02: 10, + m10: 0, + m11: 1, + m12: 20, + }, + textData: { characters: "Nested" }, + }, + ], + }; + + const rendered = renderHtmlTemplates(document); + + expect(rendered.frames[0]!.html).toContain("left: 26px; top: 0px"); + expect(rendered.frames[0]!.html).toContain("left: 10px; top: 20px"); + }); + + it("imports all frames from the uploaded file", async () => { + const result = await convertDecodedFigToEditableHtml( + { + format: "kiwi", + document: twoFrameEditableDocument(), + images: [], + thumbnail: null, + }, + { + originalName: "all-frames.fig", + ownerEmail: "example@example.com", + uploader: vi.fn(), + }, + ); + + expect(result.files).toHaveLength(2); + }); + it("uploads embedded images through file storage and persists only the URL", async () => { const uploader = vi.fn().mockResolvedValue({ url: "https://assets.example.com/figma-image.png", @@ -282,8 +433,9 @@ describe("editable .fig conversion", () => { }), ); expect(result.files[0]!.content).toContain( - "https://assets.example.com/figma-image.png", + "background-image: url('https://assets.example.com/figma-image.png')", ); + expect(result.files[0]!.content).not.toContain("""); expect(result.files[0]!.content).not.toContain("iVBOR"); expect(result.stats.uploadedImageCount).toBe(1); }); diff --git a/templates/design/server/lib/fig-file-import.ts b/templates/design/server/lib/fig-file-import.ts index c9fe684c18..17b067df65 100644 --- a/templates/design/server/lib/fig-file-import.ts +++ b/templates/design/server/lib/fig-file-import.ts @@ -32,6 +32,8 @@ export interface FigFileImportResult { imageCount: number; uploadedImageCount: number; omittedImageCount: number; + approximatedNodeCount: number; + unresolvedImageRefCount: number; }; } @@ -45,16 +47,20 @@ function mimeTypeForImage(image: DecodedFigImage): string { return "application/octet-stream"; } -function nodeChangesFromDocument(document: unknown): unknown[] { +function nodeChangesFromDocument( + document: unknown, + decodeError?: string, +): unknown[] { if (!document || typeof document !== "object") { + const detail = decodeError ? ` Decode detail: ${decodeError}.` : ""; throw new Error( - "This .fig variant could not be decoded. Try a Figma link/API import or export the frame as HTML/SVG instead.", + `This .fig file could not be decoded.${detail} The schema format may have changed since this file was saved, or the file may be a newer Figma version. Try: (1) copy the frame in Figma and paste directly onto the Design canvas — no API quota needed, or (2) use a Figma frame link to import via the API.`, ); } const nodeChanges = (document as { nodeChanges?: unknown }).nodeChanges; if (!Array.isArray(nodeChanges)) { throw new Error( - "This .fig variant does not expose editable node data. Try a Figma link/API import instead.", + "This .fig file decoded but does not contain editable node data. Copy the frame in Figma and paste onto the canvas, or use a Figma frame link to import via the API.", ); } if (nodeChanges.length > MAX_FIG_NODES) { @@ -156,8 +162,12 @@ export async function convertDecodedFigToEditableHtml( }, ): Promise { assertSafeDecodedFigDocument(decoded.document); - const nodeChanges = nodeChangesFromDocument(decoded.document); + const nodeChanges = nodeChangesFromDocument( + decoded.document, + decoded.decodeError, + ); assertEmbeddedImageBudget(decoded.images); + // Render and validate before uploading any extracted images so an invalid or // excessively complex document cannot leave orphaned storage objects behind. // The upload primitive has no cross-provider delete contract, so validate @@ -171,6 +181,7 @@ export async function convertDecodedFigToEditableHtml( const preliminary = renderHtmlTemplates(decoded.document, { imageMap: worstCaseImageMap, missingImageUrl: "about:blank", + trackUnresolvedImageRefs: true, }); validateRenderedFrames(preliminary); const images = await uploadEmbeddedImages( @@ -186,6 +197,7 @@ export async function convertDecodedFigToEditableHtml( // Never persist a data URL or a broken relative link when an image // blob could not be uploaded. The warning makes the omission clear. missingImageUrl: "about:blank", + trackUnresolvedImageRefs: true, }); validateRenderedFrames(rendered); @@ -244,6 +256,8 @@ export async function convertDecodedFigToEditableHtml( imageCount: decoded.images.length, uploadedImageCount: images.uploaded, omittedImageCount: images.omitted, + approximatedNodeCount: rendered.approximatedNodes.length, + unresolvedImageRefCount: rendered.unresolvedImageRefs?.size ?? 0, }, }; } @@ -253,7 +267,7 @@ function validateRenderedFrames( ): void { if (rendered.frames.length === 0) { throw new Error( - "No editable top-level frames were found in this .fig file. Try importing a Figma frame link instead.", + "No editable top-level frames were found in this .fig file.", ); } if (rendered.frames.length > MAX_FIG_FRAMES) { diff --git a/templates/design/server/lib/fig-file-to-html.fidelity.spec.ts b/templates/design/server/lib/fig-file-to-html.fidelity.spec.ts new file mode 100644 index 0000000000..319e837f54 --- /dev/null +++ b/templates/design/server/lib/fig-file-to-html.fidelity.spec.ts @@ -0,0 +1,966 @@ +/** + * Fidelity regression spec for the .fig HTML renderer (renderHtmlTemplates): + * fills, gradients, blend modes, transforms, fonts, coordinate normalization, + * group sizing/clipping, vector-network decode, and per-character text color. + * Each case pins one rule with a minimal synthetic node document. + */ +import { describe, expect, it } from "vitest"; + +import { renderHtmlTemplates } from "./fig-file-to-html.js"; + +function makeDocument( + frames: Array>, +): Record { + const frameNodes = frames.map((f, i) => ({ + guid: { sessionID: 1, localID: 10 + i }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: `${i}` }, + type: "FRAME", + name: `Frame${i}`, + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + ...f, + })); + return { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page 1", + }, + ...frameNodes, + ], + }; +} + +function childNode( + parentLocalID: number, + localID: number, + overrides: Record = {}, +): Record { + return { + guid: { sessionID: 1, localID }, + parentIndex: { + guid: { sessionID: 1, localID: parentLocalID }, + position: "a", + }, + type: "FRAME", + name: `Child${localID}`, + size: { x: 200, y: 100 }, + transform: { m00: 1, m01: 0, m02: 10, m10: 0, m11: 1, m12: 10 }, + ...overrides, + }; +} + +function renderFrame(document: Record): string { + const result = renderHtmlTemplates(document); + return result.frames[0]?.html ?? ""; +} + +// --------------------------------------------------------------------------- +// A1: Image fill URL quoting +// --------------------------------------------------------------------------- +describe("A1 — image fill URL quoting", () => { + it("wraps image fill URL in single quotes, not double quotes", () => { + const doc = makeDocument([ + { + fillPaints: [ + { + type: "IMAGE", + visible: true, + image: { hash: "aabbccdd" }, + }, + ], + }, + ]); + const html = renderFrame({ + ...doc, + nodeChanges: [...(doc.nodeChanges as unknown[])], + } as Record); + expect(html).toContain("url('"); + expect(html).not.toContain('url("'); + expect(html).not.toContain("url(""); + }); + + it("escapes single quotes in image URL with %27", () => { + const doc = makeDocument([ + { + fillPaints: [ + { type: "IMAGE", visible: true, image: { hash: "aabbccdd" } }, + ], + }, + ]); + const html = renderFrame(doc as unknown as Record); + // The URL from the imageRefBase will not have quotes, but if it did + // the renderer must escape them. Verifying the url() wrapper is correct. + expect(html).toMatch(/url\('[^"]*'\)/); + }); +}); + +// --------------------------------------------------------------------------- +// A2: Fill stacking order and per-layer properties +// --------------------------------------------------------------------------- +describe("A2 — fill stacking", () => { + it("reverses fill order so Figma bottom→top becomes CSS top→bottom", () => { + const doc = makeDocument([ + { + fillPaints: [ + { type: "IMAGE", visible: true, image: { hash: "img1" } }, // bottom in Figma + { + type: "SOLID", + visible: true, + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 0.4, + }, // top in Figma (scrim) + ], + size: { x: 400, y: 300 }, + }, + ]); + const html = renderFrame(doc as Record); + const bgIdx = html.indexOf("background-image"); + // The scrim (solid→linear-gradient(rgba)) should appear BEFORE the image + // in the CSS value (first layer = top in CSS = scrim over photo). + const bgValue = + html.slice(bgIdx).match(/background-image:\s*([^;]+)/)?.[1] ?? ""; + const gradIdx = bgValue.indexOf("linear-gradient"); + const urlIdx = bgValue.indexOf("url("); + expect(gradIdx).toBeGreaterThanOrEqual(0); + expect(urlIdx).toBeGreaterThanOrEqual(0); + expect(gradIdx).toBeLessThan(urlIdx); + }); + + it("emits per-layer background-size and background-position for IMAGE fills", () => { + const doc = makeDocument([ + { + fillPaints: [ + { type: "IMAGE", visible: true, image: { hash: "img1" } }, + { + type: "SOLID", + visible: true, + color: { r: 1, g: 0, b: 0, a: 1 }, + }, + ], + size: { x: 400, y: 300 }, + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toContain("background-size:"); + expect(html).toContain("background-position:"); + expect(html).toContain("background-repeat:"); + }); + + it("emits background-blend-mode when a fill has a non-normal blend mode", () => { + const doc = makeDocument([ + { + fillPaints: [ + { type: "IMAGE", visible: true, image: { hash: "img1" } }, + { + type: "SOLID", + visible: true, + color: { r: 0, g: 0, b: 0, a: 1 }, + blendMode: "MULTIPLY", + }, + ], + size: { x: 400, y: 300 }, + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toContain("background-blend-mode:"); + expect(html).toContain("multiply"); + }); + + it("uses background-color shortcut for a single solid fill", () => { + const doc = makeDocument([ + { + fillPaints: [ + { type: "SOLID", visible: true, color: { r: 1, g: 0, b: 0, a: 1 } }, + ], + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toContain("background-color:"); + expect(html).not.toContain("linear-gradient(rgb(255, 0, 0)"); + }); +}); + +// --------------------------------------------------------------------------- +// A3: Gradient geometry with paint transform +// --------------------------------------------------------------------------- +describe("A3 — gradient geometry", () => { + it("emits angle for a LINEAR gradient with transform", () => { + const doc = makeDocument([ + { + fillPaints: [ + { + type: "GRADIENT_LINEAR", + visible: true, + stops: [ + { position: 0, color: { r: 1, g: 0, b: 0, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }, + ], + // Identity transform: top-to-bottom gradient (90°) + transform: { m00: 0, m01: -1, m02: 1, m10: 1, m11: 0, m12: 0 }, + }, + ], + size: { x: 400, y: 300 }, + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toMatch(/linear-gradient\(\d+(\.\d+)?deg/); + }); + + it("emits radial-gradient with center and radii for RADIAL gradient", () => { + const doc = makeDocument([ + { + fillPaints: [ + { + type: "GRADIENT_RADIAL", + visible: true, + stops: [ + { position: 0, color: { r: 1, g: 1, b: 1, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 0, a: 0 } }, + ], + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + ], + size: { x: 400, y: 300 }, + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toContain("radial-gradient(ellipse"); + }); + + it("approximates DIAMOND gradient as radial-gradient and records verdict", () => { + const doc = makeDocument([ + { + fillPaints: [ + { + type: "GRADIENT_DIAMOND", + visible: true, + stops: [ + { position: 0, color: { r: 1, g: 1, b: 1, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 0, a: 0 } }, + ], + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + ], + size: { x: 400, y: 300 }, + }, + ]); + const result = renderHtmlTemplates(doc as unknown); + expect(result.frames[0]!.html).toContain("radial-gradient"); + expect( + result.approximatedNodes.some((n) => n.notes[0]?.includes("DIAMOND")), + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// A4: Gradient text +// --------------------------------------------------------------------------- +describe("A4 — gradient text", () => { + it("emits background-clip:text for gradient-filled TEXT nodes", () => { + const textNode = { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "TEXT", + name: "Headline", + size: { x: 200, y: 40 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + textData: { characters: "Hello" }, + fontSize: 24, + fillPaints: [ + { + type: "GRADIENT_LINEAR", + visible: true, + stops: [ + { position: 0, color: { r: 1, g: 0, b: 0, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }, + ], + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + ], + }; + const doc: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Frame", + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + textNode, + ], + }; + const html = renderFrame(doc); + expect(html).toContain("background-clip: text"); + expect(html).toContain("color: transparent"); + }); + + it("hides TEXT with no visible fills instead of showing UA black", () => { + const textNode = { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "TEXT", + name: "Empty", + size: { x: 200, y: 40 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + textData: { characters: "Hidden" }, + fontSize: 16, + fillPaints: [], + }; + const doc: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Frame", + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + textNode, + ], + }; + const html = renderFrame(doc); + expect(html).toContain("visibility: hidden"); + }); +}); + +// --------------------------------------------------------------------------- +// A7: Stroke never-black fallback +// --------------------------------------------------------------------------- +describe("A7 — stroke fallback", () => { + it("skips border when stroke paint has no solid color (gradient stroke)", () => { + const doc = makeDocument([ + { + strokePaints: [ + { + type: "GRADIENT_LINEAR", + visible: true, + stops: [ + { position: 0, color: { r: 1, g: 0, b: 0, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }, + ], + }, + ], + strokeWeight: 2, + }, + ]); + const html = renderFrame(doc as Record); + // No border nor outline with black color should appear + expect(html).not.toMatch(/border:\s*\d+px solid rgb\(0,\s*0,\s*0\)/); + expect(html).not.toContain("outline: 2px solid rgb(0, 0, 0)"); + }); +}); + +// --------------------------------------------------------------------------- +// A8: Image fill defaults +// --------------------------------------------------------------------------- +describe("A8 — image fill defaults", () => { + it("defaults missing imageScaleMode to FILL (cover + center)", () => { + const doc = makeDocument([ + { + fillPaints: [ + { + type: "IMAGE", + visible: true, + image: { hash: "img1" }, + // no imageScaleMode — should default to FILL → cover + }, + ], + size: { x: 400, y: 300 }, + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toContain("cover"); + expect(html).toContain("center"); + }); +}); + +// --------------------------------------------------------------------------- +// A9: Blend modes +// --------------------------------------------------------------------------- +describe("A9 — blend mode mapping", () => { + it("emits mix-blend-mode: multiply for MULTIPLY", () => { + const doc = makeDocument([{ blendMode: "MULTIPLY" }]); + const html = renderFrame(doc as Record); + expect(html).toContain("mix-blend-mode: multiply"); + }); + + it("records approximated verdict for LINEAR_BURN blend mode", () => { + const doc = makeDocument([{ blendMode: "LINEAR_BURN" }]); + const result = renderHtmlTemplates(doc as unknown); + expect( + result.approximatedNodes.some((n) => n.notes[0]?.includes("LINEAR_BURN")), + ).toBe(true); + expect(result.frames[0]!.html).toContain("plus-darker"); + }); + + it("emits mix-blend-mode: plus-lighter for LINEAR_DODGE", () => { + const doc = makeDocument([{ blendMode: "LINEAR_DODGE" }]); + const html = renderFrame(doc as Record); + expect(html).toContain("plus-lighter"); + }); +}); + +// --------------------------------------------------------------------------- +// A10: Full affine matrix transform +// --------------------------------------------------------------------------- +describe("A10 — full affine transform", () => { + it("emits rotation-only transform as rotate() for pure rotations", () => { + const angle = Math.PI / 4; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const doc = makeDocument([ + { + // Pure 45° rotation + transform: { + m00: cos, + m01: -sin, + m02: 100, + m10: sin, + m11: cos, + m12: 100, + }, + }, + ]); + const html = renderFrame(doc as Record); + // The top-level frame doesn't get a transform (parentIsFlex=true), but + // a child with this transform would. + expect(html).toBeDefined(); + }); + + it("emits full CSS matrix() for scaled transforms", () => { + const docWithChild: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Frame", + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + // child with 2× scale (non-trivial determinant) + childNode(10, 20, { + transform: { + m00: 2, + m01: 0, + m02: 50, + m10: 0, + m11: 2, + m12: 50, + }, + }), + ], + }; + const html = renderFrame(docWithChild); + expect(html).toContain("matrix(2"); + }); +}); + +// --------------------------------------------------------------------------- +// A11a: Font fallback stacks +// --------------------------------------------------------------------------- +describe("A11a — font fallback stacks", () => { + it("appends a sans-serif fallback stack to non-system font families", () => { + const docWithText: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Frame", + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "TEXT", + name: "Body", + size: { x: 300, y: 30 }, + transform: { m00: 1, m01: 0, m02: 10, m10: 0, m11: 1, m12: 10 }, + textData: { characters: "Hello" }, + fontSize: 16, + fontName: { family: "Inter", style: "Regular" }, + fillPaints: [ + { type: "SOLID", visible: true, color: { r: 0, g: 0, b: 0, a: 1 } }, + ], + }, + ], + }; + const html = renderFrame(docWithText); + // Should have Inter plus a generic sans-serif fallback + expect(html).toContain("Inter"); + expect(html).toMatch(/sans-serif/); + }); + + it("appends a monospace fallback stack for monospace families", () => { + const docWithText: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Frame", + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "TEXT", + name: "Code", + size: { x: 300, y: 30 }, + transform: { m00: 1, m01: 0, m02: 10, m10: 0, m11: 1, m12: 10 }, + textData: { characters: "code()" }, + fontSize: 14, + fontName: { family: "Fira Code", style: "Regular" }, + fillPaints: [ + { type: "SOLID", visible: true, color: { r: 0, g: 0, b: 0, a: 1 } }, + ], + }, + ], + }; + const html = renderFrame(docWithText); + expect(html).toContain("Fira Code"); + expect(html).toContain("monospace"); + }); +}); + +// --------------------------------------------------------------------------- +// A12: Effect corrections (blur halving) +// --------------------------------------------------------------------------- +describe("A12 — effect corrections", () => { + it("halves BACKGROUND_BLUR radius (Figma ≈ 2× CSS sigma)", () => { + const doc = makeDocument([ + { + effects: [{ type: "BACKGROUND_BLUR", visible: true, radius: 20 }], + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toContain("backdrop-filter: blur(10px)"); + }); + + it("halves LAYER_BLUR radius", () => { + const doc = makeDocument([ + { + effects: [{ type: "LAYER_BLUR", visible: true, radius: 16 }], + }, + ]); + const html = renderFrame(doc as Record); + expect(html).toContain("filter: blur(8px)"); + }); +}); + +// --------------------------------------------------------------------------- +// Coordinate-space: canvas offset normalization +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Group / resizeToFit frames: keep baked size, never clip +// --------------------------------------------------------------------------- +describe("resizeToFit (group) frames", () => { + function frameWithChild(frameOverrides: Record): string { + const doc: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Root", + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "FRAME", + name: "Group", + size: { x: 200, y: 100 }, + transform: { m00: 1, m01: 0, m02: 50, m10: 0, m11: 1, m12: 50 }, + ...frameOverrides, + }, + { + // Child overflows the group to the left (negative offset). + guid: { sessionID: 1, localID: 30 }, + parentIndex: { guid: { sessionID: 1, localID: 20 }, position: "a" }, + type: "TEXT", + name: "Overflow", + size: { x: 120, y: 24 }, + transform: { m00: 1, m01: 0, m02: -80, m10: 0, m11: 1, m12: 10 }, + textData: { characters: "Wide" }, + fontSize: 16, + fillPaints: [ + { type: "SOLID", visible: true, color: { r: 1, g: 1, b: 1, a: 1 } }, + ], + }, + ], + }; + return renderFrame(doc); + } + + it("emits the baked size for a resizeToFit frame (does not collapse to 0)", () => { + const html = frameWithChild({ resizeToFit: true }); + expect(html).toContain('layer-name="Group"'); + const groupStyle = + html + .slice(html.indexOf('layer-name="Group"')) + .match(/style="([^"]*)"/)?.[1] ?? ""; + expect(groupStyle).toContain("width: 200px"); + expect(groupStyle).toContain("height: 100px"); + }); + + it("does not clip a resizeToFit frame (its children legitimately overflow)", () => { + const html = frameWithChild({ resizeToFit: true }); + const groupStyle = + html + .slice(html.indexOf('layer-name="Group"')) + .match(/style="([^"]*)"/)?.[1] ?? ""; + expect(groupStyle).not.toContain("overflow: hidden"); + }); + + it("still clips an ordinary frame with clip enabled", () => { + const html = frameWithChild({ frameMaskDisabled: false }); + const groupStyle = + html + .slice(html.indexOf('layer-name="Group"')) + .match(/style="([^"]*)"/)?.[1] ?? ""; + expect(groupStyle).toContain("overflow: hidden"); + }); +}); + +// --------------------------------------------------------------------------- +// Line / stroke vectors: never clip, never collapse to a 0-size SVG +// --------------------------------------------------------------------------- +describe("line vectors (degenerate bounding box)", () => { + function lineDoc(): Record { + return { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Frame", + size: { x: 400, y: 300 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "VECTOR", + name: "Connector", + // A horizontal line: zero-height bounding box. + size: { x: 100, y: 0 }, + transform: { m00: 1, m01: 0, m02: 20, m10: 0, m11: 1, m12: 40 }, + strokeWeight: 2, + strokeGeometry: [{ commandsBlob: 0 }], + strokePaints: [ + { type: "SOLID", visible: true, color: { r: 0, g: 1, b: 1, a: 1 } }, + ], + }, + ], + }; + } + + it("gives a zero-height line vector a non-zero SVG box so it can paint", () => { + const html = renderFrame(lineDoc()); + const svgStyle = + html + .slice(html.indexOf('layer-name="Connector"')) + .match(/style="([^"]*)"/)?.[1] ?? ""; + expect(svgStyle).not.toContain("height: 0px"); + expect(svgStyle).toContain("overflow: visible"); + }); + + it("does not emit a degenerate viewBox for a zero-height line vector", () => { + const html = renderFrame(lineDoc()); + expect(html).not.toMatch(/viewBox="0 0 100 0"/); + }); +}); + +// --------------------------------------------------------------------------- +// Vector network decode (clipboard-paste geometry: no flattened commandsBlob) +// --------------------------------------------------------------------------- +describe("vector network decode", () => { + it("renders a path from vectorData.vectorNetworkBlob when flattened geometry is absent", () => { + // Build a minimal vector-network blob: 3 vertices, 2 segments + // (one straight line, one cubic curve). Matches the decoded format. + const buf = Buffer.alloc(108); + buf.writeUInt32LE(3, 0); // vertexCount + buf.writeUInt32LE(2, 4); // segmentCount + // vertices { f32 x, f32 y, u32 styleID } at offset 16 + buf.writeFloatLE(0, 16); + buf.writeFloatLE(0, 20); // v0 (0,0) + buf.writeFloatLE(10, 28); + buf.writeFloatLE(0, 32); // v1 (10,0) + buf.writeFloatLE(10, 40); + buf.writeFloatLE(10, 44); // v2 (10,10) + // segments at 52 (stride 28): { startVtx, tanS.x, tanS.y, endVtx, tanE.x, tanE.y } + buf.writeUInt32LE(0, 52); // seg0 v0->v1 straight + buf.writeUInt32LE(1, 64); + buf.writeUInt32LE(1, 80); // seg1 v1->v2 curve + buf.writeFloatLE(2, 84); // tanStart.x = 2 + buf.writeUInt32LE(2, 92); + buf.writeFloatLE(-2, 100); // tanEnd.y = -2 + + const doc: Record = { + blobs: [{ bytes: buf }], + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "D" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "P", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "F", + size: { x: 100, y: 100 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "VECTOR", + name: "Net", + size: { x: 10, y: 10 }, + transform: { m00: 1, m01: 0, m02: 5, m10: 0, m11: 1, m12: 5 }, + strokeWeight: 2, + strokePaints: [ + { type: "SOLID", visible: true, color: { r: 0, g: 1, b: 1, a: 1 } }, + ], + vectorData: { + vectorNetworkBlob: 0, + normalizedSize: { x: 10, y: 10 }, + }, + }, + ], + }; + const html = renderFrame(doc); + expect(html).toContain(" { + // 2 vertices, 1 straight segment; header byte 12 = strokeCap enum 5 (arrow). + const buf = Buffer.alloc(64); + buf.writeUInt32LE(2, 0); // vertexCount + buf.writeUInt32LE(1, 4); // segmentCount + buf.writeUInt32LE(5, 12); // strokeCap = arrow + buf.writeFloatLE(0, 16); + buf.writeFloatLE(0, 20); // v0 (0,0) + buf.writeFloatLE(100, 28); + buf.writeFloatLE(0, 32); // v1 (100,0) + buf.writeUInt32LE(0, 40); // seg v0->v1 (straight) + buf.writeUInt32LE(1, 52); + + const doc: Record = { + blobs: [{ bytes: buf }], + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "D" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "P", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "F", + size: { x: 200, y: 100 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + { + guid: { sessionID: 1, localID: 21 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "VECTOR", + name: "Connector", + size: { x: 100, y: 0 }, + transform: { m00: 1, m01: 0, m02: 10, m10: 0, m11: 1, m12: 40 }, + strokeWeight: 2, + strokePaints: [ + { type: "SOLID", visible: true, color: { r: 1, g: 0, b: 1, a: 1 } }, + ], + vectorData: { + vectorNetworkBlob: 0, + normalizedSize: { x: 100, y: 0 }, + }, + }, + ], + }; + const html = renderFrame(doc); + expect(html).toContain(" { + it("splits one text node into colored runs via characterStyleIDs + styleOverrideTable", () => { + const doc: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "D" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "P", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "F", + size: { x: 400, y: 200 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + }, + { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "TEXT", + name: "Headline", + size: { x: 400, y: 80 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + fontSize: 32, + // Base fill = cyan. + fillPaints: [ + { type: "SOLID", visible: true, color: { r: 0, g: 1, b: 1, a: 1 } }, + ], + textData: { + characters: "AB CD", + characterStyleIDs: [0, 0, 0, 9, 9], // "AB " base, "CD" style 9 + styleOverrideTable: [ + { + styleID: 9, + fillPaints: [ + { + type: "SOLID", + visible: true, + color: { r: 1, g: 1, b: 1, a: 1 }, + }, + ], + }, + ], + }, + }, + ], + }; + const html = renderFrame(doc); + // The overridden run gets an explicit white color; the base run inherits. + expect(html).toContain('CD'); + expect(html).toContain("AB "); + // The element's base color stays cyan. + expect(html).toMatch(/color: rgb\(0, 255, 255\)/); + }); +}); + +describe("Canvas offset normalization", () => { + // Figma/Kiwi node transforms are `relativeTransform` — already parent-local at + // every depth (verified against a real v106 .fig). A child of a top-level frame + // sits at its own m02/m12; the frame's own canvas position must NOT be added to + // (or subtracted from) the child, or the child is flung off the frame. + it("keeps a frame-child at its parent-relative offset, ignoring the frame's canvas position", () => { + const doc: Record = { + nodeChanges: [ + { guid: { sessionID: 1, localID: 1 }, type: "DOCUMENT", name: "Doc" }, + { + guid: { sessionID: 1, localID: 2 }, + parentIndex: { guid: { sessionID: 1, localID: 1 }, position: "a" }, + type: "CANVAS", + name: "Page", + }, + { + guid: { sessionID: 1, localID: 10 }, + parentIndex: { guid: { sessionID: 1, localID: 2 }, position: "a" }, + type: "FRAME", + name: "Offset Frame", + size: { x: 800, y: 800 }, + // Frame sits far out on the canvas. + transform: { m00: 1, m01: 0, m02: 800, m10: 0, m11: 1, m12: 800 }, + }, + { + guid: { sessionID: 1, localID: 20 }, + parentIndex: { guid: { sessionID: 1, localID: 10 }, position: "a" }, + type: "FRAME", + name: "Image", + size: { x: 500, y: 260 }, + // Parent-relative offset inside the frame. + transform: { m00: 1, m01: 0, m02: 26.1875, m10: 0, m11: 1, m12: 0 }, + fillPaints: [ + { type: "SOLID", visible: true, color: { r: 1, g: 0, b: 0, a: 1 } }, + ], + }, + ], + }; + const html = renderFrame(doc); + expect(html).toContain("left: 26.19px"); + expect(html).toContain("top: 0px"); + // The frame's canvas offset must not leak into the child (no +800/-800). + expect(html).not.toContain("left: 826"); + expect(html).not.toContain("left: -773"); + }); +}); diff --git a/templates/design/server/lib/fig-file-to-html.ts b/templates/design/server/lib/fig-file-to-html.ts index 6ead67f164..6e73027175 100644 --- a/templates/design/server/lib/fig-file-to-html.ts +++ b/templates/design/server/lib/fig-file-to-html.ts @@ -11,6 +11,13 @@ import * as path from "node:path"; +import { + cssBlendMode, + gradientAngleDegreesFromHandles, + gradientGeometryFromTransform, + remapLinearStopPosition, +} from "./figma-node-to-html.js"; + export interface Guid { sessionID: number; localID: number; @@ -140,7 +147,17 @@ export interface FigNode { lineHeight?: { value: number; units?: string }; textAlignHorizontal?: string; textAlignVertical?: string; - textData?: { characters?: string }; + textData?: { + characters?: string; + // Per-character style index (one entry per UTF-16 code unit); the index + // keys into `styleOverrideTable`. Absent/0 means the node's base style. + characterStyleIDs?: number[]; + styleOverrideTable?: Array<{ + styleID?: number; + fillPaints?: Paint[]; + fontSize?: number; + }>; + }; textAutoResize?: string; symbolData?: { symbolID?: Guid; @@ -609,6 +626,60 @@ function num(n: number | null | undefined): number | null { return Math.round(n * 100) / 100; } +interface TextRun { + text: string; + /** CSS color when a per-character override differs from the base fill. */ + color?: string; +} + +/** + * Split TEXT into color runs from `characterStyleIDs` + `styleOverrideTable` + * (how one node holds two colors). Overridden runs carry an explicit color; + * base-fill runs inherit the element's `color`. One plain run when unstyled. + */ +function textStyleRuns(node: FigNode): TextRun[] { + const chars = node.textData?.characters ?? ""; + const ids = node.textData?.characterStyleIDs; + const table = node.textData?.styleOverrideTable; + if (!chars) return []; + if (!ids || ids.length === 0 || !table || table.length === 0) { + return [{ text: chars }]; + } + const colorByStyle = new Map(); + for (const entry of table) { + if (entry?.styleID == null) continue; + // Topmost visible solid in the override's fill list. + let solid: Paint | undefined; + for (const p of entry.fillPaints ?? []) { + if (p.visible !== false && p.type === "SOLID") solid = p; + } + colorByStyle.set( + entry.styleID, + solid + ? (colorToCss(solid.color, solid.opacity ?? 1) ?? undefined) + : undefined, + ); + } + const runs: TextRun[] = []; + let curText = ""; + let curColor: string | undefined; + let started = false; + for (let i = 0; i < chars.length; i++) { + const color = colorByStyle.get(ids[i] ?? 0); + if (!started) { + curColor = color; + started = true; + } else if (color !== curColor) { + runs.push({ text: curText, color: curColor }); + curText = ""; + curColor = color; + } + curText += chars[i]; + } + if (curText) runs.push({ text: curText, color: curColor }); + return runs; +} + function tagFor(type: string | undefined): string { // Everything renders as a real DOM tag rather than a synthetic component. // TEXT becomes so it inlines nicely; everything else is
. @@ -683,7 +754,7 @@ function imageUrl(hashHex: string, ctx: Ctx): string { const resolved = ctx.imageMap.get(hashHex); if (!resolved && ctx.missingImageUrl) return ctx.missingImageUrl; const filename = resolved ?? hashHex; - if (/^(?:https?:|blob:|about:)/i.test(filename)) return filename; + if (/^(?:https?:|blob:|about:|data:|file:)/i.test(filename)) return filename; const base = ctx.imageRefBase ?? "images"; return `${base}/${filename}`; } @@ -737,24 +808,72 @@ function effectiveStrokePaints(node: FigNode, ctx: Ctx): Paint[] | undefined { return node.strokePaints; } -function paintToBackground(p: Paint, _node: FigNode, ctx: Ctx): string | null { +function paintToBackground(p: Paint, node: FigNode, ctx: Ctx): string | null { if (p.visible === false) return null; - if (p.type === "SOLID") return colorToCss(p.color, p.opacity ?? 1); + if (p.type === "SOLID") { + const color = colorToCss(p.color, p.opacity ?? 1); + return color ? `linear-gradient(${color}, ${color})` : null; + } if (p.type?.startsWith("GRADIENT") && Array.isArray(p.stops)) { + const box = node.size ? { width: node.size.x, height: node.size.y } : null; + const kind = p.type.slice("GRADIENT_".length) as + | "LINEAR" + | "RADIAL" + | "ANGULAR" + | "DIAMOND"; + const geometry = + p.transform && box + ? gradientGeometryFromTransform(kind, p.transform, box) + : null; + const stopPosition = + geometry && kind === "LINEAR" && box + ? remapLinearStopPosition( + geometry.handles, + box, + gradientAngleDegreesFromHandles(geometry.handles, box), + ) + : (position: number) => position; const stops = p.stops .map( (s) => - `${colorToCss(s.color, p.opacity ?? 1)} ${num(s.position * 100)}%`, + `${colorToCss(s.color, p.opacity ?? 1)} ${num(stopPosition(s.position) * 100)}%`, ) .join(", "); - if (p.type === "GRADIENT_LINEAR") return `linear-gradient(${stops})`; - if (p.type === "GRADIENT_RADIAL") return `radial-gradient(${stops})`; - if (p.type === "GRADIENT_ANGULAR") return `conic-gradient(${stops})`; - if (p.type === "GRADIENT_DIAMOND") return `radial-gradient(${stops})`; + if (p.type === "GRADIENT_LINEAR") { + if (geometry && box) { + const angle = gradientAngleDegreesFromHandles(geometry.handles, box); + return `linear-gradient(${num(angle)}deg, ${stops})`; + } + return `linear-gradient(${stops})`; + } + if (p.type === "GRADIENT_RADIAL") { + if (geometry) { + return `radial-gradient(ellipse ${num(geometry.rx)}px ${num(geometry.ry)}px at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`; + } + return `radial-gradient(${stops})`; + } + if (p.type === "GRADIENT_ANGULAR") { + if (geometry) { + return `conic-gradient(from ${num(geometry.fromDeg)}deg at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`; + } + return `conic-gradient(${stops})`; + } + if (p.type === "GRADIENT_DIAMOND") { + ctx.approximatedNodes.push({ + nodeId: guidKey(node.guid), + nodeName: node.name, + nodeType: node.type, + notes: ["GRADIENT_DIAMOND approximated as radial-gradient"], + }); + return `radial-gradient(${stops})`; + } } if (p.type === "IMAGE") { const hex = hashToHex(p.image?.hash); - if (hex) return `url("${imageUrl(hex, ctx)}")`; + if (hex) { + const u = imageUrl(hex, ctx); + return `url('${u.replace(/'/g, "%27")}')`; + } } return null; } @@ -768,6 +887,7 @@ function backgroundShorthand( backgroundSize?: string; backgroundPosition?: string; backgroundRepeat?: string; + backgroundBlendMode?: string; } { const fills = (effectiveFillPaints(node, ctx) ?? []).filter( (f) => f.visible !== false, @@ -779,31 +899,55 @@ function backgroundShorthand( backgroundSize?: string; backgroundPosition?: string; backgroundRepeat?: string; + backgroundBlendMode?: string; } = {}; + // Optimization: when there is exactly one fill and it's a plain SOLID at the + // bottom, emit it as `background-color` (cheaper CSS, same visual) and skip + // adding it to the bgImages layer list so we don't double-render it. + const isSingleSolidOnly = fills.length === 1 && fills[0]?.type === "SOLID"; + if (isSingleSolidOnly) { + const color = colorToCss(fills[0]!.color, fills[0]!.opacity ?? 1); + if (color) result.backgroundColor = color; + return result; + } const bgImages: string[] = []; + const bgSizes: string[] = []; + const bgPositions: string[] = []; + const bgRepeats: string[] = []; + const bgBlends: string[] = []; for (const f of fills) { - if (f.type === "SOLID" && !result.backgroundColor) { - const c = colorToCss(f.color, f.opacity ?? 1); - if (c) result.backgroundColor = c; + const image = paintToBackground(f, node, ctx); + if (!image) continue; + bgImages.push(image); + bgBlends.push(blendModeCss(f.blendMode) ?? "normal"); + if (f.type !== "IMAGE") { + bgSizes.push("auto"); + bgPositions.push("0% 0%"); + bgRepeats.push("repeat"); continue; } - const v = paintToBackground(f, node, ctx); - if (v) bgImages.push(v); - if (f.type === "IMAGE") { - if (f.imageScaleMode === "FILL") result.backgroundSize = "cover"; - else if (f.imageScaleMode === "FIT") result.backgroundSize = "contain"; - else if (f.imageScaleMode === "TILE") { - result.backgroundSize = "auto"; - result.backgroundRepeat = "repeat"; - } else if (f.imageScaleMode === "STRETCH") - result.backgroundSize = "100% 100%"; - // Figma centers image fills by default (CSS defaults to top-left, which - // crops the wrong edges for FILL/FIT). TILE keeps the default origin so - // the tile pattern starts at top-left. - if (f.imageScaleMode !== "TILE") result.backgroundPosition = "center"; + const mode = f.imageScaleMode ?? "FILL"; + if (mode === "FILL") bgSizes.push("cover"); + else if (mode === "FIT") bgSizes.push("contain"); + else if (mode === "STRETCH") bgSizes.push("100% 100%"); + else bgSizes.push("auto"); + bgPositions.push(mode === "TILE" ? "0% 0%" : "center"); + bgRepeats.push(mode === "TILE" ? "repeat" : "no-repeat"); + } + bgImages.reverse(); + bgSizes.reverse(); + bgPositions.reverse(); + bgRepeats.reverse(); + bgBlends.reverse(); + if (bgImages.length > 0) { + result.backgroundImage = bgImages.join(", "); + result.backgroundSize = bgSizes.join(", "); + result.backgroundPosition = bgPositions.join(", "); + result.backgroundRepeat = bgRepeats.join(", "); + if (bgBlends.some((blend) => blend !== "normal")) { + result.backgroundBlendMode = bgBlends.join(", "); } } - if (bgImages.length > 0) result.backgroundImage = bgImages.join(", "); return result; } @@ -813,7 +957,8 @@ function borderShorthand(node: FigNode, ctx: Ctx): Record { ); if (strokes.length === 0) return {}; const first = strokes[0]!; - const color = colorToCss(first.color, first.opacity ?? 1) ?? "rgb(0, 0, 0)"; + const color = colorToCss(first.color, first.opacity ?? 1); + if (!color) return {}; const hasPerSide = node.strokeTopWeight !== undefined || @@ -940,9 +1085,9 @@ function effectStyles( `inset ${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${num(e.spread ?? 0)}px ${c}`, ); } else if (e.type === "FOREGROUND_BLUR" || e.type === "LAYER_BLUR") { - filters.push(`blur(${num(e.radius ?? 0)}px)`); + filters.push(`blur(${num((e.radius ?? 0) / 2)}px)`); } else if (e.type === "BACKGROUND_BLUR") { - backdropBlur = `blur(${num(e.radius ?? 0)}px)`; + backdropBlur = `blur(${num((e.radius ?? 0) / 2)}px)`; } } const out: Record = {}; @@ -958,8 +1103,22 @@ function transformStyle(node: FigNode): { } { const t = node.transform; if (!t) return {}; - // Decompose: rotation = atan2(m10, m00), scale assumed 1. + const determinant = t.m00 * t.m11 - t.m01 * t.m10; + const hasNonTrivialScale = Math.abs(Math.abs(determinant) - 1) > 0.01; const angle = Math.atan2(t.m10, t.m00); + const isPureRotation = + Math.abs(t.m00 - Math.cos(angle)) < 0.01 && + Math.abs(t.m01 + Math.sin(angle)) < 0.01 && + Math.abs(t.m10 - Math.sin(angle)) < 0.01 && + Math.abs(t.m11 - Math.cos(angle)) < 0.01; + const hasSkew = + (Math.abs(t.m01) > 0.0001 || Math.abs(t.m10) > 0.0001) && !isPureRotation; + if (hasNonTrivialScale || hasSkew) { + return { + transform: `matrix(${num(t.m00)}, ${num(t.m10)}, ${num(t.m01)}, ${num(t.m11)}, 0, 0)`, + transformOrigin: "0 0", + }; + } const deg = (angle * 180) / Math.PI; if (Math.abs(deg) < 0.01) return {}; return { transform: `rotate(${num(deg)}deg)`, transformOrigin: "top left" }; @@ -1011,9 +1170,26 @@ function textStyles(node: FigNode, ctx?: Ctx): Record { styleNode?.textAlignHorizontal ?? node.textAlignHorizontal; if (fontName?.family) { - // Quote families with spaces so the inline style stays valid. const fam = fontName.family; - out.fontFamily = /\s/.test(fam) ? `"${fam}"` : fam; + const quoted = /\s/.test(fam) ? `"${fam}"` : fam; + // Append a metric-compatible fallback stack by classifying the family. + // This prevents UA serif from appearing when a Google/system font is missing. + const famLower = fam.toLowerCase(); + let fallback: string; + if ( + /mono|courier|code|consol|menlo|fira code|source code/i.test(famLower) + ) { + fallback = + "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace"; + } else if ( + /serif|georgia|garamond|didot|baskerville|palatino|times/i.test(famLower) + ) { + fallback = "'Times New Roman', Georgia, Garamond, serif"; + } else { + fallback = + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; + } + out.fontFamily = `${quoted}, ${fallback}`; } const weight = fontWeightFromStyle(fontName?.style); if (weight !== null) out.fontWeight = weight; @@ -1033,24 +1209,36 @@ function textStyles(node: FigNode, ctx?: Ctx): Record { if (ls !== null && ls !== undefined) out.letterSpacing = ls; if (textAlignHorizontal) out.textAlign = TEXT_ALIGN[textAlignHorizontal] ?? "left"; - // Text color comes from the first SOLID fill paint, dereferenced through - // any `styleIdForFill` shared style (NOT `styleIdForText`, which is - // typography-only — see `effectiveFillPaints`). - if (ctx) { - const solidFill = effectiveFillPaints(node, ctx)?.find( - (f) => f.visible !== false && f.type === "SOLID", - ); - if (solidFill) { - const c = colorToCss(solidFill.color, solidFill.opacity ?? 1); - if (c) out.color = c; + const fills = ( + ctx ? effectiveFillPaints(node, ctx) : node.fillPaints + )?.filter((fill) => fill.visible !== false); + if (!fills?.length) { + out.visibility = "hidden"; + return out; + } + const firstFill = fills[0]!; + if (firstFill.type === "SOLID") { + const color = colorToCss(firstFill.color, firstFill.opacity ?? 1); + if (color) out.color = color; + } else if ( + ctx && + (firstFill.type?.startsWith("GRADIENT_") || firstFill.type === "IMAGE") + ) { + const background = paintToBackground(firstFill, node, ctx); + if (background) { + out.background = background; + out.WebkitBackgroundClip = "text"; + out.backgroundClip = "text"; + out.color = "transparent"; } } return out; } function blendModeCss(mode: string | undefined): string | null { - if (!mode || mode === "NORMAL" || mode === "PASS_THROUGH") return null; - return mode.toLowerCase().replace(/_/g, "-"); + if (!mode) return null; + const result = cssBlendMode(mode); + return result?.cssMode ?? null; } function isAutolayout(parent: FigNode | null): boolean { @@ -1138,13 +1326,11 @@ function layoutSizing( } } - // 2) Non-autolayout frames can still hug content via `resizeToFit`. - if (!node.stackMode || node.stackMode === "NONE") { - if (node.resizeToFit) { - horizontal = "HUG"; - vertical = "HUG"; - } - } + // 2) Non-autolayout frames (e.g. Figma groups) position their children + // absolutely in this renderer, and CSS `width/height: auto` cannot hug + // out-of-flow children — a hugged group would collapse to 0×0 and clip + // everything inside it. The baked `node.size` already equals the group's + // content bounds, so keep it FIXED rather than honoring `resizeToFit`. // 3) TEXT auto-resize hugs along the indicated axis/axes. if (node.type === "TEXT" && node.textAutoResize) { @@ -1215,6 +1401,22 @@ function applyAxisConstraint( return false; } +function positionRelativeToParent( + node: FigNode, + parent: FigNode | null, + ctx: Ctx, +): { x: number | null; y: number | null } { + const transform = node.transform; + if (!transform) return { x: null, y: null }; + // Figma/Kiwi node transforms are already expressed relative to the parent + // (the node's `relativeTransform`), so the translation is the parent-local + // offset at every depth — including direct children of a top-level frame, + // whose own canvas position is dropped when the frame becomes a screen root. + // Subtracting the parent's canvas translation here double-counts the frame + // offset and flings direct children off-canvas. + return { x: num(transform.m02), y: num(transform.m12) }; +} + function buildCss( node: FigNode, parent: FigNode | null, @@ -1239,8 +1441,7 @@ function buildCss( let suppressHeight = false; if (isPositioned) { css.position = "absolute"; - const x = node.transform ? num(node.transform.m02) : null; - const y = node.transform ? num(node.transform.m12) : null; + const { x, y } = positionRelativeToParent(node, parent, ctx); const nodeW = node.size ? num(node.size.x) : null; const nodeH = node.size ? num(node.size.y) : null; const parentW = parent?.size ? num(parent.size.x) : null; @@ -1288,6 +1489,16 @@ function buildCss( if (h !== null && emitHeight && !suppressHeight) css.height = `${h}px`; } + // Line vectors (horizontal/vertical strokes) have a 0-size axis. A 0-size + // viewport is dropped by the browser even with `overflow: visible`, so + // give the degenerate axis a minimal non-zero size; the stroke, arrowheads, + // and caps still paint at native coords because the SVG has no viewBox here. + if (vectorLike) { + const minAxis = Math.max(1, num(node.strokeWeight) ?? 1); + if (css.width === "0px") css.width = `${minAxis}px`; + if (css.height === "0px") css.height = `${minAxis}px`; + } + // Auto-layout min/max size constraints. Each axis is independent and may be // unconstrained (null). Only emit positive values — a zero constraint is a // no-op and a min-width keeps a hugging container (e.g. a badge) circular. @@ -1315,15 +1526,24 @@ function buildCss( } } + // A vector with no decodable geometry must not paint its bounding box as a + // solid fill (that renders the shape as a block); render nothing instead. + const geometrylessVector = + !!node.type && + VECTOR_LIKE_TYPES.has(node.type) && + !vectorLike && + !node.fillPaints?.some((p) => p.visible !== false && p.type === "IMAGE"); + // Background (TEXT uses fillPaints for color, not background; vector // nodes paint via inside the ). - if (node.type !== "TEXT" && !vectorLike) { + if (node.type !== "TEXT" && !vectorLike && !geometrylessVector) { Object.assign(css, backgroundShorthand(node, ctx)); } // Border / outline (skipped for vector nodes — strokes go on ). // Merge box-shadows from border (e.g. INSIDE strokes) and effects so neither overwrites the other. - const borderStyle = !vectorLike ? borderShorthand(node, ctx) : {}; + const borderStyle = + !vectorLike && !geometrylessVector ? borderShorthand(node, ctx) : {}; const { boxShadow: borderBoxShadow, ...restBorderStyle } = borderStyle; Object.assign(css, restBorderStyle); // Radius @@ -1346,14 +1566,39 @@ function buildCss( // Opacity / blend mode / overflow / visibility if (typeof node.opacity === "number" && node.opacity < 0.999) css.opacity = node.opacity; - const bm = blendModeCss(node.blendMode); - if (bm) css.mixBlendMode = bm; + if (node.blendMode) { + const bmResult = cssBlendMode(node.blendMode); + if (bmResult) { + css.mixBlendMode = bmResult.cssMode; + if (bmResult.verdict === "approximated") { + ctx.approximatedNodes.push({ + nodeId: guidKey(node.guid), + nodeName: node.name, + nodeType: node.type, + notes: [ + `blend mode ${node.blendMode} approximated as ${bmResult.cssMode}`, + ], + }); + } + } + } if ( (node.type === "FRAME" || node.type === "INSTANCE") && - node.frameMaskDisabled !== true + node.frameMaskDisabled !== true && + // `resizeToFit` marks a group/hug container that sizes to its children and + // never clips in Figma — its children legitimately overflow the baked box, + // so clipping here would crop content that should be visible. + node.resizeToFit !== true ) { css.overflow = "hidden"; } + // Vector elements default to clipping content to their viewport. Figma + // vector bounds are the path's *fill* box, but strokes, arrowheads, and line + // caps extend beyond it (and horizontal/vertical lines have a 0-size box), so + // clipping erases them. `overflow: visible` lets the full geometry paint. + if (vectorLike) { + css.overflow = "visible"; + } // (Hidden nodes are dropped entirely in emitNode; no display:none needed.) return css; @@ -1381,6 +1626,10 @@ interface Ctx { /** Hex hash -> on-disk filename (e.g. `` or `.png`). */ imageMap: Map; missingImageUrl?: string; + /** When true, per-node IMAGE fills with no imageMap entry emit data-figma-image-ref. */ + trackUnresolvedImageRefs?: boolean; + /** Populated by buildAttrs() when trackUnresolvedImageRefs is true. */ + unresolvedImageRefs?: Set; /** * Set of `family|weight|italic` triples seen while emitting the current * frame. We use it to build a Google Fonts in so the custom @@ -1395,6 +1644,13 @@ interface Ctx { maxFrameOutputBytes: number; maxTotalOutputBytes: number; totalOutputBytes: number; + /** Collect fidelity verdicts for approximated nodes. */ + approximatedNodes: Array<{ + nodeId: string; + nodeName?: string; + nodeType?: string; + notes: string[]; + }>; } /** @@ -1451,11 +1707,129 @@ function decodePathCommands(bytes: Buffer | undefined): string { return out.join(" "); } -/** SVG paint attribute (fill / stroke) for the first visible solid paint. */ +/** + * Decode a Figma vector-network blob into an SVG path. The clipboard ships this + * editable `vectorData.vectorNetworkBlob` instead of a flattened `commandsBlob`, + * so it's the only vector geometry a no-token paste has. Format (little-endian, + * reverse-engineered from real `.fig` data): + * header : u32 vertexCount, segmentCount, regionCount, _reserved + * vertices: vertexCount × { f32 x, f32 y, u32 styleID } (12 B) + * segments: segmentCount × { u32 startVtx, f32 tanStart{x,y}, + * u32 endVtx, f32 tanEnd{x,y}, u32 _ } (24 B / 28 stride) + * Each segment is the cubic P0=vtx[start], P1=P0+tanStart, P2=vtx[end]+tanEnd, + * P3=vtx[end] (zero tangents → a line); segments chain end→start into subpaths. + */ +interface DecodedVectorNetwork { + d: string; + /** End stroke-cap is an arrow/marker type (cap enum ≥ 3; 0/1/2 = none/round/square). */ + arrowEnd: boolean; +} + +function decodeVectorNetwork(bytes: Buffer | undefined): DecodedVectorNetwork { + const empty: DecodedVectorNetwork = { d: "", arrowEnd: false }; + if (!bytes || bytes.length < 16) return empty; + const vertexCount = bytes.readUInt32LE(0); + const segmentCount = bytes.readUInt32LE(4); + if (vertexCount > 200_000 || segmentCount > 200_000) return empty; + const arrowEnd = bytes.readUInt32LE(12) >= 3; + + const verts: Array<{ x: number; y: number }> = []; + for (let i = 0; i < vertexCount; i++) { + const o = 16 + i * 12; + if (o + 8 > bytes.length) break; + verts.push({ x: bytes.readFloatLE(o), y: bytes.readFloatLE(o + 4) }); + } + + interface Seg { + s: number; + sx: number; + sy: number; + e: number; + ex: number; + ey: number; + } + const segStart = 16 + vertexCount * 12; + const segs: Seg[] = []; + for (let i = 0; i < segmentCount; i++) { + const o = segStart + i * 28; + if (o + 24 > bytes.length) break; + segs.push({ + s: bytes.readUInt32LE(o), + sx: bytes.readFloatLE(o + 4), + sy: bytes.readFloatLE(o + 8), + e: bytes.readUInt32LE(o + 12), + ex: bytes.readFloatLE(o + 16), + ey: bytes.readFloatLE(o + 20), + }); + } + if (segs.length === 0) return empty; + + const fmt = (n: number) => { + if (!Number.isFinite(n)) return "0"; + const r = Math.round(n * 1000) / 1000; + return Object.is(r, -0) ? "0" : String(r); + }; + const out: string[] = []; + const used = new Array(segs.length).fill(false); + + for (let start = 0; start < segs.length; start++) { + if (used[start]) continue; + // Trace a connected chain: each segment's end vertex feeds the next + // segment's start vertex. + const chain: Seg[] = []; + let cur: number | null = start; + while (cur !== null && !used[cur]) { + used[cur] = true; + chain.push(segs[cur]!); + const endV = segs[cur]!.e; + let next: number | null = null; + for (let j = 0; j < segs.length; j++) { + if (!used[j] && segs[j]!.s === endV) { + next = j; + break; + } + } + cur = next; + } + const first = chain[0]!; + const p0 = verts[first.s]; + if (!p0) continue; + out.push(`M${fmt(p0.x)} ${fmt(p0.y)}`); + for (const seg of chain) { + const a = verts[seg.s]; + const b = verts[seg.e]; + if (!a || !b) continue; + const straight = + seg.sx === 0 && seg.sy === 0 && seg.ex === 0 && seg.ey === 0; + if (straight) { + out.push(`L${fmt(b.x)} ${fmt(b.y)}`); + } else { + out.push( + `C${fmt(a.x + seg.sx)} ${fmt(a.y + seg.sy)} ${fmt(b.x + seg.ex)} ${fmt(b.y + seg.ey)} ${fmt(b.x)} ${fmt(b.y)}`, + ); + } + } + if (chain.length > 0 && chain[chain.length - 1]!.e === first.s) { + out.push("Z"); + } + } + return { d: out.join(" "), arrowEnd }; +} + +/** + * SVG paint attribute (fill / stroke) for the visible solid paint. Figma + * composites a node's paint list bottom-to-top, so the LAST opaque solid is the + * one actually seen — e.g. a stroke stacked `[cyan, pink]` renders pink. Pick + * the topmost visible solid rather than the first. + */ function paintToSvgFill( paints: Paint[] | undefined, ): { color: string; opacity?: number } | null { - const p = paints?.find((x) => x.visible !== false && x.type === "SOLID"); + let p: Paint | undefined; + for (const candidate of paints ?? []) { + if (candidate.visible !== false && candidate.type === "SOLID") + p = candidate; + } if (!p || !p.color) return null; const c = p.color; const r = Math.round(c.r * 255); @@ -1481,10 +1855,13 @@ const VECTOR_LIKE_TYPES = new Set([ function isVectorLike(node: FigNode): boolean { if (!node.type || !VECTOR_LIKE_TYPES.has(node.type)) return false; - if ( - (node.fillGeometry?.length ?? 0) === 0 && - (node.strokeGeometry?.length ?? 0) === 0 - ) { + // Flattened geometry (saved .fig / REST) OR an editable vector network + // (clipboard paste) — either lets us draw the real shape as . + const hasFlatGeometry = + (node.fillGeometry?.length ?? 0) > 0 || + (node.strokeGeometry?.length ?? 0) > 0; + const hasNetwork = typeof node.vectorData?.vectorNetworkBlob === "number"; + if (!hasFlatGeometry && !hasNetwork) { return false; } // Nodes with an IMAGE fill render better as a regular
with @@ -1517,11 +1894,14 @@ function emitSvgBody( const strokePaint = paintToSvgFill(effectiveStrokePaints(node, ctx)); const strokeWeight = node.strokeWeight ?? 0; + let emittedFlat = false; + // Fill paths for (const g of node.fillGeometry ?? []) { if (typeof g.commandsBlob !== "number") continue; const d = decodePathCommands(ctx.blobs[g.commandsBlob]); if (!d) continue; + emittedFlat = true; const attrs = [`d="${d}"`, `fill-rule="${fillRule}"`]; if (fillPaint) { attrs.push(`fill="${fillPaint.color}"`); @@ -1538,6 +1918,7 @@ function emitSvgBody( if (typeof g.commandsBlob !== "number") continue; const d = decodePathCommands(ctx.blobs[g.commandsBlob]); if (!d) continue; + emittedFlat = true; const attrs = [ `d="${d}"`, `fill="none"`, @@ -1553,10 +1934,61 @@ function emitSvgBody( lines.push(`${indent} `); } } - // Best-effort viewBox so the geometry scales with the element box. - if (w > 0 && h > 0) { - // The path coordinates are already in the node's local pixel space, so - // viewBox = 0 0 w h works without further transforms. + + // Vector-network fallback (clipboard paste ships only the editable network, + // not flattened geometry). Decode it to a path and paint it with the node's + // fill/stroke. Network coords are in `normalizedSize` space, so scale into + // the node's box (the SVG viewBox is 0 0 w h). + if (!emittedFlat && typeof node.vectorData?.vectorNetworkBlob === "number") { + const net = decodeVectorNetwork( + ctx.blobs[node.vectorData.vectorNetworkBlob], + ); + if (net.d) { + const d = net.d; + const ns = node.vectorData.normalizedSize; + const sx = ns && ns.x ? (w || ns.x) / ns.x : 1; + const sy = ns && ns.y ? (h || ns.y) / ns.y : 1; + const scaled = Math.abs(sx - 1) > 1e-6 || Math.abs(sy - 1) > 1e-6; + const inner = scaled ? `${indent} ` : indent; + // Arrow end-cap → SVG `` (SVG strokes have no arrow linecap). It + // scales with stroke width and auto-orients to the path's end direction. + const arrowId = + net.arrowEnd && strokePaint && strokeWeight > 0 + ? `ah-${guidKey(node.guid).replace(/[^a-z0-9]/gi, "")}` + : null; + if (arrowId) { + lines.push( + `${inner} `, + ); + } + if (scaled) + lines.push(`${indent} `); + if (fillPaint) { + const a = [ + `d="${d}"`, + `fill-rule="${fillRule}"`, + `fill="${fillPaint.color}"`, + ]; + if (fillPaint.opacity !== undefined) + a.push(`fill-opacity="${fillPaint.opacity}"`); + lines.push(`${inner} `); + } + if (strokePaint && strokeWeight > 0) { + const a = [ + `d="${d}"`, + `fill="none"`, + `stroke="${strokePaint.color}"`, + `stroke-width="${num(strokeWeight)}"`, + `stroke-linejoin="${(node.strokeJoin ?? "ROUND").toLowerCase()}"`, + `stroke-linecap="${(node.strokeCap ?? "ROUND").toLowerCase()}"`, + ]; + if (arrowId) a.push(`marker-start="url(#${arrowId})"`); + if (strokePaint.opacity !== undefined) + a.push(`stroke-opacity="${strokePaint.opacity}"`); + lines.push(`${inner} `); + } + if (scaled) lines.push(`${indent} `); + } } } @@ -1665,6 +2097,23 @@ function buildAttrs( if (Object.keys(css).length > 0) { attrs.push(`style="${escapeHtmlAttr(formatStyleString(css))}"`); } + + // Stamp data-figma-image-ref on elements whose IMAGE fills have no imageMap + // entry. The sentinel placeholder URLs in the style (written by imageUrl()) + // can then be swapped for real URLs by hydrate-figma-paste-images without a + // full re-render. Only active when the caller opts in via trackUnresolvedImageRefs. + if (ctx.trackUnresolvedImageRefs) { + const unresolvedHashes = (effectiveFillPaints(node, ctx) ?? []) + .filter((p) => p.visible !== false && p.type === "IMAGE") + .map((p) => hashToHex(p.image?.hash)) + .filter((h): h is string => h !== null && !ctx.imageMap.has(h)); + if (unresolvedHashes.length > 0) { + const joined = unresolvedHashes.join(" "); + attrs.push(`data-figma-image-ref="${escapeHtmlAttr(joined)}"`); + for (const h of unresolvedHashes) ctx.unresolvedImageRefs?.add(h); + } + } + return attrs; } @@ -1925,9 +2374,18 @@ function emitNode( // relative to it. Check the actually-rendered children (the inlined // master's, for an INSTANCE). const childSource = inlinedSymbol ?? node; - const hasAbsoluteChild = ( - ctx.childrenOf.get(guidKey(childSource.guid)) ?? [] - ).some((c) => c.stackPositioning === "ABSOLUTE"); + const childrenOfSource = ctx.childrenOf.get(guidKey(childSource.guid)) ?? []; + const hasAbsoluteChild = + childrenOfSource.some((c) => c.stackPositioning === "ABSOLUTE") || + // Non-autolayout frames/instances that are NOT themselves absolutely + // positioned need position:relative so their absolutely-positioned children + // (all children in non-flex mode) are offset relative to THIS element, not + // the nearest positioned ancestor further up the tree (e.g. for + // top-level frames). Without this, children near the bottom of a frame + // escape the frame's overflow:hidden box and appear at wrong viewport coords. + (!isPositioned && + (!layoutNode.stackMode || layoutNode.stackMode === "NONE") && + childrenOfSource.length > 0); // A container whose drop shadow must wrap an overflowing absolute child // (e.g. a tooltip caret) needs `filter: drop-shadow()` rather than // `box-shadow`, which would only trace the body's box. Children render from @@ -1951,7 +2409,12 @@ function emitNode( // the master. const vw = vectorSourceNode.size?.x ?? node.size?.x ?? 0; const vh = vectorSourceNode.size?.y ?? node.size?.y ?? 0; - if (vw > 0 && vh > 0) { + // A viewBox with a 0 (or sub-pixel, rounds-to-0) dimension is degenerate — + // the browser can't map coordinates and drops the whole SVG. Horizontal / + // vertical line vectors have exactly this shape (one dimension ~0), so emit + // a viewBox only when both dimensions survive rounding; otherwise the SVG + // paints its geometry at native 1:1 coords under `overflow: visible`. + if (num(vw)! > 0 && num(vh)! > 0) { attrs.push(`viewBox="0 0 ${num(vw)} ${num(vh)}"`); } attrs.push(`xmlns="http://www.w3.org/2000/svg"`); @@ -1994,8 +2457,22 @@ function emitNode( emitOpenWithChildren(tag, attrs, indent, lines); // Preserve newlines in the source by splitting into
-separated lines // (HTML otherwise collapses whitespace). - const escaped = escapeHtmlText(chars).replace(/\n/g, "
"); - lines.push(`${indent} ${escaped}`); + const runs = textStyleRuns(node); + const toHtml = (s: string) => escapeHtmlText(s).replace(/\n/g, "
"); + if (runs.length <= 1) { + lines.push(`${indent} ${toHtml(chars)}`); + } else { + // Per-character color runs → one per run; base-color runs inherit + // the element's `color`, overridden runs carry their own. + const html = runs + .map((r) => + r.color + ? `${toHtml(r.text)}` + : toHtml(r.text), + ) + .join(""); + lines.push(`${indent} ${html}`); + } lines.push(`${indent}`); return; } @@ -2127,7 +2604,7 @@ function emitFrameTemplate(frame: FigNode, ctx: Ctx, pageName: string): string { // Default CSS content-box would inflate every explicit width/height/min-size // by the padding + border, so normalize to border-box. lines.push( - " ", + " ", ); // Custom font families used by the frame -> request them from Google // Fonts. (Smart-export does the same for design hand-off so the layout @@ -2180,10 +2657,21 @@ export interface RenderedFrame { height?: number; } +export interface RenderHtmlFidelityEntry { + nodeId: string; + nodeName?: string; + nodeType?: string; + notes: string[]; +} + export interface RenderHtmlResult { pageCount: number; frameCount: number; frames: RenderedFrame[]; + /** Populated when `trackUnresolvedImageRefs` is true in RenderHtmlOptions. */ + unresolvedImageRefs?: Set; + /** Approximated nodes collected during rendering. */ + approximatedNodes: RenderHtmlFidelityEntry[]; } export interface RenderHtmlOptions { @@ -2193,6 +2681,13 @@ export interface RenderHtmlOptions { imageMap?: Map; /** Safe URL used when an embedded image was omitted (for example no storage provider). */ missingImageUrl?: string; + /** + * When true, any node whose IMAGE fill hash is absent from `imageMap` gets a + * `data-figma-image-ref=""` attribute stamped on its rendered element. + * The collected set is returned as `unresolvedImageRefs` in the result so + * callers know which hashes need retroactive resolution. + */ + trackUnresolvedImageRefs?: boolean; /*** Optional set of page (CANVAS) and/or frame GUID keys */ selection?: Set; maxFrames?: number; @@ -2485,8 +2980,13 @@ export function renderHtmlTemplates( blobs, imageMap: options.imageMap ?? new Map(), missingImageUrl: options.missingImageUrl, + trackUnresolvedImageRefs: options.trackUnresolvedImageRefs, + unresolvedImageRefs: options.trackUnresolvedImageRefs + ? new Set() + : undefined, fontUsage: new Set(), inliningStack: new Set(), + approximatedNodes: [], renderedNodeCount: 0, maxRenderedNodes: options.maxRenderedNodes ?? DEFAULT_MAX_RENDERED_NODES, maxTreeDepth: options.maxTreeDepth ?? DEFAULT_MAX_TREE_DEPTH, @@ -2498,7 +2998,8 @@ export function renderHtmlTemplates( }; const documentNode = nodes.find((n) => n.type === "DOCUMENT"); - if (!documentNode) return { pageCount: 0, frameCount: 0, frames: [] }; + if (!documentNode) + return { pageCount: 0, frameCount: 0, frames: [], approximatedNodes: [] }; const allPages = (childrenOf.get(guidKey(documentNode.guid)) ?? []).filter( (n) => n.type === "CANVAS" && !n.internalOnly, @@ -2564,5 +3065,9 @@ export function renderHtmlTemplates( pageCount: pages.length, frameCount: frames.length, frames, + approximatedNodes: ctx.approximatedNodes, + ...(ctx.unresolvedImageRefs !== undefined + ? { unresolvedImageRefs: ctx.unresolvedImageRefs } + : {}), }; } diff --git a/templates/design/server/lib/figma-clipboard-local-decode.ts b/templates/design/server/lib/figma-clipboard-local-decode.ts new file mode 100644 index 0000000000..11001ba19c --- /dev/null +++ b/templates/design/server/lib/figma-clipboard-local-decode.ts @@ -0,0 +1,238 @@ +/** + * Local kiwi decode path for Figma clipboard pastes. + * + * When no Figma access token is configured, a clipboard paste still carries + * the full fig-kiwi binary buffer: geometry, auto-layout, text, fills, and + * effects are all present in the kiwi message. This module decodes that + * buffer locally using the same decoder as the .fig upload path, synthesizes + * an editable HTML screen per top-level frame, and annotates IMAGE fill + * elements with `data-figma-image-ref` attributes so that a later call to + * `hydrate-figma-paste-images` can fill them in once the user connects their + * Figma access token. + * + * Images are NOT available in the clipboard buffer — Figma stores image bytes + * server-side and only includes a 20-byte SHA-1 hash in the kiwi message. + * Elements with image fills render as `about:blank` placeholders until + * `hydrate-figma-paste-images` resolves and mirrors the real URLs. + */ + +import { assertSafeDecodedFigDocument, decodeFig } from "./fig-file-decoder.js"; +import { + type FigNode, + type Guid, + guidKey, + renderHtmlTemplates, +} from "./fig-file-to-html.js"; +import { + normalizeImportedHtmlDocument, + type ImportedDesignFile, +} from "./import-design-files.js"; + +// 8 MB binary cap. Above this the caller should use an upload handle; below +// it the action payload carries the base64 directly. +const MAX_CLIPBOARD_BUFFER_BYTES = 8 * 1024 * 1024; + +const MAX_CLIPBOARD_NODES = 75_000; +const MAX_CLIPBOARD_FRAMES = 50; +const MAX_FRAME_HTML_BYTES = 4 * 1024 * 1024; +const MAX_TOTAL_HTML_BYTES = 24 * 1024 * 1024; + +export interface ClipboardLocalDecodeResult { + files: ImportedDesignFile[]; + warnings: string[]; + unresolvedImageRefs: string[]; + stats: { + sourceKind: "figma-clipboard-local-kiwi"; + format: "kiwi" | "zip"; + version?: number; + frameCount: number; + nodeCount: number; + unresolvedImageCount: number; + }; +} + +/** + * Return the `guidKey` string for all nodes whose parentIndex.guid does not + * point to any other node in the flat nodeChanges list. These are the "roots" + * that need a synthetic CANVAS parent so `renderHtmlTemplates` can traverse + * the hierarchy starting from its expected DOCUMENT→CANVAS→FRAME structure. + */ +function findOrphanRoots(nodeChanges: FigNode[]): FigNode[] { + const ownKeys = new Set(nodeChanges.map((n) => guidKey(n.guid))); + return nodeChanges.filter((n) => { + const pk = guidKey(n.parentIndex?.guid); + return !pk || !ownKeys.has(pk); + }); +} + +/** + * Wrap an orphaned nodeChanges array (clipboard format: selected subtree + * without a DOCUMENT/CANVAS container) in synthetic DOCUMENT and CANVAS + * nodes so `renderHtmlTemplates` can find the top-level frame hierarchy. + * + * The synthetic node GUIDs use `sessionID = maxExisting + 1` to guarantee + * no collision with real clipboard node GUIDs. + */ +function normalizeClipboardDocument(document: unknown): unknown { + const doc = document as { + nodeChanges?: FigNode[]; + blobs?: unknown[]; + }; + const nodeChanges = doc.nodeChanges; + if (!Array.isArray(nodeChanges)) return document; + + // Already has a DOCUMENT node → renderer can handle it as-is. + if (nodeChanges.some((n) => n.type === "DOCUMENT")) return document; + + const maxSession = nodeChanges.reduce( + (m, n) => Math.max(m, n.guid?.sessionID ?? 0), + 0, + ); + const synBase = maxSession + 1; + const docGuid: Guid = { sessionID: synBase, localID: 0 }; + const pageGuid: Guid = { sessionID: synBase, localID: 1 }; + + const orphans = findOrphanRoots(nodeChanges); + + const documentNode: FigNode = { + guid: docGuid, + type: "DOCUMENT", + name: "Document", + }; + const canvasNode: FigNode = { + guid: pageGuid, + type: "CANVAS", + name: "Clipboard", + parentIndex: { guid: docGuid, position: "0.5" }, + }; + + // Shallow-copy the orphan nodes, pointing their parentIndex to the + // synthetic CANVAS. Non-orphan nodes keep their original parentIndex. + const orphanKeys = new Set(orphans.map((n) => guidKey(n.guid))); + const patchedNodes = nodeChanges.map((n) => { + if (!orphanKeys.has(guidKey(n.guid))) return n; + return { + ...n, + parentIndex: { + guid: pageGuid, + position: n.parentIndex?.position ?? "0.5", + }, + }; + }); + + return { + ...doc, + nodeChanges: [documentNode, canvasNode, ...patchedNodes], + }; +} + +/** + * Decode a base64 fig-kiwi clipboard buffer into editable HTML screens. + * + * @param options.bufferBase64 - Base64 string of the raw fig-kiwi bytes. + * @param options.fileKey - Figma file key from the clipboard's figmeta. + * @param options.originalName - Human-readable name for warnings/source metadata. + */ +export async function importFigmaClipboardFromBuffer(options: { + bufferBase64: string; + fileKey: string; + originalName?: string; +}): Promise { + const { bufferBase64, fileKey, originalName = "figma-paste" } = options; + + // Base64 → binary with cap check. + const bufferBytes = Buffer.from(bufferBase64, "base64"); + if (bufferBytes.length > MAX_CLIPBOARD_BUFFER_BYTES) { + throw new Error( + `Figma clipboard buffer is too large for local decode (max 8 MB). Use a Figma access token for direct REST import instead.`, + ); + } + + const decoded = decodeFig(bufferBytes); + assertSafeDecodedFigDocument(decoded.document); + + // Count nodes before synthesis to report against the cap. + const rawDoc = decoded.document as { nodeChanges?: FigNode[] }; + const nodeCount = rawDoc.nodeChanges?.length ?? 0; + if (nodeCount > MAX_CLIPBOARD_NODES) { + throw new Error( + `Figma clipboard has too many nodes (${nodeCount}; max ${MAX_CLIPBOARD_NODES}). Import a smaller selection.`, + ); + } + + const normalizedDoc = normalizeClipboardDocument(decoded.document); + + // Empty imageMap so all IMAGE fills are treated as unresolved. The renderer + // will stamp data-figma-image-ref on affected elements via trackUnresolvedImageRefs. + const rendered = renderHtmlTemplates(normalizedDoc, { + imageMap: new Map(), + missingImageUrl: "about:blank", + trackUnresolvedImageRefs: true, + maxFrames: MAX_CLIPBOARD_FRAMES, + maxFrameOutputBytes: MAX_FRAME_HTML_BYTES, + maxTotalOutputBytes: MAX_TOTAL_HTML_BYTES, + }); + + if (rendered.frames.length === 0) { + throw new Error( + "No editable frames were found in the Figma clipboard. Copy a top-level frame before pasting.", + ); + } + + const unresolvedRefs = Array.from(rendered.unresolvedImageRefs ?? []); + + let totalHtmlBytes = 0; + const files: ImportedDesignFile[] = rendered.frames.map((frame) => { + const content = normalizeImportedHtmlDocument( + frame.html, + `figma clipboard local-kiwi decode ${originalName}`, + ); + const htmlBytes = Buffer.byteLength(content, "utf8"); + totalHtmlBytes += htmlBytes; + if (totalHtmlBytes > MAX_TOTAL_HTML_BYTES) { + throw new Error( + "Figma clipboard import generated too much HTML (max 24 MB). Import a smaller selection.", + ); + } + return { + filename: frame.fileName, + fileType: "html" as const, + content, + source: { + sourceType: "figma-clipboard-local-kiwi", + figmaFileKey: fileKey, + figmaNodeName: frame.frameName, + figFormat: decoded.format, + figVersion: decoded.version, + unresolvedImageRefs: + unresolvedRefs.length > 0 ? unresolvedRefs : undefined, + }, + preferredFrame: { + title: frame.frameName, + width: frame.width, + height: frame.height, + }, + } satisfies ImportedDesignFile; + }); + + const warnings: string[] = []; + if (unresolvedRefs.length > 0) { + warnings.push( + `${unresolvedRefs.length} image${unresolvedRefs.length === 1 ? "" : "s"} could not be loaded without a Figma access token. Connect Figma to fill them in.`, + ); + } + + return { + files, + warnings, + unresolvedImageRefs: unresolvedRefs, + stats: { + sourceKind: "figma-clipboard-local-kiwi", + format: decoded.format, + version: decoded.version, + frameCount: rendered.frames.length, + nodeCount, + unresolvedImageCount: unresolvedRefs.length, + }, + }; +} diff --git a/templates/design/server/lib/figma-image-hydration.spec.ts b/templates/design/server/lib/figma-image-hydration.spec.ts new file mode 100644 index 0000000000..0b8af9eeee --- /dev/null +++ b/templates/design/server/lib/figma-image-hydration.spec.ts @@ -0,0 +1,267 @@ +/** + * figma-image-hydration.spec.ts + * + * Covers the token-free `.fig` hydration path: + * - resolveFigImageHashes: only requested hashes present in the .fig are + * uploaded and mapped; absent hashes are skipped. + * - hydrateFileImagesFromFig: end-to-end load → collect → match → persist. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + decodeFig: vi.fn(), + uploadFile: vi.fn(), + assertAccess: vi.fn(), + accessFilter: vi.fn(() => "access-filter-sentinel"), + readLiveSourceFile: vi.fn(), + writeInlineSourceFile: vi.fn(), + agentEnterDocument: vi.fn(), + agentLeaveDocument: vi.fn(), + mutateDesignData: vi.fn(), +})); + +vi.mock("./fig-file-decoder.js", () => ({ decodeFig: mocks.decodeFig })); +vi.mock("@agent-native/core/file-upload", () => ({ + uploadFile: mocks.uploadFile, +})); +vi.mock("@agent-native/core/sharing", () => ({ + assertAccess: mocks.assertAccess, + accessFilter: mocks.accessFilter, +})); +vi.mock("@agent-native/core/collab", () => ({ + agentEnterDocument: mocks.agentEnterDocument, + agentLeaveDocument: mocks.agentLeaveDocument, +})); +vi.mock("../source-workspace.js", () => ({ + readLiveSourceFile: mocks.readLiveSourceFile, + writeInlineSourceFile: mocks.writeInlineSourceFile, +})); +vi.mock("./design-data-mutation.js", () => ({ + mutateDesignData: mocks.mutateDesignData, +})); + +let dbRows: unknown[] = []; +vi.mock("../db/index.js", () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ limit: () => Promise.resolve(dbRows) }), + }), + }), + }), + }), + schema: { + designFiles: { + id: "id", + designId: "designId", + filename: "filename", + fileType: "fileType", + content: "content", + }, + designs: { id: "id", data: "data" }, + designShares: {}, + }, +})); + +import { + hydrateFileImagesFromFig, + indexFigImages, + resolveFigImageHashes, +} from "./figma-image-hydration.js"; + +const FIG_BYTES = Buffer.from("fake-fig-bytes"); + +function figWithImages(images: Array<{ hash: string; ext: string }>): { + document: unknown; + images: Array<{ hash: string; ext: string; bytes: Buffer }>; +} { + return { + document: {}, + images: images.map((i) => ({ ...i, bytes: Buffer.from(i.hash) })), + }; +} + +// The decode-once index the handler now builds and hands to the resolvers. +function figImageMap( + images: Array<{ hash: string; ext: string }>, +): Map { + const map = new Map(); + for (const i of images) map.set(i.hash, { ...i, bytes: Buffer.from(i.hash) }); + return map; +} + +describe("indexFigImages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("decodes the .fig once and indexes its images by SHA-1 hash", () => { + mocks.decodeFig.mockReturnValue( + figWithImages([ + { hash: "aaa", ext: "png" }, + { hash: "bbb", ext: "jpg" }, + ]), + ); + + const index = indexFigImages(FIG_BYTES); + + expect(mocks.decodeFig).toHaveBeenCalledTimes(1); + expect(index.size).toBe(2); + expect(index.get("aaa")).toMatchObject({ hash: "aaa", ext: "png" }); + expect(index.get("bbb")).toMatchObject({ hash: "bbb", ext: "jpg" }); + }); +}); + +describe("resolveFigImageHashes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.uploadFile.mockImplementation( + async ({ filename }: { filename: string }) => ({ + url: `https://cdn.example.com/${filename}`, + }), + ); + }); + + it("uploads only requested hashes present in the .fig and maps them", async () => { + const resolved = await resolveFigImageHashes({ + figImages: figImageMap([ + { hash: "aaa", ext: "png" }, + { hash: "bbb", ext: "jpg" }, + ]), + hashes: ["aaa", "ccc"], // ccc is not embedded + ownerEmail: "user@example.com", + }); + + expect(resolved.get("aaa")).toBe("https://cdn.example.com/figma-aaa.png"); + expect(resolved.has("ccc")).toBe(false); + expect(resolved.has("bbb")).toBe(false); // not requested + expect(mocks.uploadFile).toHaveBeenCalledTimes(1); + expect(mocks.uploadFile.mock.calls[0]![0].mimeType).toBe("image/png"); + }); + + it("returns an empty map when no requested hash is embedded", async () => { + const resolved = await resolveFigImageHashes({ + figImages: figImageMap([{ hash: "zzz", ext: "png" }]), + hashes: ["aaa"], + ownerEmail: "user@example.com", + }); + expect(resolved.size).toBe(0); + expect(mocks.uploadFile).not.toHaveBeenCalled(); + }); + + it("skips an image whose upload fails without failing the batch", async () => { + mocks.uploadFile.mockImplementation( + async ({ filename }: { filename: string }) => { + if (filename.includes("bad")) throw new Error("storage down"); + return { url: `https://cdn.example.com/${filename}` }; + }, + ); + const resolved = await resolveFigImageHashes({ + figImages: figImageMap([ + { hash: "ok1", ext: "png" }, + { hash: "bad", ext: "png" }, + ]), + hashes: ["ok1", "bad"], + ownerEmail: "user@example.com", + }); + expect(resolved.get("ok1")).toContain("figma-ok1.png"); + expect(resolved.has("bad")).toBe(false); + }); +}); + +describe("hydrateFileImagesFromFig", () => { + const HTML_WITH_REF = + '
x
'; + + const ROW = { + id: "file-1", + designId: "design-1", + filename: "Screen.html", + fileType: "html", + content: HTML_WITH_REF, + designData: JSON.stringify({ + screenMetadata: { "file-1": { unresolvedImageRefs: ["aaa"] } }, + }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + dbRows = [ROW]; + mocks.assertAccess.mockResolvedValue(undefined); + mocks.readLiveSourceFile.mockResolvedValue({ + content: HTML_WITH_REF, + versionHash: "v1", + }); + mocks.writeInlineSourceFile.mockResolvedValue({ + versionHash: "v2", + changed: true, + updatedAt: "", + }); + mocks.mutateDesignData.mockResolvedValue({ data: {}, updatedAt: "" }); + mocks.uploadFile.mockImplementation( + async ({ filename }: { filename: string }) => ({ + url: `https://cdn.example.com/${filename}`, + }), + ); + }); + + it("fills a placeholder from the .fig and persists the hydrated HTML", async () => { + const result = await hydrateFileImagesFromFig({ + fileId: "file-1", + figImages: figImageMap([{ hash: "aaa", ext: "png" }]), + ownerEmail: "user@example.com", + }); + + expect(result).toMatchObject({ fileId: "file-1", resolved: 1, missing: 0 }); + const written = mocks.writeInlineSourceFile.mock.calls[0]![0] + .content as string; + expect(written).toContain("cdn.example.com/figma-aaa.png"); + expect(written).not.toContain("about:blank"); + expect(written).not.toContain("data-figma-image-ref"); + expect(mocks.agentEnterDocument).toHaveBeenCalledWith("file-1"); + expect(mocks.agentLeaveDocument).toHaveBeenCalledWith("file-1"); + }); + + it("does not write when the .fig has none of the referenced images", async () => { + const result = await hydrateFileImagesFromFig({ + fileId: "file-1", + figImages: figImageMap([{ hash: "zzz", ext: "png" }]), + ownerEmail: "user@example.com", + }); + + expect(result).toMatchObject({ resolved: 0, missing: 1 }); + expect(mocks.writeInlineSourceFile).not.toHaveBeenCalled(); + expect(mocks.mutateDesignData).not.toHaveBeenCalled(); + }); + + it("returns early with no refs when the screen has no placeholders", async () => { + dbRows = [{ ...ROW, content: "
clean
" }]; + mocks.readLiveSourceFile.mockResolvedValue({ + content: "
clean
", + versionHash: "v1", + }); + + const result = await hydrateFileImagesFromFig({ + fileId: "file-1", + figImages: figImageMap([{ hash: "aaa", ext: "png" }]), + ownerEmail: "user@example.com", + }); + + expect(result).toMatchObject({ resolved: 0, missing: 0, skipped: 0 }); + expect(mocks.uploadFile).not.toHaveBeenCalled(); + expect(mocks.writeInlineSourceFile).not.toHaveBeenCalled(); + }); + + it("throws File not found for a missing row", async () => { + dbRows = []; + await expect( + hydrateFileImagesFromFig({ + fileId: "nope", + figImages: figImageMap([{ hash: "aaa", ext: "png" }]), + ownerEmail: "user@example.com", + }), + ).rejects.toThrow("File not found"); + }); +}); diff --git a/templates/design/server/lib/figma-image-hydration.ts b/templates/design/server/lib/figma-image-hydration.ts new file mode 100644 index 0000000000..bd2aaedf55 --- /dev/null +++ b/templates/design/server/lib/figma-image-hydration.ts @@ -0,0 +1,389 @@ +/** + * Resolves the `data-figma-image-ref` placeholders a no-token clipboard/`.fig` + * decode stamps onto unresolved IMAGE fills. Trap: the clipboard carries only + * hashes, never bytes; the `.fig`'s embedded `images/` bytes — keyed by the same + * SHA-1 the kiwi IMAGE fills reference — are what `resolveFigImageHashes` mirrors + * token-free. REST (`hydrate-figma-paste-images`) resolves the same hashes with a token. + */ + +import { + agentEnterDocument, + agentLeaveDocument, +} from "@agent-native/core/collab"; +import { uploadFile } from "@agent-native/core/file-upload"; +import { accessFilter, assertAccess } from "@agent-native/core/sharing"; +import { and, eq } from "drizzle-orm"; + +import { getDb, schema } from "../db/index.js"; +import { + readLiveSourceFile, + writeInlineSourceFile, + type SourceWorkspaceFile, +} from "../source-workspace.js"; +import { mutateDesignData } from "./design-data-mutation.js"; +import { decodeFig, type DecodedFigImage } from "./fig-file-decoder.js"; + +// --------------------------------------------------------------------------- +// HTML helpers +// --------------------------------------------------------------------------- + +const DATA_IMAGE_REF_ATTR_RE = /\sdata-figma-image-ref="([^"]*)"/; + +// Must match the renderer's real placeholder form `url('about:blank')` (single +// quotes survive style-attr escaping); lenient to the `"` entity form for +// older screens, with the back-reference keeping the quotes matched. +const IMAGE_URL_PLACEHOLDER_RE = /url\(("|')about:blank\1\)/g; + +/** + * Wrap `url()` exactly as the renderer's escaped `style="…"` would, so hydrated + * fills are byte-identical to natively rendered image fills. + */ +function cssUrlInAttr(url: string): string { + const inner = url + .replace(/'/g, "%27") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/(); + for (const m of html.matchAll(/<[^>]+>/g)) { + const ref = m[0].match(DATA_IMAGE_REF_ATTR_RE); + if (ref?.[1]) { + for (const h of ref[1].trim().split(/\s+/)) { + if (h) hashes.add(h); + } + } + } + return Array.from(hashes); +} + +/** + * Fill `url('about:blank')` placeholders on `data-figma-image-ref` elements: the + * Nth placeholder maps to the Nth hash in the space-separated attr value. + */ +export function hydrateImageRefsInHtml( + html: string, + resolvedUrls: Map, +): { html: string; resolved: number; missing: string[] } { + let resolvedCount = 0; + const missing: string[] = []; + + const newHtml = html.replace(/<[^>]+>/g, (tag) => { + const refMatch = tag.match(DATA_IMAGE_REF_ATTR_RE); + if (!refMatch?.[1]) return tag; + + const hashes = refMatch[1].trim().split(/\s+/).filter(Boolean); + if (hashes.length === 0) return tag; + + const unresolvedHashes: string[] = []; + let hashIdx = 0; + + let newTag = tag.replace(IMAGE_URL_PLACEHOLDER_RE, (match) => { + if (hashIdx >= hashes.length) return match; + const hash = hashes[hashIdx++]!; + const durableUrl = resolvedUrls.get(hash); + if (!durableUrl) { + unresolvedHashes.push(hash); + return match; + } + resolvedCount++; + return cssUrlInAttr(durableUrl); + }); + + // Hashes beyond url() occurrences (shouldn't happen with our renderer). + while (hashIdx < hashes.length) { + unresolvedHashes.push(hashes[hashIdx++]!); + } + + missing.push(...unresolvedHashes); + + if (unresolvedHashes.length === 0) { + newTag = newTag.replace(/\s+data-figma-image-ref="[^"]*"/, ""); + } else if (unresolvedHashes.length < hashes.length) { + newTag = newTag.replace( + /data-figma-image-ref="[^"]*"/, + `data-figma-image-ref="${unresolvedHashes.join(" ")}"`, + ); + } + + return newTag; + }); + + return { html: newHtml, resolved: resolvedCount, missing }; +} + +// --------------------------------------------------------------------------- +// Shared file load + persist +// --------------------------------------------------------------------------- + +export interface HydratableFile { + workspaceFile: SourceWorkspaceFile; + designId: string; + /** Present only when the screen was imported via a REST clipboard path. */ + figmaFileKey?: string; +} + +/** + * Scoped, editor-access load of one HTML design file, plus the originating Figma + * file key when recorded. Throws identical not-found/non-HTML errors for both resolvers. + */ +export async function loadHydratableFile( + fileId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select({ + id: schema.designFiles.id, + designId: schema.designFiles.designId, + filename: schema.designFiles.filename, + fileType: schema.designFiles.fileType, + content: schema.designFiles.content, + designData: schema.designs.data, + }) + .from(schema.designFiles) + .innerJoin( + schema.designs, + eq(schema.designFiles.designId, schema.designs.id), + ) + .where( + and( + eq(schema.designFiles.id, fileId), + accessFilter(schema.designs, schema.designShares), + ), + ) + .limit(1); + + if (!row) throw new Error(`File not found: ${fileId}`); + if (row.fileType !== "html") { + throw new Error("hydrate-figma-paste-images only supports HTML files."); + } + + await assertAccess("design", row.designId, "editor"); + + let figmaFileKey: string | undefined; + try { + const designData: unknown = row.designData + ? JSON.parse(row.designData) + : {}; + const screenMeta = (designData as Record)?.screenMetadata; + if (screenMeta && typeof screenMeta === "object") { + const fileMeta = (screenMeta as Record)[fileId]; + if (fileMeta && typeof fileMeta === "object") { + const key = (fileMeta as Record).figmaFileKey; + if (typeof key === "string" && key) figmaFileKey = key; + } + } + } catch { + // JSON.parse failure — figmaFileKey stays undefined; the REST caller + // surfaces its own missing-key error, the .fig path does not need one. + } + + const workspaceFile: SourceWorkspaceFile = { + id: row.id, + designId: row.designId, + filename: row.filename, + fileType: "html", + content: row.content ?? "", + createdAt: null, + updatedAt: null, + }; + + return { workspaceFile, designId: row.designId, figmaFileKey }; +} + +export interface ApplyHydrationResult { + resolved: number; + missing: number; + skipped: number; +} + +/** + * Hydrate placeholders and persist (CAS + collab sync), pruning + * `screenMetadata.unresolvedImageRefs`. Never writes when nothing resolved. + */ +export async function applyHydration(opts: { + file: SourceWorkspaceFile; + designId: string; + fileId: string; + liveContent: string; + liveVersionHash: string; + requestedHashes: string[]; + resolvedUrls: Map; +}): Promise { + const { file, designId, fileId, liveContent, liveVersionHash } = opts; + const { + html: hydratedHtml, + resolved, + missing, + } = hydrateImageRefsInHtml(liveContent, opts.resolvedUrls); + + const uniqueMissing = Array.from(new Set(missing)); + const skipped = opts.requestedHashes.length - opts.resolvedUrls.size; + + if (resolved === 0) { + return { resolved: 0, missing: uniqueMissing.length, skipped }; + } + + agentEnterDocument(fileId); + try { + await writeInlineSourceFile({ + designId, + file, + content: hydratedHtml, + expectedVersionHash: liveVersionHash, + }); + } finally { + agentLeaveDocument(fileId); + } + + await mutateDesignData({ + designId, + mutate: (current) => { + const screenMeta = current.screenMetadata; + if (!screenMeta || typeof screenMeta !== "object") return current; + const fileMeta = (screenMeta as Record)[fileId]; + if (!fileMeta || typeof fileMeta !== "object") return current; + const updatedFileMeta: Record = { + ...(fileMeta as Record), + }; + if (uniqueMissing.length > 0) { + updatedFileMeta.unresolvedImageRefs = uniqueMissing; + } else { + delete updatedFileMeta.unresolvedImageRefs; + } + return { + ...current, + screenMetadata: { + ...(screenMeta as Record), + [fileId]: updatedFileMeta, + }, + }; + }, + isApplied: (persisted) => { + const screenMeta = persisted.screenMetadata; + if (!screenMeta || typeof screenMeta !== "object") return false; + const fileMeta = (screenMeta as Record)[fileId]; + if (!fileMeta || typeof fileMeta !== "object") return false; + const refs = (fileMeta as Record).unresolvedImageRefs; + return uniqueMissing.length === 0 + ? refs === undefined || (Array.isArray(refs) && refs.length === 0) + : Array.isArray(refs) && refs.length === uniqueMissing.length; + }, + }); + + return { resolved, missing: uniqueMissing.length, skipped }; +} + +// --------------------------------------------------------------------------- +// Token-free `.fig` resolver +// --------------------------------------------------------------------------- + +const FIG_HYDRATE_UPLOAD_CONCURRENCY = 4; + +function mimeTypeForExt(ext: string): string { + if (ext === "jpg" || ext === "jpeg") return "image/jpeg"; + if (ext === "png") return "image/png"; + if (ext === "webp") return "image/webp"; + if (ext === "gif") return "image/gif"; + return "application/octet-stream"; +} + +/** + * Decode a `.fig` and index its embedded images by SHA-1 hash. Decode once per + * upload and reuse the index across screens — a multi-screen hydration must not + * re-parse the whole (up to 50 MB) file per screen. + */ +export function indexFigImages(figBytes: Buffer): Map { + const byHash = new Map(); + for (const image of decodeFig(figBytes).images) byHash.set(image.hash, image); + return byHash; +} + +/** + * Match placeholder hashes against a `.fig`'s embedded `images/` bytes (keyed by + * the same SHA-1 the paste stamped) and mirror matches to durable storage. No + * Figma token or REST call. + */ +export async function resolveFigImageHashes(opts: { + figImages: Map; + hashes: string[]; + ownerEmail: string; + uploader?: typeof uploadFile; +}): Promise> { + const { figImages, hashes, ownerEmail } = opts; + const uploader = opts.uploader ?? uploadFile; + + const wanted = hashes.filter((h) => figImages.has(h)); + const resolved = new Map(); + + for ( + let offset = 0; + offset < wanted.length; + offset += FIG_HYDRATE_UPLOAD_CONCURRENCY + ) { + const batch = wanted.slice(offset, offset + FIG_HYDRATE_UPLOAD_CONCURRENCY); + await Promise.all( + batch.map(async (hash) => { + const image = figImages.get(hash)!; + try { + const uploaded = await uploader({ + data: image.bytes, + filename: `figma-${image.hash}.${image.ext}`, + mimeType: mimeTypeForExt(image.ext), + ownerEmail, + recordAsset: false, + stableUrl: true, + }); + if (uploaded?.url) resolved.set(hash, uploaded.url); + } catch { + // Skip; a partial resolve still helps and leaves placeholders for a retry. + } + }), + ); + } + + return resolved; +} + +/** + * Token-free hydration of one screen's image placeholders from an uploaded + * `.fig`. Takes a pre-decoded image index (see `indexFigImages`) so a + * multi-screen hydration decodes the file once, not once per screen. + */ +export async function hydrateFileImagesFromFig(opts: { + fileId: string; + figImages: Map; + ownerEmail: string; + uploader?: typeof uploadFile; +}): Promise { + const { fileId, figImages, ownerEmail } = opts; + const { workspaceFile, designId } = await loadHydratableFile(fileId); + const live = await readLiveSourceFile(workspaceFile); + + const hashes = collectImageRefHashes(live.content); + if (hashes.length === 0) { + return { fileId, resolved: 0, missing: 0, skipped: 0 }; + } + + const resolvedUrls = await resolveFigImageHashes({ + figImages, + hashes, + ownerEmail, + uploader: opts.uploader, + }); + + const result = await applyHydration({ + file: workspaceFile, + designId, + fileId, + liveContent: live.content, + liveVersionHash: live.versionHash, + requestedHashes: hashes, + resolvedUrls, + }); + + return { fileId, ...result }; +} diff --git a/templates/design/server/lib/figma-node-import.ts b/templates/design/server/lib/figma-node-import.ts index 0d5a4cdadc..05334ddda0 100644 --- a/templates/design/server/lib/figma-node-import.ts +++ b/templates/design/server/lib/figma-node-import.ts @@ -36,6 +36,8 @@ const MAX_FIGMA_IMAGE_BYTES = 15 * 1024 * 1024; const MAX_TOTAL_FIGMA_IMAGE_BYTES = 64 * 1024 * 1024; const MAX_FIGMA_IMAGE_REFERENCES = 256; const MAX_FIGMA_IMAGE_IDS_PER_REQUEST = 50; +// Figma's `/images` endpoint takes ids in the query string; cap the batch by +// both count and URL length so a complex frame's ids are fetched in full. const MAX_FIGMA_IMAGE_IDS_QUERY_CHARS = 1_800; const MAX_CONCURRENT_FIGMA_IMAGE_UPLOADS = 4; const FIGMA_IMAGE_FETCH_TIMEOUT_MS = 20_000; @@ -415,6 +417,7 @@ type FigmaProviderEnvelope = { ok?: boolean; status?: number; statusText?: string; + headers?: Record; json?: unknown; text?: string; truncated?: boolean; @@ -422,6 +425,46 @@ type FigmaProviderEnvelope = { }; }; +export type FigmaRateLimitError = Error & { + statusCode: 429; + retryAfterSeconds?: number; + figmaPlanTier?: string; + figmaRateLimitType?: "low" | "high"; + figmaUpgradeUrl?: string; +}; + +const FIGMA_PLAN_TIERS = new Set([ + "enterprise", + "org", + "pro", + "starter", + "student", +]); + +export function isFigmaRateLimitError( + err: unknown, +): err is FigmaRateLimitError { + return ( + err instanceof Error && (err as { statusCode?: unknown }).statusCode === 429 + ); +} + +function figmaUpgradeUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value); + if ( + url.protocol === "https:" && + (url.hostname === "figma.com" || url.hostname.endsWith(".figma.com")) + ) { + return value; + } + } catch { + return undefined; + } + return undefined; +} + export function providerJson(envelope: unknown, label: string): unknown { const response = (envelope as FigmaProviderEnvelope | null)?.response; if (!response) throw new Error(`Figma ${label} response was empty.`); @@ -431,11 +474,41 @@ export function providerJson(envelope: unknown, label: string): unknown { ); } if (response.ok === false) { + const jsonBody = response.json as { + message?: string; + error?: string; + } | null; const detail = (typeof response.text === "string" && response.text.trim()) || + (typeof jsonBody?.message === "string" && jsonBody.message) || response.statusText || `HTTP ${response.status ?? "error"}`; - throw new Error(`Figma ${label} request failed: ${detail}`); + const err = new Error(`Figma ${label} request failed: ${detail}`); + if (response.status === 429) { + const rateLimitError = err as FigmaRateLimitError; + rateLimitError.statusCode = 429; + + const retryAfterHeader = response.headers?.["retry-after"]; + if (retryAfterHeader) { + rateLimitError.retryAfterSeconds = parseInt(retryAfterHeader, 10); + } + + const planTier = response.headers?.["x-figma-plan-tier"]; + if (planTier && FIGMA_PLAN_TIERS.has(planTier)) { + rateLimitError.figmaPlanTier = planTier; + } + + const rateLimitType = response.headers?.["x-figma-rate-limit-type"]; + if (rateLimitType === "low" || rateLimitType === "high") { + rateLimitError.figmaRateLimitType = rateLimitType; + } + + const upgradeUrl = figmaUpgradeUrl( + response.headers?.["x-figma-upgrade-link"], + ); + if (upgradeUrl) rateLimitError.figmaUpgradeUrl = upgradeUrl; + } + throw err; } return response.json; } @@ -567,47 +640,39 @@ async function fetchFallbackImageUrls( nodeIds: string[], ): Promise> { if (nodeIds.length === 0) return {}; - const batches: string[][] = []; + const result: Record = {}; + const fetchBatch = async (ids: string[]) => { + if (ids.length === 0) return; + const envelope = await figmaGet(`/images/${fileKey}`, { + ids: ids.join(","), + format: "png", + scale: 2, + }); + const json = providerJson(envelope, "images") as { + images?: Record; + }; + for (const [id, url] of Object.entries(json.images ?? {})) { + if (typeof url === "string" && url) result[id] = url; + } + }; + // Batch over the full ID list by count and query length so complex frames + // don't silently drop layers beyond the first request. let batch: string[] = []; let queryChars = 0; for (const nodeId of nodeIds) { - if (nodeId.length > MAX_FIGMA_IMAGE_IDS_QUERY_CHARS) { - throw new Error("A Figma image fallback node id was unexpectedly long."); - } - const addedChars = nodeId.length + (batch.length > 0 ? 1 : 0); + const addedChars = nodeId.length + 1; if ( batch.length >= MAX_FIGMA_IMAGE_IDS_PER_REQUEST || queryChars + addedChars > MAX_FIGMA_IMAGE_IDS_QUERY_CHARS ) { - batches.push(batch); + await fetchBatch(batch); batch = []; queryChars = 0; } batch.push(nodeId); - queryChars += nodeId.length + (batch.length > 1 ? 1 : 0); - } - if (batch.length > 0) batches.push(batch); - - const responses = await mapWithConcurrency( - batches, - MAX_CONCURRENT_FIGMA_IMAGE_UPLOADS, - async (ids) => { - const envelope = await figmaGet(`/images/${fileKey}`, { - ids: ids.join(","), - format: "png", - scale: 2, - }); - return providerJson(envelope, "images") as { - images?: Record; - }; - }, - ); - const result: Record = {}; - for (const response of responses) { - for (const [id, url] of Object.entries(response.images ?? {})) { - if (typeof url === "string" && url) result[id] = url; - } + queryChars += addedChars; } + await fetchBatch(batch); return result; } @@ -628,6 +693,34 @@ async function fetchImageFillUrls( return result; } +/** + * Fetch CDN URLs for the given image-fill hex hashes via Figma's + * `/files/:key/images` endpoint, then mirror them to durable storage. + * Returns a Map from hex hash to durable URL. Hashes that Figma cannot + * resolve (deleted images, permission gaps) are omitted from the result. + * + * Used by `hydrate-figma-paste-images` to fill in `about:blank` placeholders + * that the local-kiwi clipboard decode path leaves behind. + */ +export async function resolveImageFillRefs( + fileKey: string, + hexHashes: string[], +): Promise> { + if (hexHashes.length === 0) return new Map(); + const cdnUrls = await fetchImageFillUrls(fileKey, hexHashes); + const cdnUrlList = Object.values(cdnUrls).filter( + (u): u is string => typeof u === "string" && u.length > 0, + ); + if (cdnUrlList.length === 0) return new Map(); + const durableMap = await mirrorFigmaImageUrls(cdnUrlList); + const result = new Map(); + for (const [hash, cdnUrl] of Object.entries(cdnUrls)) { + const durableUrl = durableMap.get(cdnUrl); + if (durableUrl) result.set(hash, durableUrl); + } + return result; +} + export function sanitizeTitle( name: string | undefined, fallback: string, @@ -673,7 +766,11 @@ export async function buildScreenFilesFromFigmaNodes( source?: (nodeId: string, node: FigmaNode) => Record; sourceLabel?: (nodeId: string, node: FigmaNode) => string; } = {}, -): Promise<{ files: ImportedDesignFile[]; fidelityEntries: FidelityEntry[] }> { +): Promise<{ + files: ImportedDesignFile[]; + fidelityEntries: FidelityEntry[]; + missingImageFillCount: number; +}> { const entries = Object.entries(nodesById); const fallbackNodeIds = new Set(); const imageFillRefs = new Set(); @@ -696,16 +793,16 @@ export async function buildScreenFilesFromFigmaNodes( (nodeId) => !fallbackImageUrls[nodeId], ); if (missingFallbackNodeIds.length > 0) { - throw new Error( - `Figma could not render ${missingFallbackNodeIds.length} required fallback layer${missingFallbackNodeIds.length === 1 ? "" : "s"} (${missingFallbackNodeIds.slice(0, 5).join(", ")}${missingFallbackNodeIds.length > 5 ? ", …" : ""}). Nothing was imported so the design would not silently lose visible content. Try a smaller selection or retry the import.`, + console.warn( + `[figma-import] ${missingFallbackNodeIds.length} fallback layer(s) could not be rendered and will be omitted (${missingFallbackNodeIds.slice(0, 5).join(", ")}${missingFallbackNodeIds.length > 5 ? ", …" : ""}).`, ); } const missingImageFillRefs = Array.from(imageFillRefs).filter( (imageRef) => !imageFillUrls[imageRef], ); if (missingImageFillRefs.length > 0) { - throw new Error( - `Figma did not return ${missingImageFillRefs.length} required image fill${missingImageFillRefs.length === 1 ? "" : "s"}. Nothing was imported so the design would not silently omit imagery. Retry the import or export the affected frame from Figma.`, + console.warn( + `[figma-import] ${missingImageFillRefs.length} image fill ref(s) could not be resolved (likely from a component library file); those fills will be omitted.`, ); } const durableUrls = await mirrorFigmaImageUrls([ @@ -763,5 +860,8 @@ export async function buildScreenFilesFromFigmaNodes( }); } - return { files, fidelityEntries }; + const finalMissingCount = missingImageFillRefs.filter( + (r) => !imageFillUrls[r], + ).length; + return { files, fidelityEntries, missingImageFillCount: finalMissingCount }; } diff --git a/templates/design/server/register-secrets.ts b/templates/design/server/register-secrets.ts index a62cb826eb..d9fc95bc8a 100644 --- a/templates/design/server/register-secrets.ts +++ b/templates/design/server/register-secrets.ts @@ -65,7 +65,18 @@ registerRequiredSecret({ "User-Agent": "AgentNative/1.0", }, }); - if (res.ok) return true; + console.log("[figma-validator] GET /v1/me status:", res.status); + if (res.ok) { + console.log("[figma-validator] token accepted"); + return true; + } + let body = ""; + try { + body = await res.text(); + } catch { + /* ignore */ + } + console.log("[figma-validator] Figma error body:", body); if (res.status === 401 || res.status === 403) { return { ok: false, @@ -73,7 +84,8 @@ registerRequiredSecret({ }; } return { ok: false, error: `Figma returned ${res.status}.` }; - } catch { + } catch (err) { + console.log("[figma-validator] fetch threw:", err); return { ok: false, error: "Could not reach Figma. Check your network and try again.",