diff --git a/bun.lock b/bun.lock
index 6e1ebdb..7c12b70 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,5 @@
{
- "lockfileVersion": 1,
+ "lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
@@ -9,79 +9,83 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5",
- "@figma/plugin-typings": "^1.131",
- "@rspack/cli": "^2.1.4",
- "@rspack/core": "^2.1.4",
- "@types/bun": "^1.3",
+ "@figma/plugin-typings": "^1.137",
+ "@rspack/cli": "^2.2.2",
+ "@rspack/core": "^2.2.2",
+ "@types/bun": "^1.4",
"husky": "^9.1",
"typescript": "^7.0",
},
},
},
"packages": {
- "@biomejs/biome": ["@biomejs/biome@2.5.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.4", "@biomejs/cli-darwin-x64": "2.5.4", "@biomejs/cli-linux-arm64": "2.5.4", "@biomejs/cli-linux-arm64-musl": "2.5.4", "@biomejs/cli-linux-x64": "2.5.4", "@biomejs/cli-linux-x64-musl": "2.5.4", "@biomejs/cli-win32-arm64": "2.5.4", "@biomejs/cli-win32-x64": "2.5.4" }, "bin": { "biome": "bin/biome" } }, "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw=="],
+ "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA=="],
- "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
+ "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
- "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
- "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
- "@figma/plugin-typings": ["@figma/plugin-typings@1.131.0", "", {}, "sha512-XBTPNGc65myupitMaeemyiF/ir1jn/EXXTdoGUCEVCfVB6KPQUy+gbfHbnC+QQiHxdw/Naikdx+9v/u4d49gfA=="],
+ "@figma/plugin-typings": ["@figma/plugin-typings@1.137.0", "", {}, "sha512-sab4AZPa5oWXjbO27UHeHWGGbl6oX+25LSXwUQIN+hoKeoeFuYeoZ7h6rCE1LZkHlvbsZjucs8wqcnOqmKiVnQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
- "@rspack/binding": ["@rspack/binding@2.1.4", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.1.4", "@rspack/binding-darwin-x64": "2.1.4", "@rspack/binding-linux-arm64-gnu": "2.1.4", "@rspack/binding-linux-arm64-musl": "2.1.4", "@rspack/binding-linux-riscv64-gnu": "2.1.4", "@rspack/binding-linux-riscv64-musl": "2.1.4", "@rspack/binding-linux-x64-gnu": "2.1.4", "@rspack/binding-linux-x64-musl": "2.1.4", "@rspack/binding-wasm32-wasi": "2.1.4", "@rspack/binding-win32-arm64-msvc": "2.1.4", "@rspack/binding-win32-ia32-msvc": "2.1.4", "@rspack/binding-win32-x64-msvc": "2.1.4" } }, "sha512-iye4BaTYtTt0qa39avWwEsUobVxFNhQHr6B8reFeKU7sdaZsC9LUoDR8JAt5gOnbnzFMPdKgrdU/VzkC6rXbIg=="],
+ "@rspack/binding": ["@rspack/binding@2.2.2", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.2.2", "@rspack/binding-darwin-x64": "2.2.2", "@rspack/binding-linux-arm64-gnu": "2.2.2", "@rspack/binding-linux-arm64-musl": "2.2.2", "@rspack/binding-linux-ppc64-gnu": "2.2.2", "@rspack/binding-linux-riscv64-gnu": "2.2.2", "@rspack/binding-linux-riscv64-musl": "2.2.2", "@rspack/binding-linux-s390x-gnu": "2.2.2", "@rspack/binding-linux-x64-gnu": "2.2.2", "@rspack/binding-linux-x64-musl": "2.2.2", "@rspack/binding-wasm32-wasi": "2.2.2", "@rspack/binding-win32-arm64-msvc": "2.2.2", "@rspack/binding-win32-ia32-msvc": "2.2.2", "@rspack/binding-win32-x64-msvc": "2.2.2" } }, "sha512-gWjKDQfVQJSBh/I+y9WTlyERsiShSJ7eI6Yl0SJs/6gjx8t4ixuwWCsaEFFjwSL4nSn6ML5hNQ7UFY3gj72BWA=="],
- "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3Xcs01iw48F4WeE4SHga6bCNb/UEFvtQX4P4eMIaJfGPjTQuxfabGE8yCPm9e3tpLZ5uo+IBnJ6nh5r6tIDOXQ=="],
+ "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/le/AvV4HSinXTPs2Lqpujxt189Z5T10Ggt96QzckLRPvIchzqoavVDDjltzC7XcaZ87XCH/X06jNKgzkcBBNA=="],
- "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-bz/AsCplLs+3fXULPQU9d4r8H4PdeljgHFyItvVIrA/NKZqzQ8sX0topf/zJVZAOtPH7GNnrKjq0/F0U2DHikQ=="],
+ "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFIcUPUXiPxM6ljenLafp5TemT8eLZm1riRn8fJYmpqNCK+aCcTaud18XHZpI5SjzzcY+xUHfVShgvNzKXuf2g=="],
- "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-x0HQTLU1MusCtNamuXxf3ayEPkvh9uuaq4wVyBqveRkn4FznSOoHUsxTAKMnjGARX+vdLV/y/SwWJRDp2RI4zw=="],
+ "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Pjby4pDSMNJQK2VBzpgCj6lb+DGuenS1fEDb6xi5/apbJb9v5WE+e43Mz7i+XqgXS8e846pjaONV3KM5WKB1LQ=="],
- "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-SEYCQD9UflKJMkYGnG5nt2gcsqdkgJsQmryBC/jxo+bOuY9gUSReU99FqCt7WRQrsHLbGeAFeTWs1QzItWG/Bw=="],
+ "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-0u9O7tTVT2z+F6o/eEY6f6+My8Jn9U56QiBwkfy90ZaoAfZyQSSTxqaK/zA7W43oFxQefeCpiPas27VUzrSakw=="],
- "@rspack/binding-linux-riscv64-gnu": ["@rspack/binding-linux-riscv64-gnu@2.1.4", "", { "os": "linux", "cpu": "none" }, "sha512-jYtQKtnDRaVfyasvTGY04Z7m+xDWZYVwAIEOB4hP7czM7FVLOMgHlMlvw/EgF0DNHrBthqbPfBIS2tP50CzpEA=="],
+ "@rspack/binding-linux-ppc64-gnu": ["@rspack/binding-linux-ppc64-gnu@2.2.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-N3lVnhq5qOvpmP5n386JzR1GcE8HzAqq4/r440Z6yle9m7PhVFJ6oXVWxgaDTYV0mtv9YLJmaG+YrjYfUa1vuA=="],
- "@rspack/binding-linux-riscv64-musl": ["@rspack/binding-linux-riscv64-musl@2.1.4", "", { "os": "linux", "cpu": "none" }, "sha512-ZgxKjQAm9pidq2kChQO2PqKI9OQpLuGD7iPBuyT0gQg3m1+7vvbbkArLBPzJ12CQHVZvqua7n/QGx8pQjJI2Zw=="],
+ "@rspack/binding-linux-riscv64-gnu": ["@rspack/binding-linux-riscv64-gnu@2.2.2", "", { "os": "linux", "cpu": "none" }, "sha512-ReClZyp32/rJkUDV/oGDU0X6BCFyEkTR6r4stFwm/qOOaG6mUMRzZe4gur8Yh979p4Hiz9EdkmfEHY02GBXcaQ=="],
- "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-C53B3e6M4yzlYn4hDxR9cHZV+HqkfFGR0zuhH8QCfdBfN5KyGsmXmujiFU85ANEvBgf9CF8VreBQeJ20lyto/g=="],
+ "@rspack/binding-linux-riscv64-musl": ["@rspack/binding-linux-riscv64-musl@2.2.2", "", { "os": "linux", "cpu": "none" }, "sha512-mqsorvTNerr3r8zI35NFTPYATPDlhepdiUhZjKT6ylqpHlP7vLU9fp4aEMK/CuTv2f+IVsa0+iZqtMxHs2tSjw=="],
- "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-UaeG3FRo7e5RameQvWRNQ6KeXF29LEk67U/ohb9tF4U38mKH1OGqhmwCf5yLkqiOSgOi7Ff3ireOZD83Fso1iw=="],
+ "@rspack/binding-linux-s390x-gnu": ["@rspack/binding-linux-s390x-gnu@2.2.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ql2Jub8QYWSugBppmj2u0SjcIj6fOQuAaLCYQOqU7zbvz/uuWTfoqmdWKExwrxeCU9Tzt7emVM0ETuVEpoca+A=="],
- "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@2.1.4", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "1.1.6" }, "cpu": "none" }, "sha512-0P1WZEfu7JOPzD/jQfk9U/6gnRFc7RpvYCQaYZVVYZNJ2gU6O0/yLegRGKNK/2L0zjYwiD0ynhOIBVUUERf5Pw=="],
+ "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-MNYKYEHrtIVEno2q5rgpou/JVffRwn109xPK3kxct95EojHsniNa8jwy6eEHeOazP4EN60Si7NElE3aQ6JssHw=="],
- "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-+aStQipk1EakLRPfD+/aQbtmTXfxqSWetcRRDWV3cAsD4ebv1tF8FLKYY1PCaFpQbX1FxzCBGF0KHxkAsi9cxA=="],
+ "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-y9/9CmE8lrECaF17GAhacibTL+SvlEPEotQnUuBFC/WFtXh8hU2E7a7bAkpYGSLH6kM0nrJZHO3hTvM1wdWOJw=="],
- "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@2.1.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-7nyrLRQ07j6i6omZuwiwwTI6rpjdy/Su3niLDAG7WsLL21u3/UQQrw6Fvm8SH3XYteghoHLSM3dhgaisuUhdJg=="],
+ "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@2.2.2", "", { "dependencies": { "@emnapi/core": "1.11.3", "@emnapi/runtime": "1.11.3", "@napi-rs/wasm-runtime": "1.1.6" }, "cpu": "none" }, "sha512-VbDIjjeFwZvMSKAOGY5IbU6lLzt6AHHHncTdMMkZ94Xk7O2BOHe9BXDV32Ln29TIW2C8m1fdxfPZWDiecVghUQ=="],
- "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z4je7JBaDpO9vvNMgCxlycycRJq+GQwwuXSXagW2xvvGM10Ij63YVtIehSMG9xaW8PED41oc9nWndoEsiD60pA=="],
+ "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@2.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-rfcNg0W3ZPZvXma1gTyEt9/Z8FxASIaQr+sMWTSaTPPaeU3xY1+0hYcrD0kUFNs3/5L4u63myI4R8qRXiuW3pA=="],
- "@rspack/cli": ["@rspack/cli@2.1.4", "", { "peerDependencies": { "@rspack/core": "^2.0.0-0", "@rspack/dev-server": "^2.0.0-0" }, "optionalPeers": ["@rspack/dev-server"], "bin": { "rspack": "./bin/rspack.js" } }, "sha512-DYFvcDwL8jzqPV+7zt7DKpq2Fsk7ZtK8YswhTTje6iaIwvtzAPLFI3BsG6YrQ3yfMj7jqjSe4Vb5MDPCxR/g4A=="],
+ "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@2.2.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-TFPvr9RZw9oHIhooDhXHzWjKcHpGPTxkznSeM2poIWU0CdEuua2rVUfsrriTF1Dmx+9kMly61DQmyOHCbb+b2g=="],
- "@rspack/core": ["@rspack/core@2.1.4", "", { "dependencies": { "@rspack/binding": "2.1.4" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-lpJgtr+JAXuDAMBJfRJ1LHyWVuYJyhZu6L6aj9t4lipUU03qwakHnzn3vwSCr68PsVvVPzR6NbJE1gJienSV0g=="],
+ "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-GvEGyL594dtWN9SoVnKWh0exrM8WLInaUjwcuA2JKbCi1ak/9iHxip/U1dY3DuVqBYBhmvYq96JPbl91xnzFjg=="],
+
+ "@rspack/cli": ["@rspack/cli@2.2.2", "", { "peerDependencies": { "@rspack/core": "^2.0.0-0", "@rspack/dev-server": "^2.0.0-0" }, "optionalPeers": ["@rspack/dev-server"], "bin": { "rspack": "./bin/rspack.js" } }, "sha512-4fViBJqInw3v43sbgJv1NVVXs0IbjzGFg76jbVECUcbJuO/L104Fwa0wrYQrGI6Fb922sq7zFR0C0XbssUOUtA=="],
+
+ "@rspack/core": ["@rspack/core@2.2.2", "", { "dependencies": { "@rspack/binding": "2.2.2" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-/yztfDZR5syIPBrUpzBpL+6fhhl0IHBPcXlNr4tOMBULbocFIz7Z4/cqvf1ix0DBbKpIGx99v6N1IDbk2gi8hw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
- "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+ "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
- "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
+ "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
@@ -123,7 +127,7 @@
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
- "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+ "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
diff --git a/package.json b/package.json
index 23d022f..920dd5e 100644
--- a/package.json
+++ b/package.json
@@ -15,14 +15,14 @@
"author": "",
"license": "",
"devDependencies": {
- "@figma/plugin-typings": "^1.131",
- "@rspack/cli": "^2.1.4",
- "@rspack/core": "^2.1.4",
+ "@figma/plugin-typings": "^1.137",
+ "@rspack/cli": "^2.2.2",
+ "@rspack/core": "^2.2.2",
"husky": "^9.1",
"typescript": "^7.0",
"@biomejs/biome": "^2.5",
- "@types/bun": "^1.3"
+ "@types/bun": "^1.4"
},
"dependencies": {
"jszip": "^3.10"
diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts
index f08eae7..2f0bd8a 100644
--- a/src/__tests__/code.test.ts
+++ b/src/__tests__/code.test.ts
@@ -2052,3 +2052,57 @@ describe('registerCodegen with usage output', () => {
expect(usageCode).toBe('')
})
})
+
+describe('registerCodegen error reporting', () => {
+ type CodegenHandler = (event: {
+ node: SceneNode
+ language: string
+ }) => Promise
+
+ it('returns a copyable diagnostic report instead of rejecting', async () => {
+ let capturedHandler: CodegenHandler | null = null
+ const figmaMock = {
+ editorType: 'dev',
+ mode: 'codegen',
+ command: 'noop',
+ codegen: {
+ on: mock((_event: string, handler: CodegenHandler) => {
+ capturedHandler = handler
+ }),
+ },
+ closePlugin: mock(() => {}),
+ } as unknown as typeof figma
+
+ const consoleError = spyOn(console, 'error').mockImplementation(() => {})
+
+ codeModule.registerCodegen(figmaMock)
+ expect(capturedHandler).not.toBeNull()
+ if (capturedHandler === null) throw new Error('Handler not captured')
+
+ // A TEXT node without getStyledTextSegments makes renderText throw the same
+ // way the minified bundle does in Figma.
+ const brokenText = {
+ id: '1:99',
+ type: 'TEXT',
+ name: 'Broken',
+ visible: true,
+ characters: 'hello',
+ } as unknown as SceneNode
+
+ const handler = capturedHandler as CodegenHandler
+ const result = await handler({ node: brokenText, language: 'devup-ui' })
+
+ expect(result).toHaveLength(1)
+ const [entry] = result as [
+ { title: string; language: string; code: string },
+ ]
+ expect(entry.title).toBe('Devup UI - Codegen Error')
+ expect(entry.language).toBe('PLAINTEXT')
+ expect(entry.code).toContain('DEVUP UI CODEGEN ERROR')
+ expect(entry.code).toContain('language : devup-ui')
+ expect(entry.code).toContain('selected : TEXT "Broken" (1:99)')
+ expect(entry.code).toContain('--- codegen path (innermost first) ---')
+ expect(entry.code).toContain('buildTree :: TEXT "Broken" (1:99)')
+ expect(consoleError).toHaveBeenCalled()
+ })
+})
diff --git a/src/code-impl.ts b/src/code-impl.ts
index 4f8e60a..c7b6ccd 100644
--- a/src/code-impl.ts
+++ b/src/code-impl.ts
@@ -2,32 +2,20 @@ import {
Codegen,
DEFAULT_CODEGEN_OPTIONS,
resetGlobalBuildTreeCache,
- resetMainComponentCache,
} from './codegen/Codegen'
-import { resetGetPropsCache } from './codegen/props'
-import { resetChildAnimationCache } from './codegen/props/reaction'
-import {
- resetSelectorPropsCache,
- sanitizePropertyName,
-} from './codegen/props/selector'
+import { sanitizePropertyName } from './codegen/props/selector'
+import { resetCodegenCaches } from './codegen/reset-caches'
import { ResponsiveCodegen } from './codegen/responsive/ResponsiveCodegen'
import {
coerceBooleanVariantValue,
isBooleanVariantOptions,
} from './codegen/utils/boolean-variant'
-import { resetCheckAssetNodeCache } from './codegen/utils/check-asset-node'
-import { resetCheckSameColorCache } from './codegen/utils/check-same-color'
import type { ImportMetadata } from './codegen/utils/collect-import-metadata'
+import { formatCodegenErrorReport } from './codegen/utils/diagnostics'
import { isReservedVariantKey } from './codegen/utils/extract-instance-variant-props'
-import {
- getComponentPropertyDefinitions,
- resetComponentPropertyDefinitionsCache,
-} from './codegen/utils/get-component-property-definitions'
-import { resetGetPageNodeCache } from './codegen/utils/get-page-node'
+import { getComponentPropertyDefinitions } from './codegen/utils/get-component-property-definitions'
import { nodeProxyTracker } from './codegen/utils/node-proxy'
-import { resetPaintToCssCache } from './codegen/utils/paint-to-css'
import { perfEnd, perfReport, perfReset, perfStart } from './codegen/utils/perf'
-import { resetVariableCache } from './codegen/utils/variable-cache'
import { wrapComponent } from './codegen/utils/wrap-component'
import { exportDevup, importDevup } from './commands/devup'
import { exportAssets } from './commands/exportAssets'
@@ -40,7 +28,7 @@ import {
export { extractCustomComponentImports, extractImports }
-import { getComponentName, resetTextStyleCache } from './utils'
+import { getComponentName } from './utils'
import { toPascal } from './utils/to-pascal'
type GeneratedCodeEntry = readonly [string, string, ImportMetadata?]
@@ -241,7 +229,10 @@ const debug = true
export function registerCodegen(ctx: typeof figma) {
if (ctx.editorType === 'dev' && ctx.mode === 'codegen') {
- ctx.codegen.on('generate', async ({ node: n, language }) => {
+ const handler = async ({
+ node: n,
+ language,
+ }: CodegenEvent): Promise => {
// Use the raw node for codegen (no Proxy overhead).
// Debug tracking happens AFTER codegen completes via separate walk.
const node = n
@@ -249,18 +240,7 @@ export function registerCodegen(ctx: typeof figma) {
case 'devup-ui': {
const time = Date.now()
perfReset()
- resetGetPropsCache()
- resetSelectorPropsCache()
- resetChildAnimationCache()
- resetVariableCache()
- resetCheckAssetNodeCache()
- resetCheckSameColorCache()
- resetPaintToCssCache()
- resetGetPageNodeCache()
- resetComponentPropertyDefinitionsCache()
- resetTextStyleCache()
- resetMainComponentCache()
- resetGlobalBuildTreeCache()
+ resetCodegenCaches()
let t = perfStart()
const codegen = new Codegen(node, DEFAULT_CODEGEN_OPTIONS)
@@ -594,6 +574,28 @@ export function registerCodegen(ctx: typeof figma) {
}
}
return []
+ }
+
+ ctx.codegen.on('generate', async (event) => {
+ try {
+ return await handler(event)
+ } catch (error) {
+ // The bundle is minified, so Figma's own stack trace is unreadable and
+ // the throw surfaces only as "unhandled promise rejection". Surface a
+ // structured report in the codegen panel instead, so it can be copied.
+ const report = formatCodegenErrorReport(error, {
+ node: event.node,
+ language: event.language,
+ })
+ console.error(report)
+ return [
+ {
+ title: 'Devup UI - Codegen Error',
+ language: 'PLAINTEXT' as const,
+ code: report,
+ },
+ ]
+ }
})
}
}
diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts
index 3325c95..995d3df 100644
--- a/src/codegen/Codegen.ts
+++ b/src/codegen/Codegen.ts
@@ -16,6 +16,7 @@ import {
type ImportMetadata,
mergeImportMetadata,
} from './utils/collect-import-metadata'
+import { traceAsync, traceSync } from './utils/diagnostics'
import { extractInstanceVariantProps } from './utils/extract-instance-variant-props'
import { getComponentPropertyDefinitions } from './utils/get-component-property-definitions'
import {
@@ -500,7 +501,11 @@ export class Codegen {
return result
}
- private async doBuildTree(node: SceneNode): Promise {
+ private doBuildTree(node: SceneNode): Promise {
+ return traceAsync('buildTree', node, () => this.doBuildTreeInner(node))
+ }
+
+ private async doBuildTreeInner(node: SceneNode): Promise {
const tBuild = perfStart()
let pendingAddComponentTreeNode: ComponentNode | null = null
@@ -912,7 +917,16 @@ export class Codegen {
return promise
}
- private async doAddComponentTree(
+ private doAddComponentTree(
+ node: ComponentNode,
+ nodeId: string,
+ ): Promise {
+ return traceAsync('addComponentTree', node, () =>
+ this.doAddComponentTreeInner(node, nodeId),
+ )
+ }
+
+ private async doAddComponentTreeInner(
node: ComponentNode,
nodeId: string,
): Promise {
@@ -1102,6 +1116,12 @@ export class Codegen {
* Static method so it can be used independently.
*/
static renderTree(tree: NodeTree, depth: number = 0): string {
+ return traceSync('renderTree', tree, () =>
+ Codegen.renderTreeInner(tree, depth),
+ )
+ }
+
+ private static renderTreeInner(tree: NodeTree, depth: number): string {
// Handle INSTANCE_SWAP slot placeholders — render as {propName}
if (tree.isSlot) {
if (tree.condition) {
diff --git a/src/codegen/__tests__/codegen-viewport.test.ts b/src/codegen/__tests__/codegen-viewport.test.ts
index 915cfef..0648a73 100644
--- a/src/codegen/__tests__/codegen-viewport.test.ts
+++ b/src/codegen/__tests__/codegen-viewport.test.ts
@@ -1,13 +1,11 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
-import { resetTextStyleCache } from '../../utils'
import {
Codegen,
getGlobalAssetNodes,
resetGlobalAssetNodes,
resetGlobalBuildTreeCache,
} from '../Codegen'
-import { resetGetPropsCache } from '../props'
-import { resetSelectorPropsCache } from '../props/selector'
+import { resetCodegenCaches } from '../reset-caches'
import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
// Mock figma global
@@ -50,20 +48,17 @@ import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
},
} as unknown as typeof figma
+// Every codegen cache is keyed by node.id, and other test files reuse ids such
+// as `status-error`. Reset all of them — exactly like a real codegen run does —
+// so results never depend on which test file ran first.
beforeEach(() => {
- resetGlobalBuildTreeCache()
+ resetCodegenCaches()
resetGlobalAssetNodes()
- resetGetPropsCache()
- resetSelectorPropsCache()
- resetTextStyleCache()
})
afterAll(() => {
- resetGlobalBuildTreeCache()
+ resetCodegenCaches()
resetGlobalAssetNodes()
- resetGetPropsCache()
- resetSelectorPropsCache()
- resetTextStyleCache()
;(globalThis as { figma?: unknown }).figma = undefined
})
diff --git a/src/codegen/__tests__/codegen.test.ts b/src/codegen/__tests__/codegen.test.ts
index 942e8b4..8df893c 100644
--- a/src/codegen/__tests__/codegen.test.ts
+++ b/src/codegen/__tests__/codegen.test.ts
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, it, test } from 'bun:test'
import { getComponentName } from '../../utils'
import { toPascal } from '../../utils/to-pascal'
import { Codegen, DEFAULT_CODEGEN_OPTIONS } from '../Codegen'
+import { renderText } from '../render/text'
import { ResponsiveCodegen } from '../responsive/ResponsiveCodegen'
import type { NodeTree } from '../types'
import { collectComponentProps } from '../utils/collect-component-props'
@@ -61237,3 +61238,30 @@ describe('render real world component', () => {
}
})
})
+
+describe('renderText with segments missing listOptions', () => {
+ it('treats a segment without listOptions as a plain (non-list) segment', async () => {
+ // Figma returns `listOptions: undefined` — not `{ type: 'NONE' }` — for the
+ // non-list part of a TEXT node that also contains a bulleted list.
+ const plain = createTextSegment('Heading\n')
+ ;(plain as unknown as { listOptions?: unknown }).listOptions = undefined
+ const bullet = createTextSegment('list item')
+ ;(bullet as unknown as { listOptions?: unknown }).listOptions = {
+ type: 'UNORDERED',
+ }
+
+ const node = {
+ id: '1:1',
+ type: 'TEXT',
+ name: 'Mixed',
+ getStyledTextSegments: () => [plain, bullet],
+ } as unknown as TextNode
+
+ const { children } = await renderText(node)
+ const joined = children.join('')
+ expect(joined).toContain('Heading')
+ expect(joined).toContain('')
+ expect(joined).toContain('list item')
+ })
+})
diff --git a/src/codegen/props/__tests__/bound-variables.test.ts b/src/codegen/props/__tests__/bound-variables.test.ts
index e6ddef1..c9c1af6 100644
--- a/src/codegen/props/__tests__/bound-variables.test.ts
+++ b/src/codegen/props/__tests__/bound-variables.test.ts
@@ -93,6 +93,36 @@ describe('length bound variables (padding / gap / size / radius)', () => {
})
})
+ test.each([
+ ['SPACE_EVENLY', 'space-evenly'],
+ ['SPACE_AROUND', 'space-around'],
+ ] as const)(
+ 'getAutoLayoutProps maps %s distribution to %s',
+ async (primaryAxisAlignItems, justifyContent) => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ inferredAutoLayout: {
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 8,
+ },
+ primaryAxisAlignItems,
+ counterAxisAlignItems: 'CENTER',
+ children: [{ visible: true }, { visible: true }],
+ boundVariables: {},
+ } as unknown as SceneNode
+
+ expect(await getAutoLayoutProps(node)).toEqual({
+ display: 'flex',
+ flexDir: 'row',
+ gap: '8px',
+ justifyContent,
+ alignItems: 'center',
+ })
+ },
+ )
+
test('getLayoutProps resolves width/height variables on absolute nodes', async () => {
setupFigmaMocks({
variableNamesById: {
diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts
index f33d95a..96fedc1 100644
--- a/src/codegen/props/auto-layout.ts
+++ b/src/codegen/props/auto-layout.ts
@@ -59,6 +59,8 @@ function getJustifyContent(
MAX: 'flex-end',
CENTER: 'center',
SPACE_BETWEEN: 'space-between',
+ SPACE_AROUND: 'space-around',
+ SPACE_EVENLY: 'space-evenly',
}[node.primaryAxisAlignItems]
}
diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts
index d6096e7..12ae2da 100644
--- a/src/codegen/props/index.ts
+++ b/src/codegen/props/index.ts
@@ -1,5 +1,6 @@
import type { NodeBoundVariables, NodeContext } from '../types'
import { checkAssetNode } from '../utils/check-asset-node'
+import { traceAsync, traceSync } from '../utils/diagnostics'
import { getPageNode } from '../utils/get-page-node'
import { isPageRoot } from '../utils/is-page-root'
import { perfEnd, perfStart } from '../utils/perf'
@@ -76,11 +77,17 @@ export async function getProps(
}
const t = perfStart()
- const promise = (async () => {
+ const promise = traceAsync('getProps', node, async () => {
+ // Tag every getter so a failure names the exact getter, not just "getProps".
+ const traced = (label: string, run: () => T): T =>
+ traceSync(label, node, run)
+ const tracedAsync = (label: string, run: () => Promise): Promise =>
+ traceAsync(label, node, run)
+
const isText = node.type === 'TEXT'
// Compute cross-cutting node context ONCE for all sync getters that need it.
- const ctx = computeNodeContext(node)
+ const ctx = traced('computeNodeContext', () => computeNodeContext(node))
const hasFills = 'fills' in node && node.fills !== figma.mixed
const hasStrokes = 'strokes' in node && node.strokes.length > 0
const hasEffects = 'effects' in node && node.effects.length > 0
@@ -98,52 +105,87 @@ export async function getProps(
// + padding, auto-layout, layout, min-max, border-radius,
// effect, text-shadow (newly async for variable support)
const tBorder = perfStart()
- const borderP = hasStrokes && !isText ? getBorderProps(node) : undefined
+ const borderP =
+ hasStrokes && !isText
+ ? tracedAsync('getBorderProps', () => getBorderProps(node))
+ : undefined
const tBg = perfStart()
- const bgP = hasFills ? getBackgroundProps(node) : undefined
+ const bgP = hasFills
+ ? tracedAsync('getBackgroundProps', () => getBackgroundProps(node))
+ : undefined
const tTextStroke = perfStart()
const textStrokeP =
- isText && hasStrokes ? getTextStrokeProps(node) : undefined
+ isText && hasStrokes
+ ? tracedAsync('getTextStrokeProps', () => getTextStrokeProps(node))
+ : undefined
const tReaction = perfStart()
const reactionP = hasReactionProps(node)
- ? getReactionProps(node)
+ ? tracedAsync('getReactionProps', () => getReactionProps(node))
: undefined
const tAutoLayout = perfStart()
const autoLayoutP = hasInferredAutoLayout
- ? getAutoLayoutProps(node, ctx)
+ ? tracedAsync('getAutoLayoutProps', () => getAutoLayoutProps(node, ctx))
: undefined
const tMinMax = perfStart()
- const minMaxP = getMinMaxProps(node, ctx)
+ const minMaxP = tracedAsync('getMinMaxProps', () =>
+ getMinMaxProps(node, ctx),
+ )
const tLayout = perfStart()
- const layoutP = getLayoutProps(node, ctx)
+ const layoutP = tracedAsync('getLayoutProps', () =>
+ getLayoutProps(node, ctx),
+ )
const tBorderRadius = perfStart()
const borderRadiusP = hasRadius
- ? getBorderRadiusProps(node, ctx)
+ ? tracedAsync('getBorderRadiusProps', () =>
+ getBorderRadiusProps(node, ctx),
+ )
: undefined
const tPadding = perfStart()
const paddingP =
hasInferredAutoLayout || hasPadding
- ? getPaddingProps(node, ctx)
+ ? tracedAsync('getPaddingProps', () => getPaddingProps(node, ctx))
: undefined
const tEffect = perfStart()
- const effectP = hasEffects ? getEffectProps(node) : undefined
+ const effectP = hasEffects
+ ? tracedAsync('getEffectProps', () => getEffectProps(node))
+ : undefined
const tTextShadow = perfStart()
const textShadowP =
- isText && hasEffects ? getTextShadowProps(node) : undefined
+ isText && hasEffects
+ ? tracedAsync('getTextShadowProps', () => getTextShadowProps(node))
+ : undefined
// PHASE 2: Run sync prop getters while async IPC is pending in background.
const tSync = perfStart()
- const blendProps = getBlendProps(node)
- const textAlignProps = isText ? getTextAlignProps(node) : undefined
- const objectFitProps = getObjectFitProps(node)
- const maxLineProps = isText ? getMaxLineProps(node) : undefined
- const ellipsisProps = isText ? getEllipsisProps(node) : undefined
- const positionProps = getPositionProps(node, ctx)
- const gridChildProps = getGridChildProps(node)
- const transformProps = getTransformProps(node, ctx)
- const overflowProps = getOverflowProps(node)
- const cursorProps = getCursorProps(node)
- const visibilityProps = getVisibilityProps(node)
+ const blendProps = traced('getBlendProps', () => getBlendProps(node))
+ const textAlignProps = isText
+ ? traced('getTextAlignProps', () => getTextAlignProps(node))
+ : undefined
+ const objectFitProps = traced('getObjectFitProps', () =>
+ getObjectFitProps(node),
+ )
+ const maxLineProps = isText
+ ? traced('getMaxLineProps', () => getMaxLineProps(node))
+ : undefined
+ const ellipsisProps = isText
+ ? traced('getEllipsisProps', () => getEllipsisProps(node))
+ : undefined
+ const positionProps = traced('getPositionProps', () =>
+ getPositionProps(node, ctx),
+ )
+ const gridChildProps = traced('getGridChildProps', () =>
+ getGridChildProps(node),
+ )
+ const transformProps = traced('getTransformProps', () =>
+ getTransformProps(node, ctx),
+ )
+ const overflowProps = traced('getOverflowProps', () =>
+ getOverflowProps(node),
+ )
+ const cursorProps = traced('getCursorProps', () => getCursorProps(node))
+ const visibilityProps = traced('getVisibilityProps', () =>
+ getVisibilityProps(node),
+ )
perfEnd('getProps.sync', tSync)
// PHASE 3: Await async results — likely already resolved during sync phase.
@@ -202,7 +244,7 @@ export async function getProps(
if (textShadowProps) Object.assign(result, textShadowProps)
Object.assign(result, reactionProps, cursorProps, visibilityProps)
return result
- })()
+ })
if (cacheKey) {
getPropsCache.set(cacheKey, promise)
diff --git a/src/codegen/render/text.ts b/src/codegen/render/text.ts
index 9bc7aac..e81f29b 100644
--- a/src/codegen/render/text.ts
+++ b/src/codegen/render/text.ts
@@ -1,5 +1,6 @@
import { propsToPropsWithTypography } from '../../utils'
import { textSegmentToTypography } from '../../utils/text-segment-to-typography'
+import { traceAsync } from '../utils/diagnostics'
import { fixTextChild } from '../utils/fix-text-child'
import { paintToCSS, paintToCSSSyncIfPossible } from '../utils/paint-to-css'
import { perfEnd, perfStart } from '../utils/perf'
@@ -31,7 +32,14 @@ const SEGMENT_TYPE = [
'indentation',
'hyperlink',
] as (keyof Omit)[]
-export async function renderText(node: TextNode): Promise<{
+export function renderText(node: TextNode): Promise<{
+ children: string[]
+ props: Record
+}> {
+ return traceAsync('renderText', node, () => renderTextInner(node))
+}
+
+async function renderTextInner(node: TextNode): Promise<{
children: string[]
props: Record
}> {
@@ -109,10 +117,15 @@ export async function renderText(node: TextNode): Promise<{
let text: string[] = [fixTextChild(seg.characters)]
let textComponent: 'ul' | 'ol' | null = null
- if (seg.listOptions.type === 'NONE') {
+ // Figma omits `listOptions` (leaves it undefined) on the non-list
+ // segments of a TEXT node that also contains a bulleted/numbered list,
+ // so a missing value means "not a list".
+ const listType = seg.listOptions?.type ?? 'NONE'
+
+ if (listType === 'NONE') {
text = text.map((line) => line.replace(/\r\n|\r|\n/g, '
'))
} else {
- switch (seg.listOptions.type) {
+ switch (listType) {
case 'UNORDERED': {
textComponent = 'ul'
break
diff --git a/src/codegen/reset-caches.ts b/src/codegen/reset-caches.ts
new file mode 100644
index 0000000..a9b1c6f
--- /dev/null
+++ b/src/codegen/reset-caches.ts
@@ -0,0 +1,34 @@
+import { resetTextStyleCache } from '../utils'
+import { resetGlobalBuildTreeCache, resetMainComponentCache } from './Codegen'
+import { resetGetPropsCache } from './props'
+import { resetChildAnimationCache } from './props/reaction'
+import { resetSelectorPropsCache } from './props/selector'
+import { resetCheckAssetNodeCache } from './utils/check-asset-node'
+import { resetCheckSameColorCache } from './utils/check-same-color'
+import { resetComponentPropertyDefinitionsCache } from './utils/get-component-property-definitions'
+import { resetGetPageNodeCache } from './utils/get-page-node'
+import { resetPaintToCssCache } from './utils/paint-to-css'
+import { resetVariableCache } from './utils/variable-cache'
+
+/**
+ * Clear every memoization cache the codegen pipeline keeps between runs.
+ *
+ * All of these caches are keyed by `node.id`, so a stale entry from a previous
+ * run silently changes the output of the next one. A codegen run must therefore
+ * start from a clean slate — and so must every test that drives codegen, or the
+ * result depends on which test happened to run first.
+ */
+export function resetCodegenCaches(): void {
+ resetGetPropsCache()
+ resetSelectorPropsCache()
+ resetChildAnimationCache()
+ resetVariableCache()
+ resetCheckAssetNodeCache()
+ resetCheckSameColorCache()
+ resetPaintToCssCache()
+ resetGetPageNodeCache()
+ resetComponentPropertyDefinitionsCache()
+ resetTextStyleCache()
+ resetMainComponentCache()
+ resetGlobalBuildTreeCache()
+}
diff --git a/src/codegen/utils/__tests__/diagnostics.test.ts b/src/codegen/utils/__tests__/diagnostics.test.ts
new file mode 100644
index 0000000..15c3709
--- /dev/null
+++ b/src/codegen/utils/__tests__/diagnostics.test.ts
@@ -0,0 +1,266 @@
+import { describe, expect, it } from 'bun:test'
+import {
+ describeNode,
+ errorMessage,
+ formatCodegenErrorReport,
+ getErrorTrace,
+ inspectNode,
+ tagError,
+ traceAsync,
+ traceSync,
+} from '../diagnostics'
+
+function throwingProp(key: string, rest: Record = {}) {
+ const node: Record = { ...rest }
+ Object.defineProperty(node, key, {
+ enumerable: true,
+ get() {
+ throw new Error(`cannot read ${key}`)
+ },
+ })
+ return node
+}
+
+describe('errorMessage', () => {
+ it('formats Error instances', () => {
+ expect(errorMessage(new TypeError('boom'))).toBe('TypeError: boom')
+ })
+
+ it('formats non-error throws', () => {
+ expect(errorMessage('boom')).toBe('Thrown non-error value: boom')
+ })
+})
+
+describe('describeNode', () => {
+ it('describes a SceneNode', () => {
+ expect(describeNode({ type: 'TEXT', name: 'Title', id: '1:2' })).toBe(
+ 'TEXT "Title" (1:2)',
+ )
+ })
+
+ it('falls back to NodeTree fields', () => {
+ expect(describeNode({ nodeType: 'FRAME', nodeName: 'Row' })).toBe(
+ 'FRAME "Row" ()',
+ )
+ })
+
+ it('falls back to placeholders when fields are missing or unreadable', () => {
+ expect(describeNode({})).toBe('UNKNOWN "" ()')
+ expect(describeNode({ type: 1, name: 2, id: 3 })).toBe(
+ 'UNKNOWN "" ()',
+ )
+ expect(describeNode(throwingProp('type'))).toBe(
+ 'UNKNOWN "" ()',
+ )
+ })
+
+ it('handles non-object values', () => {
+ expect(describeNode(null)).toBe('')
+ expect(describeNode(undefined)).toBe('')
+ expect(describeNode('nope')).toBe('')
+ })
+})
+
+describe('inspectNode', () => {
+ it('reports non-object values', () => {
+ expect(inspectNode(undefined)).toBe(' ')
+ expect(inspectNode(null)).toBe(' ')
+ })
+
+ it('dumps scalar and list fields', () => {
+ const report = inspectNode({
+ id: '1:2',
+ name: 'Row',
+ type: 'FRAME',
+ visible: true,
+ width: 100,
+ fills: [{ type: 'SOLID' }, { color: 1 }, 'x'],
+ children: [],
+ reactions: 'MIXED',
+ })
+ expect(report).toContain('id: "1:2"')
+ expect(report).toContain('type: "FRAME"')
+ expect(report).toContain('visible: true')
+ expect(report).toContain('width: 100')
+ expect(report).toContain('height: ')
+ expect(report).toContain('fills: len=3 [SOLID, object, string]')
+ expect(report).toContain('children: len=0 []')
+ expect(report).toContain('reactions: "MIXED"')
+ })
+
+ it('truncates long strings and reports unserializable values', () => {
+ const circular: Record = {}
+ circular.self = circular
+ const report = inspectNode({
+ name: 'a'.repeat(200),
+ component: circular,
+ })
+ expect(report).toContain('...')
+ expect(report).toContain('component: ')
+ })
+
+ it('reports fields whose getter throws', () => {
+ expect(inspectNode(throwingProp('width'))).toContain(
+ 'width: ',
+ )
+ })
+
+ it('dumps styled text segments for TEXT nodes', () => {
+ const report = inspectNode({
+ type: 'TEXT',
+ characters: 'hi',
+ getStyledTextSegments: () => [
+ {
+ listOptions: { type: 'NONE' },
+ fills: [{ type: 'SOLID' }],
+ textStyleId: '',
+ fontName: { family: 'Inter' },
+ characters: 'hi',
+ },
+ { characters: 'bye' },
+ ],
+ })
+ expect(report).toContain('styledTextSegments: len=2')
+ expect(report).toContain('[0] listOptions={"type":"NONE"}')
+ expect(report).toContain('[1] listOptions=undefined')
+ })
+
+ it('reports when styled text segments are unavailable or throw', () => {
+ expect(inspectNode({ type: 'TEXT' })).toContain(
+ 'styledTextSegments: ',
+ )
+ expect(
+ inspectNode({
+ type: 'TEXT',
+ getStyledTextSegments: () => {
+ throw new Error('nope')
+ },
+ }),
+ ).toContain('styledTextSegments: ')
+ expect(inspectNode(throwingProp('type'))).toContain(
+ 'styledTextSegments: ',
+ )
+ })
+})
+
+describe('tagError / getErrorTrace', () => {
+ it('attaches frames to an error and appends on re-tag', () => {
+ const error = new Error('boom')
+ tagError(error, 'renderText', { type: 'TEXT', name: 'A', id: '1' })
+ tagError(error, 'buildTree', { type: 'FRAME', name: 'B', id: '2' })
+ const trace = getErrorTrace(error)
+ expect(trace).toHaveLength(2)
+ expect(trace[0].label).toBe('renderText')
+ expect(trace[1].label).toBe('buildTree')
+ })
+
+ it('caps the number of frames', () => {
+ const error = new Error('boom')
+ for (let i = 0; i < 60; i++) tagError(error, `frame-${i}`, null)
+ expect(getErrorTrace(error)).toHaveLength(50)
+ })
+
+ it('wraps non-object throws so they can carry a trace', () => {
+ const tagged = tagError('boom', 'buildTree', null)
+ expect(tagged).toBeInstanceOf(Error)
+ expect((tagged as Error).message).toBe('Thrown non-error value: boom')
+ expect(getErrorTrace(tagged)).toHaveLength(1)
+ })
+
+ it('skips non-extensible errors', () => {
+ const error = Object.preventExtensions(new Error('frozen'))
+ expect(tagError(error, 'buildTree', null)).toBe(error)
+ expect(getErrorTrace(error)).toHaveLength(0)
+ })
+
+ it('returns an empty trace for untagged values', () => {
+ expect(getErrorTrace(null)).toHaveLength(0)
+ expect(getErrorTrace('boom')).toHaveLength(0)
+ expect(getErrorTrace(new Error('boom'))).toHaveLength(0)
+ expect(getErrorTrace({ __devupTrace: 'nope' })).toHaveLength(0)
+ })
+})
+
+describe('traceSync / traceAsync', () => {
+ it('passes values through untouched', async () => {
+ expect(traceSync('renderTree', null, () => 'ok')).toBe('ok')
+ await expect(traceAsync('buildTree', null, async () => 'ok')).resolves.toBe(
+ 'ok',
+ )
+ })
+
+ it('tags synchronous failures', () => {
+ const node = { type: 'FRAME', name: 'Row', id: '1:2' }
+ expect(() =>
+ traceSync('renderTree', node, () => {
+ throw new Error('boom')
+ }),
+ ).toThrow('boom')
+ try {
+ traceSync('renderTree', node, () => {
+ throw new Error('boom')
+ })
+ } catch (error) {
+ expect(getErrorTrace(error)[0]).toEqual({ label: 'renderTree', node })
+ }
+ })
+
+ it('tags asynchronous failures', async () => {
+ const node = { type: 'TEXT', name: 'Title', id: '1:3' }
+ try {
+ await traceAsync('renderText', node, async () => {
+ throw new Error('boom')
+ })
+ throw new Error('should not reach')
+ } catch (error) {
+ expect(getErrorTrace(error)[0]).toEqual({ label: 'renderText', node })
+ }
+ })
+})
+
+describe('formatCodegenErrorReport', () => {
+ it('renders the traced path and the failing node', async () => {
+ const textNode = {
+ type: 'TEXT',
+ name: 'Title',
+ id: '1:3',
+ getStyledTextSegments: () => [{ characters: 'hi' }],
+ }
+ const frameNode = { type: 'FRAME', name: 'Row', id: '1:2' }
+ let caught: unknown
+ try {
+ await traceAsync('buildTree', frameNode, () =>
+ traceAsync('renderText', textNode, async () => {
+ throw new TypeError("cannot read property 'type' of undefined")
+ }),
+ )
+ } catch (error) {
+ caught = error
+ }
+
+ const report = formatCodegenErrorReport(caught, {
+ node: frameNode,
+ language: 'devup-ui',
+ })
+ expect(report).toContain('DEVUP UI CODEGEN ERROR')
+ expect(report).toContain(
+ "error : TypeError: cannot read property 'type' of undefined",
+ )
+ expect(report).toContain('language : devup-ui')
+ expect(report).toContain('selected : FRAME "Row" (1:2)')
+ expect(report).toContain('failing : TEXT "Title" (1:3)')
+ expect(report).toContain('1. renderText :: TEXT "Title" (1:3)')
+ expect(report).toContain('2. buildTree :: FRAME "Row" (1:2)')
+ expect(report).toContain('styledTextSegments: len=1')
+ expect(report).toContain('--- raw stack (minified) ---')
+ })
+
+ it('falls back when there is no trace and no stack', () => {
+ const report = formatCodegenErrorReport('boom')
+ expect(report).toContain('error : Thrown non-error value: boom')
+ expect(report).toContain('language : ')
+ expect(report).toContain('')
+ expect(report).toContain(' ')
+ expect(report).toContain(' ')
+ })
+})
diff --git a/src/codegen/utils/diagnostics.ts b/src/codegen/utils/diagnostics.ts
new file mode 100644
index 0000000..49ec50d
--- /dev/null
+++ b/src/codegen/utils/diagnostics.ts
@@ -0,0 +1,261 @@
+/**
+ * Codegen diagnostics.
+ *
+ * The plugin ships as one minified bundle, so the stack traces Figma prints
+ * (`at (PLUGIN_37_SOURCE:1:243768)`) carry no usable information.
+ * Instead of relying on them, every significant codegen step tags the error it
+ * observes with the Figma node it was processing. `formatCodegenErrorReport`
+ * turns that chain into a copy-pasteable report naming the exact node, the
+ * phase, and the node's raw shape.
+ *
+ * Tagging is zero-cost on the success path — nothing is formatted or allocated
+ * for the report until an error actually escapes.
+ */
+
+export interface TraceFrame {
+ label: string
+ node: unknown
+}
+
+interface TracedError {
+ __devupTrace?: TraceFrame[]
+}
+
+const TRACE_KEY = '__devupTrace'
+
+/** Caps the trace so a deep tree cannot produce an unbounded report. */
+const MAX_TRACE_FRAMES = 50
+
+const SEPARATOR = '='.repeat(72)
+
+/** Scalar-ish fields dumped verbatim for the failing node. */
+const BASIC_KEYS = [
+ 'id',
+ 'name',
+ 'type',
+ 'nodeType',
+ 'nodeName',
+ 'component',
+ 'visible',
+ 'width',
+ 'height',
+ 'opacity',
+ 'rotation',
+ 'characters',
+] as const
+
+/** Array fields dumped as `len=N [itemType, ...]`. */
+const LIST_KEYS = [
+ 'children',
+ 'fills',
+ 'strokes',
+ 'effects',
+ 'reactions',
+] as const
+
+const TEXT_SEGMENT_FIELDS = [
+ 'listOptions',
+ 'fills',
+ 'textStyleId',
+ 'fontName',
+ 'fontSize',
+] as const
+
+export function errorMessage(error: unknown): string {
+ if (error instanceof Error) return `${error.name}: ${error.message}`
+ return `Thrown non-error value: ${String(error)}`
+}
+
+function formatValue(value: unknown): string {
+ if (typeof value === 'string')
+ return JSON.stringify(
+ value.length > 80 ? `${value.slice(0, 80)}...` : value,
+ )
+ if (typeof value === 'object' && value !== null) {
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return ''
+ }
+ }
+ return String(value)
+}
+
+function summarizeList(value: unknown): string {
+ if (!Array.isArray(value)) return formatValue(value)
+ const kinds = value.map((item) => {
+ if (item !== null && typeof item === 'object') {
+ const type = (item as { type?: unknown }).type
+ if (typeof type === 'string') return type
+ }
+ return typeof item
+ })
+ return `len=${value.length} [${kinds.join(', ')}]`
+}
+
+function readString(
+ record: Record,
+ key: string,
+): string | null {
+ try {
+ const value = record[key]
+ return typeof value === 'string' ? value : null
+ } catch {
+ return null
+ }
+}
+
+function readForReport(
+ record: Record,
+ key: string,
+ format: (value: unknown) => string,
+): string {
+ try {
+ const value = record[key]
+ return value === undefined ? '' : format(value)
+ } catch (error) {
+ return ``
+ }
+}
+
+/**
+ * TEXT nodes are the richest failure source (styled segments carry optional
+ * fields such as `listOptions`), so dump every segment when one is involved.
+ */
+function inspectTextSegments(record: Record): string[] {
+ try {
+ if (record.type !== 'TEXT') return []
+ const getSegments = record.getStyledTextSegments
+ if (typeof getSegments !== 'function')
+ return [' styledTextSegments: ']
+ const segments = (
+ getSegments as (fields: readonly string[]) => unknown[]
+ ).call(record, TEXT_SEGMENT_FIELDS)
+ const lines = [` styledTextSegments: len=${segments.length}`]
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i] as Record
+ lines.push(
+ ` [${i}] listOptions=${formatValue(seg.listOptions)}` +
+ ` fills=${summarizeList(seg.fills)}` +
+ ` textStyleId=${formatValue(seg.textStyleId)}` +
+ ` fontName=${formatValue(seg.fontName)}` +
+ ` characters=${formatValue(seg.characters)}`,
+ )
+ }
+ return lines
+ } catch (error) {
+ return [` styledTextSegments: `]
+ }
+}
+
+/** One-line identity of a Figma SceneNode or an intermediate NodeTree. */
+export function describeNode(node: unknown): string {
+ if (node === null || typeof node !== 'object') return `<${String(node)}>`
+ const record = node as Record
+ const type =
+ readString(record, 'type') ?? readString(record, 'nodeType') ?? 'UNKNOWN'
+ const name =
+ readString(record, 'name') ?? readString(record, 'nodeName') ?? ''
+ const id = readString(record, 'id') ?? ''
+ return `${type} "${name}" (${id})`
+}
+
+/** Multi-line dump of everything we can safely read off the failing node. */
+export function inspectNode(node: unknown): string {
+ if (node === null || typeof node !== 'object')
+ return ` `
+ const record = node as Record
+ const lines: string[] = []
+ for (const key of BASIC_KEYS)
+ lines.push(` ${key}: ${readForReport(record, key, formatValue)}`)
+ for (const key of LIST_KEYS)
+ lines.push(` ${key}: ${readForReport(record, key, summarizeList)}`)
+ for (const line of inspectTextSegments(record)) lines.push(line)
+ return lines.join('\n')
+}
+
+/**
+ * Attach `label` + `node` to an error so the caller chain becomes readable.
+ * Non-object throws are wrapped so they can carry a trace too.
+ */
+export function tagError(
+ error: unknown,
+ label: string,
+ node: unknown,
+): unknown {
+ const carrier =
+ error !== null && typeof error === 'object'
+ ? error
+ : new Error(errorMessage(error))
+ const traced = carrier as TracedError
+ const frames = traced[TRACE_KEY]
+ if (frames) {
+ if (frames.length < MAX_TRACE_FRAMES) frames.push({ label, node })
+ } else if (Object.isExtensible(carrier)) {
+ traced[TRACE_KEY] = [{ label, node }]
+ }
+ return carrier
+}
+
+export function getErrorTrace(error: unknown): readonly TraceFrame[] {
+ if (error === null || typeof error !== 'object') return []
+ const frames = (error as TracedError)[TRACE_KEY]
+ return Array.isArray(frames) ? frames : []
+}
+
+export function traceSync(label: string, node: unknown, run: () => T): T {
+ try {
+ return run()
+ } catch (error) {
+ throw tagError(error, label, node)
+ }
+}
+
+export async function traceAsync(
+ label: string,
+ node: unknown,
+ run: () => Promise,
+): Promise {
+ try {
+ return await run()
+ } catch (error) {
+ throw tagError(error, label, node)
+ }
+}
+
+function formatStack(error: unknown): string {
+ if (error instanceof Error && typeof error.stack === 'string')
+ return error.stack
+ return ' '
+}
+
+export function formatCodegenErrorReport(
+ error: unknown,
+ context: { node?: unknown; language?: string } = {},
+): string {
+ const frames = getErrorTrace(error)
+ const failingNode = frames.length > 0 ? frames[0].node : context.node
+ const lines: string[] = [
+ SEPARATOR,
+ 'DEVUP UI CODEGEN ERROR - copy this whole block and send it to the maintainer',
+ SEPARATOR,
+ `error : ${errorMessage(error)}`,
+ `language : ${context.language ?? ''}`,
+ `selected : ${describeNode(context.node)}`,
+ `failing : ${describeNode(failingNode)}`,
+ '',
+ '--- codegen path (innermost first) ---',
+ ]
+ if (frames.length === 0) {
+ lines.push(' ')
+ } else {
+ for (let i = 0; i < frames.length; i++)
+ lines.push(
+ ` ${i + 1}. ${frames[i].label} :: ${describeNode(frames[i].node)}`,
+ )
+ }
+ lines.push('', '--- failing node ---', inspectNode(failingNode))
+ lines.push('', '--- raw stack (minified) ---', formatStack(error))
+ lines.push(SEPARATOR)
+ return lines.join('\n')
+}
diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts
index 22deefd..9b699d3 100644
--- a/src/commands/devup/export-devup.ts
+++ b/src/commands/devup/export-devup.ts
@@ -6,6 +6,7 @@ import {
perfStart,
} from '../../codegen/utils/perf'
import { downloadFile } from '../../utils/download-file'
+import { isMotionEasing } from '../../utils/is-motion-easing'
import { isVariableAlias } from '../../utils/is-variable-alias'
import { optimizeHex } from '../../utils/optimize-hex'
import { rgbaToHex } from '../../utils/rgba-to-hex'
@@ -119,14 +120,19 @@ export async function buildDevupConfig(
variables.map(async (variable, i) => {
if (variable === null || variable.resolvedType !== 'COLOR') return
const value = variable.valuesByMode[mode.modeId]
- if (typeof value === 'boolean' || typeof value === 'number')
+ if (
+ typeof value === 'boolean' ||
+ typeof value === 'number' ||
+ isMotionEasing(value)
+ )
return
if (isVariableAlias(value)) {
const nextValue = await variableAliasToValue(value, mode.modeId)
if (nextValue === null) return
if (
typeof nextValue === 'boolean' ||
- typeof nextValue === 'number'
+ typeof nextValue === 'number' ||
+ isMotionEasing(nextValue)
)
return
colors[camelNames[i]] = optimizeHex(
diff --git a/src/utils/__tests__/is-motion-easing.test.ts b/src/utils/__tests__/is-motion-easing.test.ts
new file mode 100644
index 0000000..05ec455
--- /dev/null
+++ b/src/utils/__tests__/is-motion-easing.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from 'bun:test'
+import { isMotionEasing } from '../is-motion-easing'
+
+describe('isMotionEasing', () => {
+ test('should check motionEasing', () => {
+ expect(isMotionEasing({ type: 'EASE_IN_AND_OUT' })).toBe(true)
+ expect(isMotionEasing({ type: 'CUSTOM_CUBIC_BEZIER' })).toBe(true)
+
+ expect(isMotionEasing({ r: 0, g: 0, b: 0, a: 1 })).toBe(false)
+ expect(isMotionEasing({ id: 'id', type: 'VARIABLE_ALIAS' })).toBe(false)
+ expect(isMotionEasing('#fff')).toBe(false)
+ expect(isMotionEasing(1)).toBe(false)
+ expect(isMotionEasing(null)).toBe(false)
+ expect(isMotionEasing(undefined)).toBe(false)
+ })
+})
diff --git a/src/utils/is-motion-easing.ts b/src/utils/is-motion-easing.ts
new file mode 100644
index 0000000..de29068
--- /dev/null
+++ b/src/utils/is-motion-easing.ts
@@ -0,0 +1,13 @@
+/**
+ * `VariableValue` gained `MotionEasing` with Figma's Motion API, so colour code
+ * paths have to narrow it out. It is the only object-valued variant that
+ * carries a `type` other than `VariableAlias` — `RGB`/`RGBA` have none.
+ */
+export function isMotionEasing(value: unknown): value is MotionEasing {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ 'type' in value &&
+ value.type !== 'VARIABLE_ALIAS'
+ )
+}