Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/tests/unknown-paint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { parsePaint, buildSimplifiedStrokes } from "~/transformers/style.js";
import type { Paint, Node as FigmaDocumentNode } from "@figma/rest-api-spec";

// Paint types outside the REST spec reach real files: Figma code-component
// CUSTOM paints (customEffectId + componentPropAssignments) were observed live
// on 2026-08-22. The spec's Paint union cannot express them, so the fixture
// casts through unknown exactly the way the API payload arrives at runtime.
const customPaint = {
type: "CUSTOM",
visible: true,
blendMode: "NORMAL",
customEffectId: "CodeComponentId:20e41e41-fixture",
componentPropAssignments: [{ defId: "1:2", value: "fixture" }],
} as unknown as Paint;

const solidRed: Paint = {
type: "SOLID",
blendMode: "NORMAL",
color: { r: 1, g: 0, b: 0, a: 1 },
};

describe("unknown paint types", () => {
it("degrades to a marked passthrough instead of throwing", () => {
const fill = parsePaint(customPaint);
expect(fill).toEqual({ type: "CUSTOM", unknownPaint: true, raw: customPaint });
});

it("preserves the raw Figma fields for later consumers", () => {
const fill = parsePaint(customPaint) as { raw: Record<string, unknown> };
expect(fill.raw).toBe(customPaint);
expect(fill.raw.customEffectId).toBe("CodeComponentId:20e41e41-fixture");
expect(fill.raw.componentPropAssignments).toEqual([{ defId: "1:2", value: "fixture" }]);
});

it("leaves known paints untouched", () => {
expect(parsePaint(solidRed)).toMatch(/^#ff0000$/i);
});

it("does not abort stroke parsing when one stroke paint is unknown", () => {
const node = {
id: "1:1",
name: "mixed-strokes",
type: "RECTANGLE",
strokes: [solidRed, customPaint],
strokeWeight: 2,
} as unknown as FigmaDocumentNode;
const strokes = buildSimplifiedStrokes(node);
expect(strokes.colors).toHaveLength(2);
// Reversed to CSS stacking order: the unknown top paint leads, marked.
expect(strokes.colors[0]).toEqual({ type: "CUSTOM", unknownPaint: true, raw: customPaint });
expect(strokes.colors[1]).toMatch(/^#ff0000$/i);
expect(strokes.strokeWeight).toBe("2px");
});
});
22 changes: 20 additions & 2 deletions src/transformers/style.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Node as FigmaDocumentNode, Paint } from "@figma/rest-api-spec";
import { generateCSSShorthand, isVisible } from "~/utils/common.js";
import { tagError } from "~/utils/error-meta.js";
import { hasValue, isStrokeWeights } from "~/utils/identity.js";
import { Logger } from "~/utils/logger.js";

import { convertColor, formatRGBAColor } from "./style/color.js";
import { translateScaleMode, handleImageTransform, parsePatternPaint } from "./style/image.js";
Expand All @@ -21,10 +21,22 @@ import type { CSSRGBAColor, CSSHexColor } from "./style/color.js";
import type { SimplifiedPatternFill } from "./style/image.js";
import type { SimplifiedGradientFill } from "./style/gradient.js";

/**
* A paint whose type this transformer does not recognize, passed through
* marked instead of dropped or thrown on. `raw` is the untouched Figma paint,
* so consumers that learn a new type later lose nothing in the meantime.
*/
export type SimplifiedUnknownFill = {
type: string;
unknownPaint: true;
raw: Paint;
};

export type SimplifiedFill =
| SimplifiedImageFill
| SimplifiedGradientFill
| SimplifiedPatternFill
| SimplifiedUnknownFill
| CSSRGBAColor
| CSSHexColor;

Expand Down Expand Up @@ -148,6 +160,12 @@ export function parsePaint(raw: Paint, hasChildren: boolean = false): Simplified
gradient: convertGradientToCss(raw),
};
} else {
tagError(new Error(`Unknown paint type: ${raw.type}`), { category: "internal" });
// Figma ships paint types faster than this transformer learns them (live
// example: code-component CUSTOM paints, which are absent from the REST
// spec). Throwing here failed an entire get_figma_data response over one
// paint on one node, so unknown types degrade instead: marked, with the
// raw paint preserved, and the rest of the simplification stays usable.
Logger.error(`Unknown paint type "${raw.type}" passed through unparsed.`);
return { type: raw.type, unknownPaint: true, raw };
}
}