diff --git a/src/tests/unknown-paint.test.ts b/src/tests/unknown-paint.test.ts new file mode 100644 index 00000000..0659248d --- /dev/null +++ b/src/tests/unknown-paint.test.ts @@ -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 }; + 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"); + }); +}); diff --git a/src/transformers/style.ts b/src/transformers/style.ts index a58a1163..ab90a801 100644 --- a/src/transformers/style.ts +++ b/src/transformers/style.ts @@ -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"; @@ -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; @@ -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 }; } }