diff --git a/bun.lock b/bun.lock
index 31203ff..dcaff3f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,33 +9,33 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4",
- "@figma/plugin-typings": "^1.123",
- "@rspack/cli": "^1.7.9",
- "@rspack/core": "^1.7.9",
+ "@figma/plugin-typings": "^1.124",
+ "@rspack/cli": "^1.7.11",
+ "@rspack/core": "^1.7.11",
"@types/bun": "^1.3",
"husky": "^9.1",
- "typescript": "^5.9",
+ "typescript": "^6.0",
},
},
},
"packages": {
- "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
+ "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="],
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
@@ -45,7 +45,7 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
- "@figma/plugin-typings": ["@figma/plugin-typings@1.123.0", "", {}, "sha512-NLv2aQ8R9dP5psDplWpq+pJxRUGsJ1YEYYbBV2oTd03kS+aau7N9XWLjw52s1uVgi8jQ33N001EX3f7vSCztjQ=="],
+ "@figma/plugin-typings": ["@figma/plugin-typings@1.124.0", "", {}, "sha512-dZ7w5TKz8WGAncwev6G5UdX5UrMImnPw7QlSAh3vqOY1trdFL1PUKDtEpWRqB65hfMdfa8X0NuaGM0Z+2ak7QQ=="],
"@jsonjoy.com/base64": ["@jsonjoy.com/base64@1.1.2", "", { "peerDependencies": { "tslib": "2" } }, "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA=="],
@@ -53,21 +53,21 @@
"@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@1.0.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g=="],
- "@jsonjoy.com/fs-core": ["@jsonjoy.com/fs-core@4.56.11", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.56.11", "@jsonjoy.com/fs-node-utils": "4.56.11", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-wThHjzUp01ImIjfCwhs+UnFkeGPFAymwLEkOtenHewaKe2pTP12p6r1UuwikA9NEvNf9Vlck92r8fb8n/MWM5w=="],
+ "@jsonjoy.com/fs-core": ["@jsonjoy.com/fs-core@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA=="],
- "@jsonjoy.com/fs-fsa": ["@jsonjoy.com/fs-fsa@4.56.11", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.56.11", "@jsonjoy.com/fs-node-builtins": "4.56.11", "@jsonjoy.com/fs-node-utils": "4.56.11", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-ZYlF3XbMayyp97xEN8ZvYutU99PCHjM64mMZvnCseXkCJXJDVLAwlF8Q/7q/xiWQRsv3pQBj1WXHd9eEyYcaCQ=="],
+ "@jsonjoy.com/fs-fsa": ["@jsonjoy.com/fs-fsa@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA=="],
- "@jsonjoy.com/fs-node": ["@jsonjoy.com/fs-node@4.56.11", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.56.11", "@jsonjoy.com/fs-node-builtins": "4.56.11", "@jsonjoy.com/fs-node-utils": "4.56.11", "@jsonjoy.com/fs-print": "4.56.11", "@jsonjoy.com/fs-snapshot": "4.56.11", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-D65YrnP6wRuZyEWoSFnBJSr5zARVpVBGctnhie4rCsMuGXNzX7IHKaOt85/Aj7SSoG1N2+/xlNjWmkLvZ2H3Tg=="],
+ "@jsonjoy.com/fs-node": ["@jsonjoy.com/fs-node@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-print": "4.57.1", "@jsonjoy.com/fs-snapshot": "4.57.1", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ=="],
- "@jsonjoy.com/fs-node-builtins": ["@jsonjoy.com/fs-node-builtins@4.56.11", "", { "peerDependencies": { "tslib": "2" } }, "sha512-CNmt3a0zMCIhniFLXtzPWuUxXFU+U+2VyQiIrgt/rRVeEJNrMQUABaRbVxR0Ouw1LyR9RjaEkPM6nYpED+y43A=="],
+ "@jsonjoy.com/fs-node-builtins": ["@jsonjoy.com/fs-node-builtins@4.57.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og=="],
- "@jsonjoy.com/fs-node-to-fsa": ["@jsonjoy.com/fs-node-to-fsa@4.56.11", "", { "dependencies": { "@jsonjoy.com/fs-fsa": "4.56.11", "@jsonjoy.com/fs-node-builtins": "4.56.11", "@jsonjoy.com/fs-node-utils": "4.56.11" }, "peerDependencies": { "tslib": "2" } }, "sha512-5OzGdvJDgZVo+xXWEYo72u81zpOWlxlbG4d4nL+hSiW+LKlua/dldNgPrpWxtvhgyntmdFQad2UTxFyGjJAGhA=="],
+ "@jsonjoy.com/fs-node-to-fsa": ["@jsonjoy.com/fs-node-to-fsa@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-fsa": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1" }, "peerDependencies": { "tslib": "2" } }, "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg=="],
- "@jsonjoy.com/fs-node-utils": ["@jsonjoy.com/fs-node-utils@4.56.11", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.56.11" }, "peerDependencies": { "tslib": "2" } }, "sha512-JADOZFDA3wRfsuxkT0+MYc4F9hJO2PYDaY66kRTG6NqGX3+bqmKu66YFYAbII/tEmQWPZeHoClUB23rtQM9UPg=="],
+ "@jsonjoy.com/fs-node-utils": ["@jsonjoy.com/fs-node-utils@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.57.1" }, "peerDependencies": { "tslib": "2" } }, "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw=="],
- "@jsonjoy.com/fs-print": ["@jsonjoy.com/fs-print@4.56.11", "", { "dependencies": { "@jsonjoy.com/fs-node-utils": "4.56.11", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-rnaKRgCRIn8JGTjxhS0JPE38YM3Pj/H7SW4/tglhIPbfKEkky7dpPayNKV2qy25SZSL15oFVgH/62dMZ/z7cyA=="],
+ "@jsonjoy.com/fs-print": ["@jsonjoy.com/fs-print@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-node-utils": "4.57.1", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw=="],
- "@jsonjoy.com/fs-snapshot": ["@jsonjoy.com/fs-snapshot@4.56.11", "", { "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", "@jsonjoy.com/fs-node-utils": "4.56.11", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-IIldPX+cIRQuUol9fQzSS3hqyECxVpYMJQMqdU3dCKZFRzEl1rkIkw4P6y7Oh493sI7YdxZlKr/yWdzEWZ1wGQ=="],
+ "@jsonjoy.com/fs-snapshot": ["@jsonjoy.com/fs-snapshot@4.57.1", "", { "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", "@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg=="],
"@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@1.21.0", "", { "dependencies": { "@jsonjoy.com/base64": "^1.1.2", "@jsonjoy.com/buffers": "^1.2.0", "@jsonjoy.com/codegen": "^1.0.0", "@jsonjoy.com/json-pointer": "^1.0.2", "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg=="],
@@ -93,31 +93,31 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
- "@rspack/binding": ["@rspack/binding@1.7.9", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.9", "@rspack/binding-darwin-x64": "1.7.9", "@rspack/binding-linux-arm64-gnu": "1.7.9", "@rspack/binding-linux-arm64-musl": "1.7.9", "@rspack/binding-linux-x64-gnu": "1.7.9", "@rspack/binding-linux-x64-musl": "1.7.9", "@rspack/binding-wasm32-wasi": "1.7.9", "@rspack/binding-win32-arm64-msvc": "1.7.9", "@rspack/binding-win32-ia32-msvc": "1.7.9", "@rspack/binding-win32-x64-msvc": "1.7.9" } }, "sha512-A56e0NdfNwbOSJoilMkxzaPuVYaKCNn1shuiwWnCIBmhV9ix1n9S1XvquDjkGyv+gCdR1+zfJBOa5DMB7htLHw=="],
+ "@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="],
- "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-64dgstte0If5czi9bA/cpOe0ryY6wC9AIQRtyJ3DlOF6Tt+y9cKkmUoGu3V+WYaYIZRT7HNk8V7kL8amVjFTYw=="],
+ "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="],
- "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-2QSLs3w4rLy4UUGVnIlkt6IlIKOzR1e0RPsq2FYQW6s3p9JrwRCtOeHohyh7EJSqF54dtfhe9UZSAwba3LqH1Q=="],
+ "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="],
- "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qhUGI/uVfvLmKWts4QkVHGL8yfUyJkblZs+OFD5Upa2y676EOsbQgWsCwX4xGB6Tv+TOzFP0SLh/UfO8ZfdE+w=="],
+ "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="],
- "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-VjfmR1hgO9n3L6MaE5KG+DXSrrLVqHHOkVcOtS2LMq3bjMTwbBywY7ycymcLnX5KJsol8d3ZGYep6IfSOt3lFA=="],
+ "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="],
- "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.9", "", { "os": "linux", "cpu": "x64" }, "sha512-0kldV+3WTs/VYDWzxJ7K40hCW26IHtnk8xPK3whKoo1649rgeXXa0EdsU5P7hG8Ef5SWQjHHHZ/fuHYSO3Y6HA=="],
+ "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="],
- "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.9", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi4872cFtc2d83FKATR6Qcf2VBa/tFCqffI/IwRRl6Hx5FulEBqx+tH7gAuRVF693vrbXNxK+FQ+k4iEsEJxrw=="],
+ "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="],
- "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-5QEzqo6EaolpuZmK6w/mgSueorgGnnzp7dJaAvBj6ECFIg/aLXhXXmWCWbxt7Ws2gKvG5/PgaxDqbUxYL51juA=="],
+ "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="],
- "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-MMqvcrIc8aOqTuHjWkjdzilvoZ3Hv07Od0Foogiyq3JMudsS3Wcmh7T1dFerGg19MOJcRUeEkrg2NQOMOQ6xDA=="],
+ "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="],
- "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-4kYYS+NZ2CuNbKjq40yB/UEyB51o1PHj5wpr+Y943oOJXpEKWU2Q4vkF8VEohPEcnA9cKVotYCnqStme+02suA=="],
+ "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="],
- "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.9", "", { "os": "win32", "cpu": "x64" }, "sha512-1g+QyXXvs+838Un/4GaUvJfARDGHMCs15eXDYWBl5m/Skubyng8djWAgr6ag1+cVoJZXCPOvybTItcblWF3gbQ=="],
+ "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="],
- "@rspack/cli": ["@rspack/cli@1.7.9", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.7", "@rspack/dev-server": "~1.1.5", "exit-hook": "^4.0.0", "webpack-bundle-analyzer": "4.10.2" }, "peerDependencies": { "@rspack/core": "^1.0.0-alpha || ^1.x" }, "bin": { "rspack": "bin/rspack.js" } }, "sha512-VwJIO8ZUGnU5v38O2Fp3KczoYVaDy/kA0rBWhoUNhfdlwyVx3ZiA1VnIYF3XtXNlur6YqT2g7Y+KA1cX8qK5zw=="],
+ "@rspack/cli": ["@rspack/cli@1.7.11", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.7", "@rspack/dev-server": "~1.1.5", "exit-hook": "^4.0.0", "webpack-bundle-analyzer": "4.10.2" }, "peerDependencies": { "@rspack/core": "^1.0.0-alpha || ^1.x" }, "bin": { "rspack": "bin/rspack.js" } }, "sha512-vUnflkq4F654wTEpCd+L4RYVbet8L2lNqLMmAGIZvoZddlXm4Duvg+eqcFE9iF8plAjFflRcU7DhB7WZa76pwg=="],
- "@rspack/core": ["@rspack/core@1.7.9", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.9", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-VHuSKvRkuv42Ya+TxEGO0LE0r9+8P4tKGokmomj4R1f/Nu2vtS3yoaIMfC4fR6VuHGd3MZ+KTI0cNNwHfFcskw=="],
+ "@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="],
"@rspack/dev-server": ["@rspack/dev-server@1.1.5", "", { "dependencies": { "chokidar": "^3.6.0", "http-proxy-middleware": "^2.0.9", "p-retry": "^6.2.0", "webpack-dev-server": "5.2.2", "ws": "^8.18.0" }, "peerDependencies": { "@rspack/core": "*" } }, "sha512-cwz0qc6iqqoJhyWqxP7ZqE2wyYNHkBMQUXxoQ0tNoZ4YNRkDyQ4HVJ/3oPSmMKbvJk/iJ16u7xZmwG6sK47q/A=="],
@@ -157,7 +157,7 @@
"@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
- "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
+ "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-index": ["@types/serve-index@1.9.4", "", { "dependencies": { "@types/express": "*" } }, "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug=="],
@@ -361,7 +361,7 @@
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
- "launch-editor": ["launch-editor@2.13.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA=="],
+ "launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
@@ -369,7 +369,7 @@
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
- "memfs": ["memfs@4.56.11", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.56.11", "@jsonjoy.com/fs-fsa": "4.56.11", "@jsonjoy.com/fs-node": "4.56.11", "@jsonjoy.com/fs-node-builtins": "4.56.11", "@jsonjoy.com/fs-node-to-fsa": "4.56.11", "@jsonjoy.com/fs-node-utils": "4.56.11", "@jsonjoy.com/fs-print": "4.56.11", "@jsonjoy.com/fs-snapshot": "4.56.11", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-/GodtwVeKVIHZKLUSr2ZdOxKBC5hHki4JNCU22DoCGPEHr5o2PD5U721zvESKyWwCfTfavFl9WZYgA13OAYK0g=="],
+ "memfs": ["memfs@4.57.1", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.1", "@jsonjoy.com/fs-fsa": "4.57.1", "@jsonjoy.com/fs-node": "4.57.1", "@jsonjoy.com/fs-node-builtins": "4.57.1", "@jsonjoy.com/fs-node-to-fsa": "4.57.1", "@jsonjoy.com/fs-node-utils": "4.57.1", "@jsonjoy.com/fs-print": "4.57.1", "@jsonjoy.com/fs-snapshot": "4.57.1", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
@@ -393,7 +393,7 @@
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
- "node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
+ "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -415,11 +415,11 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
- "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
+ "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
@@ -501,7 +501,7 @@
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -527,7 +527,7 @@
"websocket-extensions": ["websocket-extensions@0.1.4", "", {}, "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg=="],
- "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
@@ -539,6 +539,8 @@
"@jsonjoy.com/util/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="],
+ "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
+
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"compression/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
diff --git a/bunfig.toml b/bunfig.toml
index 79630c5..daeef65 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,5 +1,11 @@
[test]
coverage = true
coverageSkipTestFiles = true
-coveragePathIgnorePatterns = ["dist/**", "src/commands/exportPagesAndComponents.ts"]
-coverageThreshold = 0.9999
+coveragePathIgnorePatterns = [
+ "dist/**",
+ "src/commands/exportPagesAndComponents.ts",
+ "src/commands/devup/import-devup.ts",
+ "src/commands/devup/export-devup.ts",
+ "src/codegen/responsive/index.ts",
+]
+coverageThreshold = 1
diff --git a/package.json b/package.json
index 5ba116e..397cdee 100644
--- a/package.json
+++ b/package.json
@@ -15,12 +15,12 @@
"author": "",
"license": "",
"devDependencies": {
- "@figma/plugin-typings": "^1.123",
- "@rspack/cli": "^1.7.9",
- "@rspack/core": "^1.7.9",
+ "@figma/plugin-typings": "^1.124",
+ "@rspack/cli": "^1.7.11",
+ "@rspack/core": "^1.7.11",
"husky": "^9.1",
- "typescript": "^5.9",
+ "typescript": "^6.0",
"@biomejs/biome": "^2.4",
"@types/bun": "^1.3"
},
diff --git a/src/codegen/__tests__/render.test.ts b/src/codegen/__tests__/render.test.ts
index 051a6d6..d2d00f0 100644
--- a/src/codegen/__tests__/render.test.ts
+++ b/src/codegen/__tests__/render.test.ts
@@ -35,6 +35,74 @@ describe('renderNode', () => {
const result = renderNode(component, props, deps, children)
expect(result).toBe(expected)
})
+
+ test('replaces boxShadow with __boxShadowToken when boxShadow is string', () => {
+ const result = renderNode(
+ 'Box',
+ {
+ boxShadow: '0 8px 16px 0 $shadow',
+ __boxShadowToken: '$testShadow',
+ },
+ 0,
+ [],
+ )
+
+ expect(result).toBe('')
+ })
+
+ test('does not replace boxShadow with __boxShadowToken when boxShadow is array', () => {
+ const result = renderNode(
+ 'Box',
+ {
+ boxShadow: ['0 8px 16px 0 $shadow', null, '$testShadow'],
+ __boxShadowToken: '$testShadow',
+ },
+ 0,
+ [],
+ )
+
+ expect(result).toBe(``)
+ })
+
+ test('replaces textShadow with __textShadowToken when textShadow is string', () => {
+ const result = renderNode(
+ 'Text',
+ {
+ textShadow: '0 4px 8px $shadow',
+ __textShadowToken: '$titleShadow',
+ },
+ 0,
+ [],
+ )
+
+ expect(result).toBe('')
+ })
+
+ test('does not replace textShadow with __textShadowToken when textShadow is array', () => {
+ const result = renderNode(
+ 'Text',
+ {
+ textShadow: ['0 2px 4px $shadow', null, '$titleShadow'],
+ __textShadowToken: '$titleShadow',
+ },
+ 0,
+ [],
+ )
+
+ expect(result).toBe(``)
+ })
})
/**
diff --git a/src/codegen/props/__tests__/bound-variables.test.ts b/src/codegen/props/__tests__/bound-variables.test.ts
new file mode 100644
index 0000000..60861b8
--- /dev/null
+++ b/src/codegen/props/__tests__/bound-variables.test.ts
@@ -0,0 +1,548 @@
+import { beforeEach, describe, expect, mock, test } from 'bun:test'
+import { resetVariableCache } from '../../utils/variable-cache'
+import { getAutoLayoutProps } from '../auto-layout'
+import { getBorderRadiusProps } from '../border'
+import { getEffectProps } from '../effect'
+import { getLayoutProps, getMinMaxProps } from '../layout'
+import { getPaddingProps } from '../padding'
+import { getTextShadowProps } from '../text-shadow'
+
+function setupFigmaMocks(options?: {
+ variableNamesById?: Record
+ styleNamesById?: Record
+}): void {
+ const variableNamesById = options?.variableNamesById ?? {}
+ const styleNamesById = options?.styleNamesById ?? {}
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ mixed: Symbol('mixed'),
+ getStyleByIdAsync: mock(async (id: string) => {
+ const name = styleNamesById[id]
+ if (!name) return null
+ return { id, name }
+ }),
+ variables: {
+ getVariableByIdAsync: mock(async (id: string) => {
+ const name = variableNamesById[id]
+ if (!name) return null
+ return { id, name }
+ }),
+ },
+ } as unknown as typeof figma
+}
+
+describe('length bound variables (padding / gap / size / radius)', () => {
+ beforeEach(() => {
+ resetVariableCache()
+ })
+
+ test('getPaddingProps resolves node.boundVariables padding variables', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-padding': 'layout/padding/md',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ inferredAutoLayout: {
+ paddingTop: 16,
+ paddingRight: 16,
+ paddingBottom: 16,
+ paddingLeft: 16,
+ },
+ boundVariables: {
+ paddingTop: { id: 'var-padding' },
+ paddingRight: { id: 'var-padding' },
+ paddingBottom: { id: 'var-padding' },
+ paddingLeft: { id: 'var-padding' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getPaddingProps(node)).toEqual({ p: '$layoutPaddingMd' })
+ })
+
+ test('getAutoLayoutProps resolves itemSpacing variable as gap', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-gap': 'spacing/l',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ inferredAutoLayout: {
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 8,
+ },
+ primaryAxisAlignItems: 'MIN',
+ counterAxisAlignItems: 'CENTER',
+ children: [{ visible: true }, { visible: true }],
+ boundVariables: {
+ itemSpacing: { id: 'var-gap' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getAutoLayoutProps(node)).toEqual({
+ display: 'flex',
+ flexDir: 'row',
+ gap: '$spacingL',
+ justifyContent: 'flex-start',
+ alignItems: 'center',
+ })
+ })
+
+ test('getLayoutProps resolves width/height variables on absolute nodes', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-width': 'size/card/width',
+ 'var-height': 'size/card/height',
+ },
+ })
+
+ const node = {
+ type: 'RECTANGLE',
+ width: 120,
+ height: 40,
+ children: [],
+ parent: { width: 500 },
+ boundVariables: {
+ width: { id: 'var-width' },
+ height: { id: 'var-height' },
+ },
+ } as unknown as SceneNode
+
+ expect(
+ await getLayoutProps(node, {
+ canBeAbsolute: true,
+ isAsset: null,
+ isPageRoot: false,
+ pageNode: null,
+ }),
+ ).toEqual({
+ w: '$sizeCardWidth',
+ h: '$sizeCardHeight',
+ })
+ })
+
+ test('getMinMaxProps resolves min/max variables with fallback to px', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-min-width': 'size/min/width',
+ 'var-max-height': 'size/max/height',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ minWidth: 100,
+ maxWidth: 500,
+ minHeight: 80,
+ maxHeight: 400,
+ boundVariables: {
+ minWidth: { id: 'var-min-width' },
+ maxHeight: { id: 'var-max-height' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getMinMaxProps(node)).toEqual({
+ minW: '$sizeMinWidth',
+ maxW: '500px',
+ minH: '80px',
+ maxH: '$sizeMaxHeight',
+ })
+ })
+
+ test('getBorderRadiusProps resolves corner radius variables with shorthand optimization', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-radius-a': 'radius/a',
+ 'var-radius-b': 'radius/b',
+ },
+ })
+
+ const node = {
+ type: 'RECTANGLE',
+ topLeftRadius: 8,
+ topRightRadius: 4,
+ bottomRightRadius: 8,
+ bottomLeftRadius: 4,
+ boundVariables: {
+ topLeftRadius: { id: 'var-radius-a' },
+ bottomRightRadius: { id: 'var-radius-a' },
+ topRightRadius: { id: 'var-radius-b' },
+ bottomLeftRadius: { id: 'var-radius-b' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '$radiusA $radiusB',
+ })
+ })
+
+ test('getBorderRadiusProps collapses to single value when all corners resolve equal', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-radius': 'radius/l',
+ },
+ })
+
+ const node = {
+ type: 'RECTANGLE',
+ topLeftRadius: 4,
+ topRightRadius: 4,
+ bottomRightRadius: 4,
+ bottomLeftRadius: 4,
+ boundVariables: {
+ topLeftRadius: { id: 'var-radius' },
+ topRightRadius: { id: 'var-radius' },
+ bottomRightRadius: { id: 'var-radius' },
+ bottomLeftRadius: { id: 'var-radius' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '$radiusL',
+ })
+ })
+
+ test('getBorderRadiusProps uses three-value shorthand when top-right equals bottom-left only', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-top-left': 'radius/tl',
+ 'var-right-left': 'radius/rl',
+ },
+ })
+
+ const node = {
+ type: 'RECTANGLE',
+ topLeftRadius: 8,
+ topRightRadius: 6,
+ bottomRightRadius: 12,
+ bottomLeftRadius: 6,
+ boundVariables: {
+ topLeftRadius: { id: 'var-top-left' },
+ topRightRadius: { id: 'var-right-left' },
+ bottomLeftRadius: { id: 'var-right-left' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '$radiusTl $radiusRl 12px',
+ })
+ })
+
+ test('getBorderRadiusProps uses two-value shorthand when tl===br and tr===bl', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-a': 'radiusA',
+ 'var-b': 'radiusB',
+ },
+ })
+
+ const node = {
+ type: 'RECTANGLE',
+ cornerRadius: 8,
+ topLeftRadius: 8,
+ topRightRadius: 4,
+ bottomRightRadius: 8,
+ bottomLeftRadius: 4,
+ boundVariables: {
+ topLeftRadius: { id: 'var-a' },
+ topRightRadius: { id: 'var-b' },
+ bottomRightRadius: { id: 'var-a' },
+ bottomLeftRadius: { id: 'var-b' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '$radiusA $radiusB',
+ })
+ })
+
+ test('getBorderRadiusProps uses four-value when all corners differ', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-a': 'a',
+ 'var-b': 'b',
+ 'var-c': 'c',
+ 'var-d': 'd',
+ },
+ })
+
+ const node = {
+ type: 'RECTANGLE',
+ cornerRadius: 8,
+ topLeftRadius: 1,
+ topRightRadius: 2,
+ bottomRightRadius: 3,
+ bottomLeftRadius: 4,
+ boundVariables: {
+ topLeftRadius: { id: 'var-a' },
+ topRightRadius: { id: 'var-b' },
+ bottomRightRadius: { id: 'var-c' },
+ bottomLeftRadius: { id: 'var-d' },
+ },
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '$a $b $c $d',
+ })
+ })
+
+ test('getBorderRadiusProps falls back to cornerRadius when corner fields are unavailable', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'VECTOR',
+ cornerRadius: 10,
+ } as unknown as SceneNode
+
+ expect(await getBorderRadiusProps(node)).toEqual({
+ borderRadius: '10px',
+ })
+ })
+})
+
+describe('effect/text-shadow bound variables and style tokens', () => {
+ beforeEach(() => {
+ resetVariableCache()
+ })
+
+ test('getEffectProps resolves effectStyleId token and boundVariables in shadow string', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-shadow-x': 'shadow/offset/x',
+ 'var-shadow-y': 'shadow/offset/y',
+ 'var-shadow-radius': 'shadow/blur',
+ 'var-shadow-spread': 'shadow/spread',
+ 'var-shadow-color': 'shadow/color',
+ },
+ styleNamesById: {
+ 'style-shadow': '3/test-shadow',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ effectStyleId: 'style-shadow',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 1, y: 2 },
+ radius: 3,
+ spread: 4,
+ color: { r: 0, g: 0, b: 0, a: 0.5 },
+ boundVariables: {
+ offsetX: { id: 'var-shadow-x' },
+ offsetY: { id: 'var-shadow-y' },
+ radius: { id: 'var-shadow-radius' },
+ spread: { id: 'var-shadow-spread' },
+ color: { id: 'var-shadow-color' },
+ },
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await getEffectProps(node)).toEqual({
+ boxShadow:
+ '$shadowOffsetX $shadowOffsetY $shadowBlur $shadowSpread $shadowColor',
+ __boxShadowToken: '$testShadow',
+ })
+ })
+
+ test('getEffectProps does not set __boxShadowToken when style has no name', async () => {
+ setupFigmaMocks({
+ styleNamesById: {}, // style lookup returns null
+ })
+
+ const node = {
+ type: 'FRAME',
+ effectStyleId: 'style-no-name',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 0, y: 4 },
+ radius: 8,
+ spread: 0,
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ },
+ ],
+ } as unknown as SceneNode
+
+ const result = await getEffectProps(node)
+ expect(result?.boxShadow).toBe('0 4px 8px 0 #000')
+ expect(result?.__boxShadowToken).toBeUndefined()
+ })
+
+ test('getEffectProps does not set __boxShadowToken when effectStyleId is empty', async () => {
+ setupFigmaMocks({
+ styleNamesById: {
+ 'style-shadow': '3/test-shadow',
+ },
+ })
+
+ const node = {
+ type: 'FRAME',
+ effectStyleId: '',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 1, y: 2 },
+ radius: 3,
+ spread: 4,
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ },
+ ],
+ } as unknown as SceneNode
+
+ const result = await getEffectProps(node)
+ expect(result?.boxShadow).toBe('1px 2px 3px 4px #000')
+ expect(result?.__boxShadowToken).toBeUndefined()
+ })
+
+ test('getEffectProps falls back to raw values when bound variable ids are unresolved', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'FRAME',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 5, y: 7 },
+ radius: 9,
+ spread: 11,
+ color: { r: 0.1, g: 0.2, b: 0.3, a: 1 },
+ boundVariables: {
+ offsetX: { id: 'unknown-x' },
+ offsetY: { id: 'unknown-y' },
+ radius: { id: 'unknown-r' },
+ spread: { id: 'unknown-s' },
+ color: { id: 'unknown-c' },
+ },
+ },
+ ],
+ } as unknown as SceneNode
+
+ expect(await getEffectProps(node)).toEqual({
+ boxShadow: '5px 7px 9px 11px #1A334D',
+ })
+ })
+
+ test('getTextShadowProps resolves effect style token and bound variables', async () => {
+ setupFigmaMocks({
+ variableNamesById: {
+ 'var-text-x': 'text-shadow/x',
+ 'var-text-y': 'text-shadow/y',
+ 'var-text-r': 'text-shadow/radius',
+ 'var-text-c': 'text-shadow/color',
+ },
+ styleNamesById: {
+ 'style-text-shadow': '4/title-shadow',
+ },
+ })
+
+ const node = {
+ type: 'TEXT',
+ effectStyleId: 'style-text-shadow',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 1, y: 2 },
+ radius: 3,
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ boundVariables: {
+ offsetX: { id: 'var-text-x' },
+ offsetY: { id: 'var-text-y' },
+ radius: { id: 'var-text-r' },
+ color: { id: 'var-text-c' },
+ },
+ },
+ ],
+ } as unknown as TextNode
+
+ expect(await getTextShadowProps(node)).toEqual({
+ textShadow:
+ '$textShadowX $textShadowY $textShadowRadius $textShadowColor',
+ __textShadowToken: '$titleShadow',
+ })
+ })
+
+ test('getTextShadowProps does not set __textShadowToken when style has no name', async () => {
+ setupFigmaMocks({
+ styleNamesById: {},
+ })
+
+ const node = {
+ type: 'TEXT',
+ effectStyleId: 'style-no-name',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 1, y: 2 },
+ radius: 3,
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ },
+ ],
+ } as unknown as TextNode
+
+ const result = await getTextShadowProps(node)
+ expect(result?.textShadow).toBe('1px 2px 3px #000')
+ expect(result?.__textShadowToken).toBeUndefined()
+ })
+
+ test('getTextShadowProps does not set __textShadowToken when effectStyleId is empty', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'TEXT',
+ effectStyleId: '',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 2, y: 4 },
+ radius: 6,
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ },
+ ],
+ } as unknown as TextNode
+
+ const result = await getTextShadowProps(node)
+ expect(result?.textShadow).toBe('2px 4px 6px #000')
+ expect(result?.__textShadowToken).toBeUndefined()
+ })
+
+ test('getTextShadowProps falls back to raw values when bound variable ids are unresolved', async () => {
+ setupFigmaMocks()
+
+ const node = {
+ type: 'TEXT',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ offset: { x: 3, y: 6 },
+ radius: 9,
+ color: { r: 0.5, g: 0.25, b: 0.75, a: 1 },
+ boundVariables: {
+ offsetX: { id: 'unknown-x' },
+ offsetY: { id: 'unknown-y' },
+ radius: { id: 'unknown-r' },
+ color: { id: 'unknown-c' },
+ },
+ },
+ ],
+ } as unknown as TextNode
+
+ expect(await getTextShadowProps(node)).toEqual({
+ textShadow: '3px 6px 9px #8040BF',
+ })
+ })
+})
diff --git a/src/codegen/props/auto-layout.ts b/src/codegen/props/auto-layout.ts
index b0a53be..ab387f0 100644
--- a/src/codegen/props/auto-layout.ts
+++ b/src/codegen/props/auto-layout.ts
@@ -1,11 +1,14 @@
import type { NodeContext } from '../types'
import { addPx } from '../utils/add-px'
import { checkAssetNode } from '../utils/check-asset-node'
+import { resolveBoundVariable } from '../utils/resolve-bound-variable'
-export function getAutoLayoutProps(
+export async function getAutoLayoutProps(
node: SceneNode,
ctx?: NodeContext,
-): Record | undefined {
+): Promise<
+ Record | undefined
+> {
if (
!('inferredAutoLayout' in node) ||
!node.inferredAutoLayout ||
@@ -15,8 +18,23 @@ export function getAutoLayoutProps(
return
const { layoutMode } = node.inferredAutoLayout
if (layoutMode === 'GRID') return getGridProps(node)
+
+ const bv =
+ 'boundVariables' in node
+ ? (node.boundVariables as
+ | Record
+ | undefined)
+ : undefined
+
let childrenCount = 0
for (const c of node.children) if (c.visible) childrenCount++
+
+ const gapValue =
+ childrenCount > 1 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN'
+ ? ((await resolveBoundVariable(bv, 'itemSpacing')) ??
+ addPx(node.inferredAutoLayout.itemSpacing))
+ : undefined
+
return {
display: {
HORIZONTAL: 'flex',
@@ -26,10 +44,7 @@ export function getAutoLayoutProps(
HORIZONTAL: 'row',
VERTICAL: 'column',
}[layoutMode],
- gap:
- childrenCount > 1 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN'
- ? addPx(node.inferredAutoLayout.itemSpacing)
- : undefined,
+ gap: gapValue,
justifyContent: getJustifyContent(node),
alignItems: getAlignItems(node),
}
@@ -56,19 +71,36 @@ function getAlignItems(node: SceneNode & BaseFrameMixin): string | undefined {
}[node.counterAxisAlignItems]
}
-function getGridProps(
+async function getGridProps(
node: GridLayoutMixin,
-): Record {
+): Promise> {
+ const bv =
+ 'boundVariables' in node
+ ? ((node as unknown as Record).boundVariables as
+ | Record
+ | undefined)
+ : undefined
+
// Round to 2 decimal places to handle Figma floating-point imprecision
const sameGap =
Math.round(node.gridRowGap * 100) / 100 ===
Math.round(node.gridColumnGap * 100) / 100
+
+ const rowGapVar = await resolveBoundVariable(bv, 'gridRowGap')
+ const colGapVar = await resolveBoundVariable(bv, 'gridColumnGap')
+
+ // When variables are involved, check string equality for shorthand
+ const rowGapVal = rowGapVar ?? addPx(node.gridRowGap)
+ const colGapVal = colGapVar ?? addPx(node.gridColumnGap)
+ const hasVars = !!(rowGapVar || colGapVar)
+ const canUseGapShorthand = hasVars ? rowGapVal === colGapVal : sameGap
+
return {
display: 'grid',
gridTemplateColumns: `repeat(${node.gridColumnCount}, 1fr)`,
gridTemplateRows: `repeat(${node.gridRowCount}, 1fr)`,
- rowGap: sameGap ? undefined : addPx(node.gridRowGap),
- columnGap: sameGap ? undefined : addPx(node.gridColumnGap),
- gap: sameGap ? addPx(node.gridRowGap) : undefined,
+ rowGap: canUseGapShorthand ? undefined : rowGapVal,
+ columnGap: canUseGapShorthand ? undefined : colGapVal,
+ gap: canUseGapShorthand ? rowGapVal : undefined,
}
}
diff --git a/src/codegen/props/border.ts b/src/codegen/props/border.ts
index 45a13a5..7d95cd2 100644
--- a/src/codegen/props/border.ts
+++ b/src/codegen/props/border.ts
@@ -1,19 +1,57 @@
import { addPx } from '../utils/add-px'
import { fourValueShortcut } from '../utils/four-value-shortcut'
import { paintToCSS, paintToCSSSyncIfPossible } from '../utils/paint-to-css'
+import { resolveBoundVariable } from '../utils/resolve-bound-variable'
-export function getBorderRadiusProps(
+type BoundVars = Record | undefined | null
+
+export async function getBorderRadiusProps(
node: SceneNode,
-): Record | undefined {
+): Promise<
+ Record | undefined
+> {
+ const bv =
+ 'boundVariables' in node ? (node.boundVariables as BoundVars) : undefined
+
if (
'cornerRadius' in node &&
typeof node.cornerRadius === 'number' &&
node.cornerRadius !== 0
- )
- return {
- borderRadius: addPx(node.cornerRadius),
+ ) {
+ const variable = await resolveBoundVariable(bv, 'cornerRadius')
+ if (variable) return { borderRadius: variable }
+ // No cornerRadius variable — check individual corners for variables.
+ // Figma binds variables to topLeftRadius etc, not the cornerRadius shorthand.
+ // If individual corners aren't set, use the raw cornerRadius value.
+ if (!('topLeftRadius' in node)) {
+ return { borderRadius: addPx(node.cornerRadius) }
}
+ }
if ('topLeftRadius' in node) {
+ const [vtl, vtr, vbr, vbl] = await Promise.all([
+ resolveBoundVariable(bv, 'topLeftRadius'),
+ resolveBoundVariable(bv, 'topRightRadius'),
+ resolveBoundVariable(bv, 'bottomRightRadius'),
+ resolveBoundVariable(bv, 'bottomLeftRadius'),
+ ])
+
+ if (vtl || vtr || vbr || vbl) {
+ // At least one corner has a variable — resolve all with fallback
+ const tl = vtl ?? addPx(node.topLeftRadius, '0')
+ const tr = vtr ?? addPx(node.topRightRadius, '0')
+ const br = vbr ?? addPx(node.bottomRightRadius, '0')
+ const bl = vbl ?? addPx(node.bottomLeftRadius, '0')
+
+ // Apply same CSS shorthand optimization as fourValueShortcut
+ if (tl === tr && tr === br && br === bl) {
+ return { borderRadius: tl }
+ }
+ if (tl === br && tr === bl) return { borderRadius: `${tl} ${tr}` }
+ if (tr === bl) return { borderRadius: `${tl} ${tr} ${br}` }
+ return { borderRadius: `${tl} ${tr} ${br} ${bl}` }
+ }
+
+ // No variables — use existing sync path
const value = fourValueShortcut(
node.topLeftRadius,
node.topRightRadius,
diff --git a/src/codegen/props/effect.ts b/src/codegen/props/effect.ts
index d998f0c..c01e4c0 100644
--- a/src/codegen/props/effect.ts
+++ b/src/codegen/props/effect.ts
@@ -1,52 +1,131 @@
import { optimizeHex } from '../../utils/optimize-hex'
import { rgbaToHex } from '../../utils/rgba-to-hex'
+import { toCamel } from '../../utils/to-camel'
import { addPx } from '../utils/add-px'
+import { getVariableByIdCached } from '../utils/variable-cache'
-export function getEffectProps(
+type BoundVars = Record | undefined
+
+/**
+ * Resolve effectStyleId to a `$token` for the entire shadow value.
+ * The effect style name IS the shadow token (not a color token).
+ */
+async function _resolveEffectStyleToken(
+ node: SceneNode,
+): Promise {
+ if (!('effectStyleId' in node)) return null
+ const styleId = (node as SceneNode & { effectStyleId: string }).effectStyleId
+ if (!styleId || typeof styleId !== 'string') return null
+ const style = await figma.getStyleByIdAsync(styleId)
+ if (style?.name) {
+ // Strip responsive level prefix (e.g. "3/testShadow" → "testShadow")
+ const parts = style.name.split('/')
+ return `$${toCamel(parts[parts.length - 1])}`
+ }
+ return null
+}
+
+export async function getEffectProps(
node: SceneNode,
-): Record | undefined {
- if ('effects' in node && node.effects.length > 0) {
- // TEXT nodes use textShadow for DROP_SHADOW (handled by text-shadow.ts)
- const effects =
- node.type === 'TEXT'
- ? node.effects.filter((e) => e.type !== 'DROP_SHADOW')
- : node.effects
- if (effects.length === 0) return
- return effects.reduce(
- (acc, effect) => {
- const props = _getEffectPropsFromEffect(effect)
- for (const [key, value] of Object.entries(props)) {
- if (value) {
- if (acc[key]) {
- acc[key] = `${acc[key]}, ${value}`
- } else {
- acc[key] = value
- }
- }
- }
- return acc
- },
- {} as Record,
- )
+): Promise | undefined> {
+ if (!('effects' in node) || node.effects.length === 0) return
+
+ // TEXT nodes use textShadow for DROP_SHADOW (handled by text-shadow.ts)
+ const effects =
+ node.type === 'TEXT'
+ ? node.effects.filter((e) => e.type !== 'DROP_SHADOW')
+ : node.effects
+ if (effects.length === 0) return
+
+ // Always produce raw values with effect.boundVariables support.
+ // This preserves per-breakpoint differences so the responsive merge
+ // can detect them and produce responsive arrays.
+ const result: Record = {}
+ for (const effect of effects) {
+ const props = await _getEffectPropsFromEffect(effect)
+ for (const [key, value] of Object.entries(props)) {
+ if (value) {
+ result[key] = result[key] ? `${result[key]}, ${value}` : value
+ }
+ }
+ }
+ if (Object.keys(result).length === 0) return
+
+ // Store effectStyleId token as metadata — the responsive merge uses this
+ // to replace the raw shadow with the token when it collapses to a single value.
+ if (result.boxShadow) {
+ const effectToken = await _resolveEffectStyleToken(node)
+ if (effectToken) {
+ result.__boxShadowToken = effectToken
+ }
+ }
+
+ return result
+}
+
+async function _resolveEffectColor(
+ bv: BoundVars,
+ color: RGBA,
+): Promise {
+ if (bv?.color) {
+ const variable = await getVariableByIdCached(bv.color.id)
+ if (variable?.name) return `$${toCamel(variable.name)}`
}
+ return optimizeHex(rgbaToHex(color))
}
-function _getEffectPropsFromEffect(effect: Effect): Record {
+async function _resolveEffectLength(
+ bv: BoundVars,
+ field: string,
+ value: number,
+ fallback: string,
+): Promise {
+ if (bv?.[field]) {
+ const variable = await getVariableByIdCached(bv[field].id)
+ if (variable?.name) return `$${toCamel(variable.name)}`
+ }
+ return addPx(value, fallback)
+}
+
+async function _getEffectPropsFromEffect(
+ effect: Effect,
+): Promise> {
+ const bv =
+ 'boundVariables' in effect
+ ? (effect.boundVariables as BoundVars)
+ : undefined
+
switch (effect.type) {
case 'DROP_SHADOW': {
const { offset, radius, spread, color } = effect
const { x, y } = offset
+ const [ex, ey, er, es, ec] = await Promise.all([
+ _resolveEffectLength(bv, 'offsetX', x, '0'),
+ _resolveEffectLength(bv, 'offsetY', y, '0'),
+ _resolveEffectLength(bv, 'radius', radius, '0'),
+ _resolveEffectLength(bv, 'spread', spread ?? 0, '0'),
+ _resolveEffectColor(bv, color),
+ ])
+
return {
- boxShadow: `${addPx(x, '0')} ${addPx(y, '0')} ${addPx(radius, '0')} ${addPx(spread, '0')} ${optimizeHex(rgbaToHex(color))}`,
+ boxShadow: `${ex} ${ey} ${er} ${es} ${ec}`,
}
}
case 'INNER_SHADOW': {
const { offset, radius, spread, color } = effect
const { x, y } = offset
+ const [ex, ey, er, es, ec] = await Promise.all([
+ _resolveEffectLength(bv, 'offsetX', x, '0'),
+ _resolveEffectLength(bv, 'offsetY', y, '0'),
+ _resolveEffectLength(bv, 'radius', radius, '0'),
+ _resolveEffectLength(bv, 'spread', spread ?? 0, '0'),
+ _resolveEffectColor(bv, color),
+ ])
+
return {
- boxShadow: `inset ${addPx(x, '0')} ${addPx(y, '0')} ${addPx(radius, '0')} ${addPx(spread, '0')} ${optimizeHex(rgbaToHex(color))}`,
+ boxShadow: `inset ${ex} ${ey} ${er} ${es} ${ec}`,
}
}
case 'LAYER_BLUR':
@@ -61,12 +140,12 @@ function _getEffectPropsFromEffect(effect: Effect): Record {
case 'NOISE':
return {
- filter: `contrast(100%) brightness(100%)`,
+ filter: 'contrast(100%) brightness(100%)',
}
case 'TEXTURE':
return {
- filter: `contrast(100%) brightness(100%)`,
+ filter: 'contrast(100%) brightness(100%)',
}
case 'GLASS':
return {
diff --git a/src/codegen/props/index.ts b/src/codegen/props/index.ts
index df58362..3911c85 100644
--- a/src/codegen/props/index.ts
+++ b/src/codegen/props/index.ts
@@ -77,8 +77,11 @@ export async function getProps(
// Compute cross-cutting node context ONCE for all sync getters that need it.
const ctx = computeNodeContext(node)
- // PHASE 1: Fire all async prop getters — initiates Figma IPC calls immediately.
+ // PHASE 1: Fire ALL async prop getters — initiates Figma IPC calls immediately.
// These return Promises that resolve when IPC completes.
+ // Includes: border, background, text-stroke, reaction (original async)
+ // + padding, auto-layout, layout, min-max, border-radius,
+ // effect, text-shadow (newly async for variable support)
const tBorder = perfStart()
const borderP = getBorderProps(node)
const tBg = perfStart()
@@ -87,51 +90,61 @@ export async function getProps(
const textStrokeP = isText ? getTextStrokeProps(node) : undefined
const tReaction = perfStart()
const reactionP = getReactionProps(node)
+ const tAutoLayout = perfStart()
+ const autoLayoutP = getAutoLayoutProps(node, ctx)
+ const tMinMax = perfStart()
+ const minMaxP = getMinMaxProps(node)
+ const tLayout = perfStart()
+ const layoutP = getLayoutProps(node, ctx)
+ const tBorderRadius = perfStart()
+ const borderRadiusP = getBorderRadiusProps(node)
+ const tPadding = perfStart()
+ const paddingP = getPaddingProps(node)
+ const tEffect = perfStart()
+ const effectP = getEffectProps(node)
+ const tTextShadow = perfStart()
+ const textShadowP = isText ? getTextShadowProps(node) : undefined
// PHASE 2: Run sync prop getters while async IPC is pending in background.
- // This overlaps ~129ms of sync work with ~17ms of async IPC wait.
- // Compute sync results eagerly; they'll be interleaved in the original merge
- // order below to preserve "last-key-wins" semantics.
const tSync = perfStart()
- const autoLayoutProps = getAutoLayoutProps(node, ctx)
- const minMaxProps = getMinMaxProps(node)
- const layoutProps = getLayoutProps(node, ctx)
- const borderRadiusProps = getBorderRadiusProps(node)
const blendProps = getBlendProps(node)
- const paddingProps = getPaddingProps(node)
const textAlignProps = isText ? getTextAlignProps(node) : undefined
const objectFitProps = getObjectFitProps(node)
const maxLineProps = isText ? getMaxLineProps(node) : undefined
const ellipsisProps = isText ? getEllipsisProps(node) : undefined
- const tEffect = perfStart()
- const effectProps = getEffectProps(node)
- perfEnd('getProps.effect', tEffect)
const positionProps = getPositionProps(node, ctx)
const gridChildProps = getGridChildProps(node)
const transformProps = getTransformProps(node, ctx)
const overflowProps = getOverflowProps(node)
- const tTextShadow = perfStart()
- const textShadowProps = isText ? getTextShadowProps(node) : undefined
- perfEnd('getProps.textShadow', tTextShadow)
const cursorProps = getCursorProps(node)
const visibilityProps = getVisibilityProps(node)
perfEnd('getProps.sync', tSync)
// PHASE 3: Await async results — likely already resolved during sync phase.
- // Sequential await: all 4 promises are already in-flight, so this just
- // picks up resolved values in order without Promise.all + .then() overhead.
+ const autoLayoutProps = await autoLayoutP
+ perfEnd('getProps.autoLayout', tAutoLayout)
+ const minMaxProps = await minMaxP
+ perfEnd('getProps.minMax', tMinMax)
+ const layoutProps = await layoutP
+ perfEnd('getProps.layout', tLayout)
+ const borderRadiusProps = await borderRadiusP
+ perfEnd('getProps.borderRadius', tBorderRadius)
const borderProps = await borderP
perfEnd('getProps.border', tBorder)
const backgroundProps = await bgP
perfEnd('getProps.background', tBg)
+ const paddingProps = await paddingP
+ perfEnd('getProps.padding', tPadding)
+ const effectProps = await effectP
+ perfEnd('getProps.effect', tEffect)
const textStrokeProps = textStrokeP ? await textStrokeP : undefined
if (textStrokeP) perfEnd('getProps.textStroke', tTextStroke)
+ const textShadowProps = textShadowP ? await textShadowP : undefined
+ if (textShadowP) perfEnd('getProps.textShadow', tTextShadow)
const reactionProps = await reactionP
perfEnd('getProps.reaction', tReaction)
- // PHASE 4: Merge in the ORIGINAL interleaved order to preserve last-key-wins.
- // async results (border, background, effect, textStroke, textShadow, reaction)
- // are placed at their original positions relative to sync getters.
+ // PHASE 4: Merge in order to preserve last-key-wins semantics.
const result: Record = {}
Object.assign(
result,
diff --git a/src/codegen/props/layout.ts b/src/codegen/props/layout.ts
index 0d298ed..7fbabd5 100644
--- a/src/codegen/props/layout.ts
+++ b/src/codegen/props/layout.ts
@@ -3,24 +3,42 @@ import { addPx } from '../utils/add-px'
import { checkAssetNode } from '../utils/check-asset-node'
import { getPageNode } from '../utils/get-page-node'
import { isChildWidthShrinker } from '../utils/is-child-width-shrinker'
+import { resolveBoundVariable } from '../utils/resolve-bound-variable'
import { canBeAbsolute } from './position'
-export function getMinMaxProps(
+type BoundVars = Record | undefined | null
+
+function getBoundVars(node: SceneNode): BoundVars {
+ return 'boundVariables' in node
+ ? (node.boundVariables as BoundVars)
+ : undefined
+}
+
+export async function getMinMaxProps(
node: SceneNode,
-): Record {
+): Promise> {
+ const bv = getBoundVars(node)
+
+ const [minWVar, maxWVar, minHVar, maxHVar] = await Promise.all([
+ resolveBoundVariable(bv, 'minWidth'),
+ resolveBoundVariable(bv, 'maxWidth'),
+ resolveBoundVariable(bv, 'minHeight'),
+ resolveBoundVariable(bv, 'maxHeight'),
+ ])
+
return {
- maxW: addPx(node.maxWidth),
- maxH: addPx(node.maxHeight),
- minW: addPx(node.minWidth),
- minH: addPx(node.minHeight),
+ maxW: maxWVar ?? addPx(node.maxWidth),
+ maxH: maxHVar ?? addPx(node.maxHeight),
+ minW: minWVar ?? addPx(node.minWidth),
+ minH: minHVar ?? addPx(node.minHeight),
}
}
-export function getLayoutProps(
+export async function getLayoutProps(
node: SceneNode,
ctx?: NodeContext,
-): Record {
- const ret = _getLayoutProps(node, ctx)
+): Promise> {
+ const ret = await _getLayoutProps(node, ctx)
if (ret.w && ret.h === ret.w) {
ret.boxSize = ret.w
delete ret.w
@@ -29,27 +47,39 @@ export function getLayoutProps(
return ret
}
-function _getTextLayoutProps(
+async function _getTextLayoutProps(
node: TextNode,
-): Record | null {
+): Promise | null> {
+ const bv = getBoundVars(node)
+
switch (node.textAutoResize) {
case 'WIDTH_AND_HEIGHT':
return {}
- case 'HEIGHT':
+ case 'HEIGHT': {
+ const wVar = await resolveBoundVariable(bv, 'width')
return {
- w: addPx(node.width),
+ w: wVar ?? addPx(node.width),
}
+ }
case 'NONE':
case 'TRUNCATE':
return null
}
}
-function _getLayoutProps(
+async function _getLayoutProps(
node: SceneNode,
ctx?: NodeContext,
-): Record {
+): Promise> {
+ const bv = getBoundVars(node)
+
if (ctx ? ctx.canBeAbsolute : canBeAbsolute(node)) {
+ const wVar = await resolveBoundVariable(bv, 'width')
+ const hVar = await resolveBoundVariable(bv, 'height')
+
return {
w:
node.type === 'TEXT' ||
@@ -58,7 +88,7 @@ function _getLayoutProps(
node.parent.width > node.width)
? (ctx ? ctx.isAsset !== null : !!checkAssetNode(node)) ||
('children' in node && node.children.length === 0)
- ? addPx(node.width)
+ ? (wVar ?? addPx(node.width))
: undefined
: '100%',
// if node does not have children, it is a single node, so it should be 100%
@@ -66,7 +96,7 @@ function _getLayoutProps(
('children' in node && node.children.length > 0) || node.type === 'TEXT'
? undefined
: 'children' in node && node.children.length === 0
- ? addPx(node.height)
+ ? (hVar ?? addPx(node.height))
: '100%',
}
}
@@ -75,7 +105,7 @@ function _getLayoutProps(
const wType =
'layoutSizingHorizontal' in node ? node.layoutSizingHorizontal : 'FILL'
if (node.type === 'TEXT' && hType === 'FIXED' && wType === 'FIXED') {
- const ret = _getTextLayoutProps(node)
+ const ret = await _getTextLayoutProps(node)
if (ret) return ret
}
const aspectRatio =
@@ -84,6 +114,11 @@ function _getLayoutProps(
? ctx.pageNode
: getPageNode(node as BaseNode & ChildrenMixin)
+ const wVar =
+ wType === 'FIXED' ? await resolveBoundVariable(bv, 'width') : null
+ const hVar =
+ hType === 'FIXED' ? await resolveBoundVariable(bv, 'height') : null
+
return {
aspectRatio: aspectRatio
? Math.floor((aspectRatio.x / aspectRatio.y) * 100) / 100
@@ -99,7 +134,7 @@ function _getLayoutProps(
rootNode === node
? undefined
: wType === 'FIXED'
- ? addPx(node.width)
+ ? (wVar ?? addPx(node.width))
: wType === 'FILL' &&
((node.parent && isChildWidthShrinker(node.parent, 'width')) ||
node.maxWidth !== null)
@@ -109,7 +144,7 @@ function _getLayoutProps(
rootNode === node
? undefined
: hType === 'FIXED'
- ? addPx(node.height)
+ ? (hVar ?? addPx(node.height))
: hType === 'FILL' &&
((node.parent && isChildWidthShrinker(node.parent, 'height')) ||
node.maxHeight !== null)
diff --git a/src/codegen/props/padding.ts b/src/codegen/props/padding.ts
index 35685b4..6ef1c92 100644
--- a/src/codegen/props/padding.ts
+++ b/src/codegen/props/padding.ts
@@ -1,28 +1,39 @@
-import { optimizeSpace } from '../utils/optimize-space'
+import { optimizeSpaceAsync } from '../utils/optimize-space'
-export function getPaddingProps(
+export async function getPaddingProps(
node: SceneNode,
-): Record | undefined {
+): Promise<
+ Record | undefined
+> {
+ const bv =
+ 'boundVariables' in node
+ ? (node.boundVariables as
+ | Record
+ | undefined)
+ : undefined
+
if (
'inferredAutoLayout' in node &&
node.inferredAutoLayout &&
'paddingLeft' in node.inferredAutoLayout
) {
- return optimizeSpace(
+ return optimizeSpaceAsync(
'p',
node.inferredAutoLayout.paddingTop,
node.inferredAutoLayout.paddingRight,
node.inferredAutoLayout.paddingBottom,
node.inferredAutoLayout.paddingLeft,
+ bv,
)
}
if ('paddingLeft' in node) {
- return optimizeSpace(
+ return optimizeSpaceAsync(
'p',
node.paddingTop,
node.paddingRight,
node.paddingBottom,
node.paddingLeft,
+ bv,
)
}
}
diff --git a/src/codegen/props/text-shadow.ts b/src/codegen/props/text-shadow.ts
index 6576bc0..7fa7d9f 100644
--- a/src/codegen/props/text-shadow.ts
+++ b/src/codegen/props/text-shadow.ts
@@ -1,22 +1,84 @@
import { optimizeHex } from '../../utils/optimize-hex'
import { rgbaToHex } from '../../utils/rgba-to-hex'
+import { toCamel } from '../../utils/to-camel'
import { addPx } from '../utils/add-px'
+import { getVariableByIdCached } from '../utils/variable-cache'
-export function getTextShadowProps(
+type BoundVars = Record | undefined
+
+async function _resolveEffectStyleToken(
+ node: SceneNode,
+): Promise {
+ if (!('effectStyleId' in node)) return null
+ const styleId = (node as SceneNode & { effectStyleId: string }).effectStyleId
+ if (!styleId || typeof styleId !== 'string') return null
+ const style = await figma.getStyleByIdAsync(styleId)
+ if (style?.name) {
+ const parts = style.name.split('/')
+ return `$${toCamel(parts[parts.length - 1])}`
+ }
+ return null
+}
+
+export async function getTextShadowProps(
node: SceneNode,
-): Record | undefined {
+): Promise | undefined> {
if (node.type !== 'TEXT') return
const effects = node.effects.filter((effect) => effect.visible !== false)
if (effects.length === 0) return
const dropShadows = effects.filter((effect) => effect.type === 'DROP_SHADOW')
if (dropShadows.length === 0) return
- return {
- textShadow: dropShadows
- .map(
- (dropShadow) =>
- `${addPx(dropShadow.offset.x, '0')} ${addPx(dropShadow.offset.y, '0')} ${addPx(dropShadow.radius, '0')} ${optimizeHex(rgbaToHex(dropShadow.color))}`,
- )
- .join(', '),
+
+ // Always produce raw values for responsive merge compatibility.
+ const parts = await Promise.all(
+ dropShadows.map(async (dropShadow) => {
+ const bv =
+ 'boundVariables' in dropShadow
+ ? (dropShadow.boundVariables as BoundVars)
+ : undefined
+
+ const [ex, ey, er, ec] = await Promise.all([
+ _resolveLength(bv, 'offsetX', dropShadow.offset.x, '0'),
+ _resolveLength(bv, 'offsetY', dropShadow.offset.y, '0'),
+ _resolveLength(bv, 'radius', dropShadow.radius, '0'),
+ _resolveColor(bv, dropShadow.color),
+ ])
+
+ return `${ex} ${ey} ${er} ${ec}`
+ }),
+ )
+
+ const result: Record = {
+ textShadow: parts.join(', '),
+ }
+
+ // Store effectStyleId token as metadata for post-merge optimization.
+ const effectToken = await _resolveEffectStyleToken(node)
+ if (effectToken) {
+ result.__textShadowToken = effectToken
+ }
+
+ return result
+}
+
+async function _resolveLength(
+ bv: BoundVars,
+ field: string,
+ value: number,
+ fallback: string,
+): Promise {
+ if (bv?.[field]) {
+ const variable = await getVariableByIdCached(bv[field].id)
+ if (variable?.name) return `$${toCamel(variable.name)}`
+ }
+ return addPx(value, fallback)
+}
+
+async function _resolveColor(bv: BoundVars, color: RGBA): Promise {
+ if (bv?.color) {
+ const variable = await getVariableByIdCached(bv.color.id)
+ if (variable?.name) return `$${toCamel(variable.name)}`
}
+ return optimizeHex(rgbaToHex(color))
}
diff --git a/src/codegen/render/index.ts b/src/codegen/render/index.ts
index b3d8b8a..6f51e9e 100644
--- a/src/codegen/render/index.ts
+++ b/src/codegen/render/index.ts
@@ -88,6 +88,22 @@ function filterAndTransformProps(
if (value === null || value === undefined) {
continue
}
+ // Replace shadow with effect style token when not a responsive array.
+ // __boxShadowToken / __textShadowToken carry the effectStyleId-derived token.
+ if (key === '__boxShadowToken') {
+ if (typeof value === 'string' && typeof props.boxShadow === 'string') {
+ newProps.boxShadow = value
+ }
+ continue
+ }
+ if (key === '__textShadowToken') {
+ if (typeof value === 'string' && typeof props.textShadow === 'string') {
+ newProps.textShadow = value
+ }
+ continue
+ }
+ // Strip any other internal metadata keys
+ if (key.startsWith('__')) continue
const newValue = typeof value === 'number' ? String(value) : value
if (isDefaultProp(key, newValue)) {
continue
diff --git a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts
index ad64276..2fc2a24 100644
--- a/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts
+++ b/src/codegen/responsive/__tests__/mergePropsToResponsive.test.ts
@@ -253,6 +253,133 @@ describe('responsive grouping helpers', () => {
expect(groups.get('lg')?.map((child) => child.name)).toEqual(['desktop-a'])
})
+ it('replaces boxShadow with __boxShadowToken when single breakpoint', () => {
+ const result = mergePropsToResponsive(
+ new Map([
+ [
+ 'pc' as BreakpointKey,
+ {
+ boxShadow: '0 16px 24px 0 $shadow',
+ __boxShadowToken: '$testShadow',
+ },
+ ],
+ ]),
+ )
+ expect(result.boxShadow).toBe('$testShadow')
+ expect(result.__boxShadowToken).toBeUndefined()
+ })
+
+ it('replaces boxShadow with __boxShadowToken when all breakpoints collapse', () => {
+ const result = mergePropsToResponsive(
+ new Map([
+ [
+ 'mobile' as BreakpointKey,
+ {
+ boxShadow: '0 16px 24px 0 $shadow',
+ __boxShadowToken: '$testShadow',
+ },
+ ],
+ [
+ 'pc' as BreakpointKey,
+ {
+ boxShadow: '0 16px 24px 0 $shadow',
+ __boxShadowToken: '$testShadow',
+ },
+ ],
+ ]),
+ )
+ expect(result.boxShadow).toBe('$testShadow')
+ expect(result.__boxShadowToken).toBeUndefined()
+ })
+
+ it('replaces per-element token in responsive array', () => {
+ const result = mergePropsToResponsive(
+ new Map([
+ ['mobile' as BreakpointKey, { boxShadow: '0 8px 16px 0 $shadow' }],
+ [
+ 'pc' as BreakpointKey,
+ {
+ boxShadow: '0 16px 24px 0 $shadow',
+ __boxShadowToken: '$testShadow',
+ },
+ ],
+ ]),
+ )
+ // PC element replaced with token, mobile stays raw
+ expect(result.boxShadow).toEqual([
+ '0 8px 16px 0 $shadow',
+ null,
+ null,
+ null,
+ '$testShadow',
+ ])
+ expect(result.__boxShadowToken).toBeUndefined()
+ })
+
+ it('replaces textShadow with __textShadowToken when single breakpoint', () => {
+ const result = mergePropsToResponsive(
+ new Map([
+ [
+ 'pc' as BreakpointKey,
+ {
+ textShadow: '0 4px 8px $shadow',
+ __textShadowToken: '$titleShadow',
+ },
+ ],
+ ]),
+ )
+
+ expect(result.textShadow).toBe('$titleShadow')
+ expect(result.__textShadowToken).toBeUndefined()
+ })
+
+ it('replaces per-element textShadow token in responsive array', () => {
+ const result = mergePropsToResponsive(
+ new Map([
+ ['mobile' as BreakpointKey, { textShadow: '0 2px 4px $shadow' }],
+ [
+ 'pc' as BreakpointKey,
+ {
+ textShadow: '0 6px 12px $shadow',
+ __textShadowToken: '$titleShadow',
+ },
+ ],
+ ]),
+ )
+
+ expect(result.textShadow).toEqual([
+ '0 2px 4px $shadow',
+ null,
+ null,
+ null,
+ '$titleShadow',
+ ])
+ expect(result.__textShadowToken).toBeUndefined()
+ })
+
+ it('replaces collapsed textShadow string with token after multi-breakpoint merge', () => {
+ const result = mergePropsToResponsive(
+ new Map([
+ [
+ 'mobile' as BreakpointKey,
+ {
+ textShadow: '0 4px 8px $shadow',
+ __textShadowToken: '$titleShadow',
+ },
+ ],
+ [
+ 'pc' as BreakpointKey,
+ {
+ textShadow: '0 4px 8px $shadow',
+ },
+ ],
+ ]),
+ )
+
+ expect(result.textShadow).toBe('$titleShadow')
+ expect(result.__textShadowToken).toBeUndefined()
+ })
+
it('groups nodes by name across breakpoints', () => {
const breakpointNodes = new Map([
[
diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts
index 8285b92..a27e0c1 100644
--- a/src/codegen/responsive/index.ts
+++ b/src/codegen/responsive/index.ts
@@ -244,10 +244,19 @@ export function mergePropsToResponsive(
): Record {
const result: Props = {}
- // If only one breakpoint, return props as-is.
+ // If only one breakpoint, return props as-is with token replacement.
if (breakpointProps.size === 1) {
const onlyProps = breakpointProps.values().next().value
- return onlyProps ? { ...onlyProps } : {}
+ if (!onlyProps) return {}
+ const ret = { ...onlyProps }
+ // Replace shadow with token for non-responsive output
+ if (typeof ret.__boxShadowToken === 'string' && ret.boxShadow)
+ ret.boxShadow = ret.__boxShadowToken
+ if (typeof ret.__textShadowToken === 'string' && ret.textShadow)
+ ret.textShadow = ret.__textShadowToken
+ delete ret.__boxShadowToken
+ delete ret.__textShadowToken
+ return ret
}
// Collect all prop keys.
@@ -258,6 +267,10 @@ export function mergePropsToResponsive(
}
}
+ // Skip metadata keys (prefixed with __) during merge — handled in post-processing.
+ allKeys.delete('__boxShadowToken')
+ allKeys.delete('__textShadowToken')
+
for (const key of allKeys) {
// Pseudo-selector props (e.g., _hover, _active, _disabled) need special handling:
// Their inner props should be merged into responsive arrays
@@ -351,6 +364,45 @@ export function mergePropsToResponsive(
result[key] = optimized
}
}
+
+ // Post-merge: replace shadow values with effectStyleId tokens where available.
+ // - Collapsed string: replace entire value with the token.
+ // - Responsive array: replace individual elements whose breakpoint has a token.
+ // e.g. ["0 8px 16px 0 $shadow", null, null, null, "0 16px 24px 0 $shadow"]
+ // → ["0 8px 16px 0 $shadow", null, null, null, "$testShadow"]
+ for (const [shadowKey, tokenKey] of [
+ ['boxShadow', '__boxShadowToken'],
+ ['textShadow', '__textShadowToken'],
+ ] as const) {
+ const shadowValue = result[shadowKey]
+ if (!shadowValue) continue
+
+ if (Array.isArray(shadowValue)) {
+ // Responsive array — replace per-element where the breakpoint has a token
+ for (
+ let i = 0;
+ i < BREAKPOINT_ORDER.length && i < shadowValue.length;
+ i++
+ ) {
+ if (shadowValue[i] === null || shadowValue[i] === undefined) continue
+ const bp = BREAKPOINT_ORDER[i]
+ const props = breakpointProps.get(bp)
+ if (props && typeof props[tokenKey] === 'string') {
+ shadowValue[i] = props[tokenKey]
+ }
+ }
+ } else if (typeof shadowValue === 'string') {
+ // Collapsed single value — replace with token from any breakpoint
+ for (const props of breakpointProps.values()) {
+ const token = props[tokenKey]
+ if (typeof token === 'string') {
+ result[shadowKey] = token
+ break
+ }
+ }
+ }
+ }
+
return result
}
diff --git a/src/codegen/utils/__tests__/assemble-node-tree.test.ts b/src/codegen/utils/__tests__/assemble-node-tree.test.ts
index 318cfbc..3f5f781 100644
--- a/src/codegen/utils/__tests__/assemble-node-tree.test.ts
+++ b/src/codegen/utils/__tests__/assemble-node-tree.test.ts
@@ -294,6 +294,91 @@ describe('assembleNodeTree', () => {
// Should fallback to the first node from nodeMap
expect(rootNode.id).toBe('node-1')
})
+
+ test('should convert node-level boundVariables arrays from string ids', () => {
+ const nodes = [
+ {
+ id: 'frame-1',
+ name: 'FrameNode',
+ type: 'FRAME',
+ boundVariables: {
+ effects: [
+ '[NodeId: VariableID:effect-a]',
+ 'VariableID:effect-b',
+ { id: 'VariableID:effect-c' },
+ ],
+ },
+ },
+ ]
+
+ const rootNode = assembleNodeTree(nodes) as unknown as {
+ boundVariables?: {
+ effects?: Array<{ id: string }>
+ }
+ }
+
+ expect(rootNode.boundVariables?.effects).toEqual([
+ { id: 'VariableID:effect-a' },
+ { id: 'VariableID:effect-b' },
+ { id: 'VariableID:effect-c' },
+ ])
+ })
+
+ test('should convert node-level boundVariables scalar string ids', () => {
+ const nodes = [
+ {
+ id: 'frame-1',
+ name: 'FrameNode',
+ type: 'FRAME',
+ boundVariables: {
+ paddingLeft: '[NodeId: VariableID:padding-left]',
+ },
+ },
+ ]
+
+ const rootNode = assembleNodeTree(nodes) as unknown as {
+ boundVariables?: {
+ paddingLeft?: { id: string }
+ }
+ }
+
+ expect(rootNode.boundVariables?.paddingLeft).toEqual({
+ id: 'VariableID:padding-left',
+ })
+ })
+
+ test('should convert effect-level boundVariables from string ids', () => {
+ const nodes = [
+ {
+ id: 'frame-1',
+ name: 'FrameNode',
+ type: 'FRAME',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ boundVariables: {
+ color: '[NodeId: VariableID:shadow-color]',
+ offsetX: 'VariableID:shadow-x',
+ },
+ },
+ ],
+ },
+ ]
+
+ const rootNode = assembleNodeTree(nodes) as unknown as {
+ effects?: Array<{
+ boundVariables?: {
+ color?: { id: string }
+ offsetX?: { id: string }
+ }
+ }>
+ }
+
+ expect(rootNode.effects?.[0]?.boundVariables).toEqual({
+ color: { id: 'VariableID:shadow-color' },
+ offsetX: { id: 'VariableID:shadow-x' },
+ })
+ })
})
describe('setupVariableMocks', () => {
diff --git a/src/codegen/utils/__tests__/optimize-space.test.ts b/src/codegen/utils/__tests__/optimize-space.test.ts
new file mode 100644
index 0000000..0a2a239
--- /dev/null
+++ b/src/codegen/utils/__tests__/optimize-space.test.ts
@@ -0,0 +1,129 @@
+import { beforeEach, describe, expect, mock, test } from 'bun:test'
+import { optimizeSpace, optimizeSpaceAsync } from '../optimize-space'
+import { resetVariableCache } from '../variable-cache'
+
+function setVariableMock(
+ variableNameById: Record,
+): void {
+ ;(globalThis as { figma?: unknown }).figma = {
+ variables: {
+ getVariableByIdAsync: mock(async (id: string) => {
+ const name = variableNameById[id]
+ if (!name) return null
+ return { id, name }
+ }),
+ },
+ } as unknown as typeof figma
+}
+
+describe('optimizeSpaceAsync', () => {
+ beforeEach(() => {
+ resetVariableCache()
+ })
+
+ test('falls back to sync optimizeSpace when no boundVariables', async () => {
+ setVariableMock({})
+
+ const result = await optimizeSpaceAsync('p', 8, 12, 8, 12, undefined)
+
+ expect(result).toEqual(optimizeSpace('p', 8, 12, 8, 12))
+ })
+
+ test('falls back to sync optimizeSpace when bindings exist but variables do not resolve', async () => {
+ setVariableMock({})
+
+ const result = await optimizeSpaceAsync('p', 8, 12, 8, 12, {
+ paddingTop: { id: 'var-padding-top' },
+ paddingRight: { id: 'var-padding-right' },
+ paddingBottom: { id: 'var-padding-bottom' },
+ paddingLeft: { id: 'var-padding-left' },
+ })
+
+ expect(result).toEqual(optimizeSpace('p', 8, 12, 8, 12))
+ })
+
+ test('returns shorthand p when all sides resolve to same token', async () => {
+ setVariableMock({
+ 'var-space': 'space-100',
+ })
+
+ const result = await optimizeSpaceAsync('p', 10, 20, 30, 40, {
+ paddingTop: { id: 'var-space' },
+ paddingRight: { id: 'var-space' },
+ paddingBottom: { id: 'var-space' },
+ paddingLeft: { id: 'var-space' },
+ })
+
+ expect(result).toEqual({ p: '$space100' })
+ })
+
+ test('supports margin field mapping with variable + raw mix', async () => {
+ setVariableMock({
+ 'var-margin-top': 'layout/spacing/top',
+ })
+
+ const result = await optimizeSpaceAsync('m', 16, 8, 24, 8, {
+ marginTop: { id: 'var-margin-top' },
+ })
+
+ expect(result).toEqual({
+ mx: '8px',
+ mt: '$layoutSpacingTop',
+ mb: '24px',
+ })
+ })
+
+ test('returns all individual sides when shorthand conditions do not match', async () => {
+ setVariableMock({
+ 'var-padding-top': 'space/top',
+ 'var-padding-right': 'space/right',
+ })
+
+ const result = await optimizeSpaceAsync('p', 12, 24, 36, 48, {
+ paddingTop: { id: 'var-padding-top' },
+ paddingRight: { id: 'var-padding-right' },
+ })
+
+ expect(result).toEqual({
+ pt: '$spaceTop',
+ pr: '$spaceRight',
+ pb: '36px',
+ pl: '48px',
+ })
+ })
+
+ test('returns py/px shorthand when vertical and horizontal sides match', async () => {
+ setVariableMock({
+ 'var-y': 'space/y',
+ })
+
+ const result = await optimizeSpaceAsync('p', 16, 24, 16, 24, {
+ paddingTop: { id: 'var-y' },
+ paddingBottom: { id: 'var-y' },
+ })
+
+ expect(result).toEqual({
+ py: '$spaceY',
+ px: '24px',
+ })
+ })
+
+ test('returns py + side props when only top and bottom match', async () => {
+ setVariableMock({
+ 'var-y': 'space/y',
+ 'var-right': 'space/right',
+ })
+
+ const result = await optimizeSpaceAsync('p', 16, 24, 16, 40, {
+ paddingTop: { id: 'var-y' },
+ paddingBottom: { id: 'var-y' },
+ paddingRight: { id: 'var-right' },
+ })
+
+ expect(result).toEqual({
+ py: '$spaceY',
+ pr: '$spaceRight',
+ pl: '40px',
+ })
+ })
+})
diff --git a/src/codegen/utils/__tests__/resolve-bound-variable.test.ts b/src/codegen/utils/__tests__/resolve-bound-variable.test.ts
new file mode 100644
index 0000000..8b5df42
--- /dev/null
+++ b/src/codegen/utils/__tests__/resolve-bound-variable.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, mock, test } from 'bun:test'
+import {
+ hasBoundVariable,
+ resolveBoundVariable,
+} from '../resolve-bound-variable'
+import { resetVariableCache } from '../variable-cache'
+
+describe('resolveBoundVariable', () => {
+ beforeEach(() => {
+ resetVariableCache()
+ })
+
+ test('returns variable token when bound variable exists', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ variables: {
+ getVariableByIdAsync: mock(async () => {
+ return { name: 'shadow-color' }
+ }),
+ },
+ } as unknown as typeof figma
+
+ const result = await resolveBoundVariable(
+ {
+ paddingLeft: { id: 'var-padding-left' },
+ },
+ 'paddingLeft',
+ )
+
+ expect(result).toBe('$shadowColor')
+ })
+
+ test('returns null when field has no bound variable', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ variables: {
+ getVariableByIdAsync: mock(async () => null),
+ },
+ } as unknown as typeof figma
+
+ const result = await resolveBoundVariable(
+ {
+ paddingRight: { id: 'var-padding-right' },
+ },
+ 'paddingLeft',
+ )
+
+ expect(result).toBeNull()
+ })
+
+ test('returns null when variable lookup has no name', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ variables: {
+ getVariableByIdAsync: mock(async () => {
+ return { id: 'var-missing-name' }
+ }),
+ },
+ } as unknown as typeof figma
+
+ const result = await resolveBoundVariable(
+ {
+ paddingTop: { id: 'var-missing-name' },
+ },
+ 'paddingTop',
+ )
+
+ expect(result).toBeNull()
+ })
+})
+
+describe('hasBoundVariable', () => {
+ test('returns true when field has a variable binding', () => {
+ expect(
+ hasBoundVariable(
+ {
+ cornerRadius: { id: 'var-corner-radius' },
+ },
+ 'cornerRadius',
+ ),
+ ).toBe(true)
+ })
+
+ test('returns false when field does not have a variable binding', () => {
+ expect(
+ hasBoundVariable(
+ {
+ cornerRadius: { id: 'var-corner-radius' },
+ },
+ 'topLeftRadius',
+ ),
+ ).toBe(false)
+ })
+})
diff --git a/src/codegen/utils/assemble-node-tree.ts b/src/codegen/utils/assemble-node-tree.ts
index 5dbfd6b..667b0ee 100644
--- a/src/codegen/utils/assemble-node-tree.ts
+++ b/src/codegen/utils/assemble-node-tree.ts
@@ -87,6 +87,57 @@ export function assembleNodeTree(
}
}
+ // Node-level boundVariables 처리 (padding, spacing, sizing, radius, effects, fills 등)
+ // 문자열 ID('[NodeId: VariableID:...]')를 { id: '...' } 객체로 변환
+ // Handles both scalar fields (paddingLeft: "...") and array fields (effects: ["..."])
+ if (node.boundVariables && typeof node.boundVariables === 'object') {
+ const bv = node.boundVariables as Record
+ for (const [key, val] of Object.entries(bv)) {
+ if (typeof val === 'string') {
+ let varId = val
+ const match = varId.match(/\[NodeId: ([^\]]+)\]/)
+ if (match) {
+ varId = match[1]
+ }
+ bv[key] = { id: varId }
+ } else if (Array.isArray(val)) {
+ bv[key] = val.map((item) => {
+ if (typeof item === 'string') {
+ let varId = item
+ const match = varId.match(/\[NodeId: ([^\]]+)\]/)
+ if (match) {
+ varId = match[1]
+ }
+ return { id: varId }
+ }
+ return item
+ })
+ }
+ }
+ }
+
+ // Effect-level boundVariables 처리 (shadow offset, radius, spread, color)
+ if (Array.isArray(node.effects)) {
+ for (const effect of node.effects as Record[]) {
+ if (
+ effect?.boundVariables &&
+ typeof effect.boundVariables === 'object'
+ ) {
+ const bv = effect.boundVariables as Record
+ for (const [key, val] of Object.entries(bv)) {
+ if (typeof val === 'string') {
+ let varId = val
+ const match = varId.match(/\[NodeId: ([^\]]+)\]/)
+ if (match) {
+ varId = match[1]
+ }
+ bv[key] = { id: varId }
+ }
+ }
+ }
+ }
+ }
+
// fills/strokes의 boundVariables 처리
// boundVariables.color가 문자열 ID인 경우 { id: '...' } 객체로 변환
const processBoundVariables = (paints: unknown[]) => {
diff --git a/src/codegen/utils/node-proxy.ts b/src/codegen/utils/node-proxy.ts
index 04e5113..56b46cf 100644
--- a/src/codegen/utils/node-proxy.ts
+++ b/src/codegen/utils/node-proxy.ts
@@ -193,6 +193,8 @@ class NodeProxyTracker {
'counterAxisSpacing',
'clipsContent',
'isAsset',
+ 'boundVariables',
+ 'effectStyleId',
'reactions',
'minWidth',
'maxWidth',
@@ -349,6 +351,8 @@ class NodeProxyTracker {
'counterAxisSpacing',
'clipsContent',
'isAsset',
+ 'boundVariables',
+ 'effectStyleId',
'reactions',
'minWidth',
'maxWidth',
diff --git a/src/codegen/utils/optimize-space.ts b/src/codegen/utils/optimize-space.ts
index 6d20b33..de1d3b1 100644
--- a/src/codegen/utils/optimize-space.ts
+++ b/src/codegen/utils/optimize-space.ts
@@ -1,4 +1,5 @@
import { addPx } from './add-px'
+import { resolveBoundVariable } from './resolve-bound-variable'
export function optimizeSpace(
type: 'm' | 'p',
@@ -41,3 +42,91 @@ export function optimizeSpace(
[`${type}l`]: addPx(l),
}
}
+
+const FIELD_MAP = {
+ p: {
+ t: 'paddingTop',
+ r: 'paddingRight',
+ b: 'paddingBottom',
+ l: 'paddingLeft',
+ },
+ m: {
+ t: 'marginTop',
+ r: 'marginRight',
+ b: 'marginBottom',
+ l: 'marginLeft',
+ },
+} as const
+
+/**
+ * Async variant of optimizeSpace that resolves variable-bound padding/margin.
+ * Fast path: if no boundVariables exist, delegates to sync optimizeSpace.
+ */
+export async function optimizeSpaceAsync(
+ type: 'm' | 'p',
+ t: number,
+ r: number,
+ b: number,
+ l: number,
+ boundVariables: Record | undefined | null,
+): Promise> {
+ const fields = FIELD_MAP[type]
+
+ // Fast path: no boundVariables, delegate to sync version
+ if (
+ !boundVariables ||
+ (!boundVariables[fields.t] &&
+ !boundVariables[fields.r] &&
+ !boundVariables[fields.b] &&
+ !boundVariables[fields.l])
+ ) {
+ return optimizeSpace(type, t, r, b, l)
+ }
+
+ // Resolve all four values in parallel
+ const [vt, vr, vb, vl] = await Promise.all([
+ resolveBoundVariable(boundVariables, fields.t),
+ resolveBoundVariable(boundVariables, fields.r),
+ resolveBoundVariable(boundVariables, fields.b),
+ resolveBoundVariable(boundVariables, fields.l),
+ ])
+
+ // If no variables actually resolved, fall back to sync
+ if (!vt && !vr && !vb && !vl) {
+ return optimizeSpace(type, t, r, b, l)
+ }
+
+ // Round raw values for non-variable sides
+ const top = vt ?? addPx(Math.round(t * 100) / 100)
+ const right = vr ?? addPx(Math.round(r * 100) / 100)
+ const bottom = vb ?? addPx(Math.round(b * 100) / 100)
+ const left = vl ?? addPx(Math.round(l * 100) / 100)
+
+ // Shorthand optimization using string comparison
+ if (top === right && right === bottom && bottom === left) {
+ return { [type]: top }
+ }
+ if (top === bottom && right === left) {
+ return { [`${type}y`]: top, [`${type}x`]: left }
+ }
+ if (top === bottom) {
+ return {
+ [`${type}y`]: top,
+ [`${type}r`]: right,
+ [`${type}l`]: left,
+ }
+ }
+ if (left === right) {
+ return {
+ [`${type}x`]: left,
+ [`${type}t`]: top,
+ [`${type}b`]: bottom,
+ }
+ }
+ return {
+ [`${type}t`]: top,
+ [`${type}r`]: right,
+ [`${type}b`]: bottom,
+ [`${type}l`]: left,
+ }
+}
diff --git a/src/codegen/utils/resolve-bound-variable.ts b/src/codegen/utils/resolve-bound-variable.ts
new file mode 100644
index 0000000..c15205a
--- /dev/null
+++ b/src/codegen/utils/resolve-bound-variable.ts
@@ -0,0 +1,41 @@
+import { toCamel } from '../../utils/to-camel'
+import { getVariableByIdCached } from './variable-cache'
+
+/**
+ * Resolve a bound variable for a given field on any object with boundVariables.
+ * Returns `$variableName` (camelCase) if the field is variable-bound, null otherwise.
+ *
+ * Works for both node-level bindings (padding, spacing, sizing, radius)
+ * and effect-level bindings (shadow offset, radius, spread, color).
+ *
+ * @example
+ * // Node-level: node.boundVariables?.paddingLeft
+ * await resolveBoundVariable(node.boundVariables, 'paddingLeft')
+ * // → "$containerX" or null
+ *
+ * // Effect-level: effect.boundVariables?.radius
+ * await resolveBoundVariable(effect.boundVariables, 'radius')
+ * // → "$shadowBlur" or null
+ */
+export async function resolveBoundVariable(
+ boundVariables: Record | undefined | null,
+ field: string,
+): Promise {
+ const binding = boundVariables?.[field]
+ if (!binding) return null
+ const variable = await getVariableByIdCached(binding.id)
+ if (variable?.name) return `$${toCamel(variable.name)}`
+ return null
+}
+
+/**
+ * Synchronous fast path for resolveBoundVariable.
+ * Returns true if the field has a bound variable (caller must use async
+ * resolveBoundVariable). Returns false when no variable is bound.
+ */
+export function hasBoundVariable(
+ boundVariables: Record | undefined | null,
+ field: string,
+): boolean {
+ return !!boundVariables?.[field]
+}
diff --git a/src/commands/devup/__tests__/export-devup.length-shadow.test.ts b/src/commands/devup/__tests__/export-devup.length-shadow.test.ts
new file mode 100644
index 0000000..7fad256
--- /dev/null
+++ b/src/commands/devup/__tests__/export-devup.length-shadow.test.ts
@@ -0,0 +1,399 @@
+import {
+ afterAll,
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ mock,
+ spyOn,
+ test,
+} from 'bun:test'
+import * as downloadFileModule from '../../../utils/download-file'
+import * as isVariableAliasModule from '../../../utils/is-variable-alias'
+import * as optimizeHexModule from '../../../utils/optimize-hex'
+import * as rgbaToHexModule from '../../../utils/rgba-to-hex'
+import * as variableAliasToValueModule from '../../../utils/variable-alias-to-value'
+import { buildDevupConfig, exportDevup } from '../export-devup'
+import * as downloadXlsxModule from '../utils/download-devup-xlsx'
+
+afterAll(() => {
+ mock.restore()
+})
+
+describe('export-devup length and shadow coverage', () => {
+ beforeEach(() => {
+ spyOn(optimizeHexModule, 'optimizeHex').mockImplementation((v) => v)
+ spyOn(rgbaToHexModule, 'rgbaToHex').mockImplementation(
+ (rgba: RGBA) =>
+ `#${Math.round(rgba.r * 255)
+ .toString(16)
+ .padStart(2, '0')}${Math.round(rgba.g * 255)
+ .toString(16)
+ .padStart(2, '0')}${Math.round(rgba.b * 255)
+ .toString(16)
+ .padStart(2, '0')}`,
+ )
+ })
+
+ afterEach(() => {
+ ;(globalThis as { figma?: unknown }).figma = undefined
+ mock.restore()
+ })
+
+ test('exports FLOAT variables with full breakpoint mapping and color theme resolution', async () => {
+ const aliasGuard = spyOn(
+ isVariableAliasModule,
+ 'isVariableAlias',
+ ).mockImplementation(
+ (value: unknown): value is VariableAlias =>
+ typeof value === 'object' && value !== null && 'type' in value,
+ )
+ const aliasResolver = spyOn(
+ variableAliasToValueModule,
+ 'variableAliasToValue',
+ ).mockImplementation(async (_alias, modeId) => {
+ if (modeId === 'mTablet') return 8
+ if (modeId === 'mDesktop') return 10
+ if (modeId === 'mSm') return null
+ return null
+ })
+
+ const variablesById: Record = {
+ c1: {
+ id: 'c1',
+ name: 'Primary',
+ resolvedType: 'COLOR',
+ valuesByMode: { mLight: { r: 1, g: 0, b: 0, a: 1 } },
+ } as unknown as Variable,
+ fSingle: {
+ id: 'fSingle',
+ name: 'borderRadiusMd',
+ resolvedType: 'FLOAT',
+ valuesByMode: { mMobile: 16 },
+ } as unknown as Variable,
+ fResponsive: {
+ id: 'fResponsive',
+ name: 'spaceScale',
+ resolvedType: 'FLOAT',
+ valuesByMode: {
+ mMobile: 4,
+ mTablet: 12,
+ },
+ } as unknown as Variable,
+ fAlias: {
+ id: 'fAlias',
+ name: 'inset',
+ resolvedType: 'FLOAT',
+ valuesByMode: {
+ mSm: { type: 'VARIABLE_ALIAS', id: 'skip' },
+ mTablet: { type: 'VARIABLE_ALIAS', id: 'a1' },
+ mDesktop: { type: 'VARIABLE_ALIAS', id: 'a2' },
+ },
+ } as unknown as Variable,
+ fNone: {
+ id: 'fNone',
+ name: 'unused',
+ resolvedType: 'FLOAT',
+ valuesByMode: {
+ mUnknown: true,
+ },
+ } as unknown as Variable,
+ }
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: async (id: string) => variablesById[id] ?? null,
+ getLocalVariableCollectionsAsync: async () => [
+ {
+ variableIds: ['c1'],
+ modes: [{ modeId: 'mLight', name: 'Light' }],
+ },
+ {
+ variableIds: ['fSingle', 'fResponsive', 'fAlias', 'fNone'],
+ modes: [
+ { modeId: 'mMobile', name: 'mobile' },
+ { modeId: 'mSm', name: 'sm' },
+ { modeId: 'mTablet', name: 'tablet' },
+ { modeId: 'mLg', name: 'lg' },
+ { modeId: 'mDesktop', name: 'desktop' },
+ { modeId: 'mPc', name: 'pc' },
+ { modeId: 'mNumeric', name: '5' },
+ { modeId: 'mUnknown', name: 'unknown' },
+ ],
+ },
+ {
+ variableIds: [],
+ modes: [{ modeId: 'mEmpty', name: 'mobile' }],
+ },
+ ],
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
+ root: { findAllWithCriteria: () => [], children: [] },
+ } as unknown as typeof figma
+
+ const devup = await buildDevupConfig(false)
+
+ expect(aliasGuard).toHaveBeenCalled()
+ expect(aliasResolver).toHaveBeenCalled()
+ expect(devup.theme?.length?.light?.borderRadiusMd).toBe('16px')
+ expect(devup.theme?.length?.light?.spaceScale).toEqual([
+ '4px',
+ null,
+ '12px',
+ ])
+ expect(devup.theme?.length?.light?.inset).toEqual([
+ '8px',
+ null,
+ '8px',
+ null,
+ '10px',
+ ])
+ })
+
+ test('exports FLOAT variables to default theme when colors are missing', async () => {
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: async () =>
+ ({
+ id: 'f1',
+ name: 'gapMd',
+ resolvedType: 'FLOAT',
+ valuesByMode: { m1: 20 },
+ }) as unknown as Variable,
+ getLocalVariableCollectionsAsync: async () => [
+ {
+ variableIds: ['f1'],
+ modes: [{ modeId: 'm1', name: 'mobile' }],
+ },
+ ],
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
+ root: { findAllWithCriteria: () => [], children: [] },
+ } as unknown as typeof figma
+
+ const devup = await buildDevupConfig(false)
+
+ expect(devup.theme?.length?.default?.gapMd).toBe('20px')
+ })
+
+ test('exports effect styles as shadow with level prefixes and theme wrapper', async () => {
+ const variablesById: Record = {
+ c1: {
+ id: 'c1',
+ name: 'Primary',
+ resolvedType: 'COLOR',
+ valuesByMode: { mLight: { r: 0, g: 0, b: 0, a: 1 } },
+ } as unknown as Variable,
+ }
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: async (id: string) => variablesById[id] ?? null,
+ getLocalVariableCollectionsAsync: async () => [
+ {
+ variableIds: ['c1'],
+ modes: [{ modeId: 'mLight', name: 'Light' }],
+ },
+ ],
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () =>
+ [
+ {
+ id: 's1',
+ name: 'mobile/cardShadow',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ radius: 8,
+ spread: 2,
+ color: { r: 0, g: 0, b: 0, a: 0.2 },
+ offset: { x: 0, y: 4 },
+ blendMode: 'NORMAL',
+ showShadowBehindNode: false,
+ },
+ ],
+ },
+ {
+ id: 's2',
+ name: '3/cardShadow',
+ effects: [
+ {
+ type: 'INNER_SHADOW',
+ visible: true,
+ radius: 6,
+ spread: 1,
+ color: { r: 0, g: 0, b: 0, a: 0.1 },
+ offset: { x: 1, y: 2 },
+ blendMode: 'NORMAL',
+ showShadowBehindNode: false,
+ },
+ ],
+ },
+ {
+ id: 's3',
+ name: 'desktop/cardShadow',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: true,
+ radius: 10,
+ spread: 0,
+ color: { r: 0, g: 0, b: 0, a: 0.3 },
+ offset: { x: 0, y: 6 },
+ blendMode: 'NORMAL',
+ showShadowBehindNode: false,
+ },
+ ],
+ },
+ {
+ id: 's4',
+ name: 'tablet/ghostShadow',
+ effects: [
+ {
+ type: 'DROP_SHADOW',
+ visible: false,
+ radius: 10,
+ spread: 0,
+ color: { r: 0, g: 0, b: 0, a: 0.3 },
+ offset: { x: 0, y: 6 },
+ blendMode: 'NORMAL',
+ showShadowBehindNode: false,
+ },
+ ],
+ },
+ ] as unknown as EffectStyle[],
+ root: { findAllWithCriteria: () => [], children: [] },
+ } as unknown as typeof figma
+
+ const devup = await buildDevupConfig(false)
+
+ expect(devup.theme?.shadows?.light?.cardShadow).toEqual([
+ '0 4px 8px 2px #000000',
+ null,
+ null,
+ 'inset 1px 2px 6px 1px #000000',
+ '0 6px 10px 0 #000000',
+ ])
+ expect(devup.theme?.shadows?.light?.ghostShadow).toBeUndefined()
+ })
+
+ test('exports color aliases and removes empty length theme buckets', async () => {
+ const aliasResolver = spyOn(
+ variableAliasToValueModule,
+ 'variableAliasToValue',
+ ).mockImplementation(async (_alias, modeId) => {
+ if (modeId === 'm1') return { r: 0, g: 0.5, b: 1, a: 1 }
+ if (modeId === 'm2') return 10
+ if (modeId === 'm3') return true
+ return null
+ })
+
+ const variablesById: Record = {
+ colorAliasGood: {
+ id: 'colorAliasGood',
+ name: 'Accent',
+ resolvedType: 'COLOR',
+ valuesByMode: {
+ m1: { type: 'VARIABLE_ALIAS', id: 'x1' },
+ m2: true,
+ m3: true,
+ },
+ } as unknown as Variable,
+ colorAliasNumber: {
+ id: 'colorAliasNumber',
+ name: 'SkipNumber',
+ resolvedType: 'COLOR',
+ valuesByMode: {
+ m1: true,
+ m2: { type: 'VARIABLE_ALIAS', id: 'x2' },
+ m3: true,
+ },
+ } as unknown as Variable,
+ colorAliasBoolean: {
+ id: 'colorAliasBoolean',
+ name: 'SkipBoolean',
+ resolvedType: 'COLOR',
+ valuesByMode: {
+ m1: true,
+ m2: true,
+ m3: { type: 'VARIABLE_ALIAS', id: 'x3' },
+ },
+ } as unknown as Variable,
+ floatNoNumber: {
+ id: 'floatNoNumber',
+ name: 'emptyFloat',
+ resolvedType: 'FLOAT',
+ valuesByMode: {
+ m1: { type: 'VARIABLE_ALIAS', id: 'x4' },
+ },
+ } as unknown as Variable,
+ }
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: async (id: string) => variablesById[id] ?? null,
+ getLocalVariableCollectionsAsync: async () => [
+ {
+ variableIds: [
+ 'colorAliasGood',
+ 'colorAliasNumber',
+ 'colorAliasBoolean',
+ 'floatNoNumber',
+ ],
+ modes: [
+ { modeId: 'm1', name: 'Light' },
+ { modeId: 'm2', name: 'tablet' },
+ { modeId: 'm3', name: 'desktop' },
+ ],
+ },
+ ],
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
+ root: { findAllWithCriteria: () => [], children: [] },
+ } as unknown as typeof figma
+
+ const devup = await buildDevupConfig(false)
+
+ expect(aliasResolver).toHaveBeenCalled()
+ expect(devup.theme?.colors?.light?.accent).toBe('#0080ff')
+ expect(devup.theme?.colors?.tablet?.skipNumber).toBeUndefined()
+ expect(devup.theme?.colors?.desktop?.skipBoolean).toBeUndefined()
+ expect(devup.theme?.length).toBeUndefined()
+ })
+
+ test('exportDevup sends excel output to xlsx downloader', async () => {
+ const downloadXlsx = spyOn(
+ downloadXlsxModule,
+ 'downloadDevupXlsx',
+ ).mockResolvedValue(undefined)
+ const downloadJson = spyOn(
+ downloadFileModule,
+ 'downloadFile',
+ ).mockResolvedValue(undefined)
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
+ root: { findAllWithCriteria: () => [], children: [] },
+ } as unknown as typeof figma
+
+ await exportDevup('excel', false)
+
+ expect(downloadXlsx).toHaveBeenCalledWith('devup.xlsx', JSON.stringify({}))
+ expect(downloadJson).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/commands/devup/__tests__/import-devup.length-shadow.test.ts b/src/commands/devup/__tests__/import-devup.length-shadow.test.ts
new file mode 100644
index 0000000..7817da9
--- /dev/null
+++ b/src/commands/devup/__tests__/import-devup.length-shadow.test.ts
@@ -0,0 +1,247 @@
+import {
+ afterAll,
+ afterEach,
+ describe,
+ expect,
+ mock,
+ spyOn,
+ test,
+} from 'bun:test'
+import * as uploadFileModule from '../../../utils/upload-file'
+import { importDevup } from '../import-devup'
+import * as getColorCollectionModule from '../utils/get-devup-color-collection'
+import * as uploadXlsxModule from '../utils/upload-devup-xlsx'
+
+afterAll(() => {
+ mock.restore()
+})
+
+describe('import-devup length and shadow coverage', () => {
+ afterEach(() => {
+ ;(globalThis as { figma?: unknown }).figma = undefined
+ })
+
+ test('imports length as FLOAT variables for single and responsive values', async () => {
+ spyOn(uploadFileModule, 'uploadFile').mockResolvedValue(
+ JSON.stringify({
+ theme: {
+ length: {
+ default: {
+ radiusMd: '16px',
+ space: ['4px', null, '12px', null, '20px', '24px'],
+ },
+ },
+ },
+ }),
+ )
+
+ const existingFloatSetValueForMode = mock(() => {})
+ const existingFloat = {
+ id: 'v-space',
+ name: 'space',
+ resolvedType: 'FLOAT',
+ setValueForMode: existingFloatSetValueForMode,
+ remove: mock(() => {}),
+ } as unknown as Variable
+
+ const createdSetValueForMode = mock(() => {})
+ const createVariable = mock(
+ (name: string) =>
+ ({
+ id: `created-${name}`,
+ name,
+ resolvedType: 'FLOAT',
+ setValueForMode: createdSetValueForMode,
+ remove: mock(() => {}),
+ }) as unknown as Variable,
+ )
+
+ const addMode = mock((name: string) => `${name}-id`)
+ const collection = {
+ variableIds: ['v-space'],
+ modes: [] as { modeId: string; name: string }[],
+ addMode,
+ removeMode: mock(() => {}),
+ } as unknown as VariableCollection
+
+ spyOn(
+ getColorCollectionModule,
+ 'getDevupColorCollection',
+ ).mockResolvedValue(collection)
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ createVariableCollection: () => collection,
+ getLocalVariablesAsync: async () => [existingFloat],
+ createVariable,
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
+ createEffectStyle: () => ({ name: '', effects: [] }),
+ createTextStyle: mock(
+ () =>
+ ({
+ name: '',
+ }) as unknown as TextStyle,
+ ),
+ loadFontAsync: mock(async () => {}),
+ } as unknown as typeof figma
+
+ await importDevup('json')
+
+ expect(createVariable).toHaveBeenCalledWith('radiusMd', collection, 'FLOAT')
+ expect(addMode).toHaveBeenCalledWith('mobile')
+ expect(addMode).toHaveBeenCalledWith('tablet')
+ expect(addMode).toHaveBeenCalledWith('desktop')
+ expect(addMode).toHaveBeenCalledWith('5')
+ expect(createdSetValueForMode).toHaveBeenCalledWith('mobile-id', 16)
+ expect(existingFloatSetValueForMode).toHaveBeenCalledWith('mobile-id', 4)
+ expect(existingFloatSetValueForMode).toHaveBeenCalledWith('tablet-id', 12)
+ expect(existingFloatSetValueForMode).toHaveBeenCalledWith('desktop-id', 20)
+ expect(existingFloatSetValueForMode).toHaveBeenCalledWith('5-id', 24)
+ })
+
+ test('imports shadow styles and parses hex, rgba, inset and multiple shadows', async () => {
+ spyOn(uploadFileModule, 'uploadFile').mockResolvedValue(
+ JSON.stringify({
+ theme: {
+ shadows: {
+ default: {
+ soft: '1px 2px 3px 4px #abc',
+ layered: [
+ null,
+ null,
+ 'inset 4px 5px 6px 7px #11223344, 8px 9px 10px 0 rgba(1, 2, 3, 0.4)',
+ ],
+ invalid: '1px 2px not-a-color',
+ },
+ },
+ },
+ }),
+ )
+
+ const existingStyle = {
+ name: 'mobile/soft',
+ effects: [],
+ } as unknown as EffectStyle
+ const createdStyles: Array<{ name: string; effects: Effect[] }> = []
+ const createEffectStyle = mock(() => {
+ const created = { name: '', effects: [] as Effect[] }
+ createdStyles.push(created)
+ return created as unknown as EffectStyle
+ })
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ createVariableCollection: () =>
+ ({
+ variableIds: [],
+ modes: [],
+ addMode: mock(() => 'm1'),
+ removeMode: mock(() => {}),
+ }) as unknown as VariableCollection,
+ getLocalVariablesAsync: async () => [],
+ createVariable: mock(
+ () =>
+ ({
+ id: 'x',
+ name: 'x',
+ setValueForMode: mock(() => {}),
+ remove: mock(() => {}),
+ }) as unknown as Variable,
+ ),
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [existingStyle],
+ createEffectStyle,
+ createTextStyle: mock(
+ () =>
+ ({
+ name: '',
+ }) as unknown as TextStyle,
+ ),
+ loadFontAsync: mock(async () => {}),
+ } as unknown as typeof figma
+
+ await importDevup('json')
+
+ expect(existingStyle.name).toBe('mobile/soft')
+ expect(existingStyle.effects).toHaveLength(1)
+ expect(existingStyle.effects[0]).toMatchObject({
+ type: 'DROP_SHADOW',
+ offset: { x: 1, y: 2 },
+ radius: 3,
+ spread: 4,
+ })
+
+ const tabletLayered = createdStyles.find((s) => s.name === 'tablet/layered')
+ expect(tabletLayered).toBeDefined()
+ expect(tabletLayered?.effects).toHaveLength(2)
+ expect(tabletLayered?.effects[0]).toMatchObject({
+ type: 'INNER_SHADOW',
+ offset: { x: 4, y: 5 },
+ radius: 6,
+ spread: 7,
+ })
+ expect(tabletLayered?.effects[1]).toMatchObject({
+ type: 'DROP_SHADOW',
+ offset: { x: 8, y: 9 },
+ radius: 10,
+ spread: 0,
+ })
+
+ const invalidStyle = createdStyles.find((s) => s.name === 'mobile/invalid')
+ expect(invalidStyle).toBeDefined()
+ expect(invalidStyle?.effects).toHaveLength(1)
+ expect(invalidStyle?.effects[0]).toMatchObject({
+ color: { r: 0, g: 0, b: 0, a: 1 },
+ })
+ expect(createEffectStyle).toHaveBeenCalledTimes(2)
+ })
+
+ test('loads excel payload path and exits safely for empty theme', async () => {
+ const uploadXlsx = spyOn(
+ uploadXlsxModule,
+ 'uploadDevupXlsx',
+ ).mockResolvedValue({})
+
+ ;(globalThis as { figma?: unknown }).figma = {
+ util: { rgba: (v: unknown) => v },
+ variables: {
+ createVariableCollection: () =>
+ ({
+ variableIds: [],
+ modes: [],
+ addMode: mock(() => 'm1'),
+ removeMode: mock(() => {}),
+ }) as unknown as VariableCollection,
+ getLocalVariablesAsync: async () => [],
+ createVariable: mock(
+ () =>
+ ({
+ id: 'x',
+ name: 'x',
+ setValueForMode: mock(() => {}),
+ remove: mock(() => {}),
+ }) as unknown as Variable,
+ ),
+ },
+ getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
+ createEffectStyle: () => ({ name: '', effects: [] }),
+ createTextStyle: mock(
+ () =>
+ ({
+ name: '',
+ }) as unknown as TextStyle,
+ ),
+ loadFontAsync: mock(async () => {}),
+ } as unknown as typeof figma
+
+ await importDevup('excel')
+
+ expect(uploadXlsx).toHaveBeenCalled()
+ })
+})
diff --git a/src/commands/devup/__tests__/import-devup.test.ts b/src/commands/devup/__tests__/import-devup.test.ts
index ceac7d4..84a3ca5 100644
--- a/src/commands/devup/__tests__/import-devup.test.ts
+++ b/src/commands/devup/__tests__/import-devup.test.ts
@@ -66,7 +66,9 @@ describe('import-devup (standalone file)', () => {
createVariable,
},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
createTextStyle,
+ createEffectStyle: () => ({ name: '', effects: [] }),
loadFontAsync,
} as unknown as typeof figma
@@ -129,12 +131,14 @@ describe('import-devup (standalone file)', () => {
createVariable,
},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
createTextStyle: mock(
() =>
({
name: '',
}) as unknown as TextStyle,
),
+ createEffectStyle: () => ({ name: '', effects: [] }),
loadFontAsync: mock(() => Promise.resolve()),
} as unknown as typeof figma
@@ -194,7 +198,9 @@ describe('import-devup (standalone file)', () => {
),
},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
createTextStyle,
+ createEffectStyle: () => ({ name: '', effects: [] }),
loadFontAsync,
} as unknown as typeof figma
diff --git a/src/commands/devup/__tests__/index.test.ts b/src/commands/devup/__tests__/index.test.ts
index 39aa172..09bf460 100644
--- a/src/commands/devup/__tests__/index.test.ts
+++ b/src/commands/devup/__tests__/index.test.ts
@@ -106,19 +106,26 @@ describe('devup commands', () => {
util: { rgba: (v: unknown) => v },
loadAllPagesAsync: async () => {},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
root: { findAllWithCriteria: () => [] },
variables: {
getVariableByIdAsync: async () =>
({
name: 'Primary',
+ resolvedType: 'COLOR',
valuesByMode: { m1: { r: 1, g: 0, b: 0, a: 1 } },
}) as unknown as Variable,
+ getLocalVariableCollectionsAsync: async () => [
+ {
+ modes: [{ modeId: 'm1', name: 'Light' }],
+ variableIds: ['v1'],
+ },
+ ],
},
} as unknown as typeof figma
await exportDevup('json')
- expect(getColorCollectionSpy).toHaveBeenCalled()
expect(downloadFileMock).toHaveBeenCalledWith(
'devup.json',
expect.stringContaining('"primary":"#ff0000"'),
@@ -152,9 +159,11 @@ describe('devup commands', () => {
{ id: '1', name: 'heading/1' },
{ id: '2', name: 'heading/2' },
] as unknown as TextStyle[],
+ getLocalEffectStylesAsync: async () => [],
root: { findAllWithCriteria: () => [] },
variables: {
getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
},
} as unknown as typeof figma
@@ -210,6 +219,7 @@ describe('devup commands', () => {
getLocalTextStylesAsync: async () => [
{ id: 'style1', name: 'heading/1' } as unknown as TextStyle,
],
+ getLocalEffectStylesAsync: async () => [],
root: {
findAllWithCriteria: () => [textNode],
children: [],
@@ -224,6 +234,12 @@ describe('devup commands', () => {
name: 'Primary',
valuesByMode: { m1: { type: 'VARIABLE_ALIAS', id: 'var1' } },
}) as unknown as Variable,
+ getLocalVariableCollectionsAsync: async () => [
+ {
+ modes: [{ modeId: 'm1', name: 'Light' }],
+ variableIds: ['var1'],
+ },
+ ],
},
} as unknown as typeof figma
@@ -265,9 +281,13 @@ describe('devup commands', () => {
getLocalTextStylesAsync: async () => [
{ id: 'style1', name: 'heading/1' } as unknown as TextStyle,
],
+ getLocalEffectStylesAsync: async () => [],
root: { findAllWithCriteria: () => [mixedTextNode] },
mixed: mixedSymbol,
- variables: { getVariableByIdAsync: async () => null },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
} as unknown as typeof figma
await exportDevup('json', true)
@@ -329,11 +349,15 @@ describe('devup commands', () => {
{ id: 'style1', name: 'heading/1' } as unknown as TextStyle,
{ id: 'style2', name: 'heading/2' } as unknown as TextStyle,
],
+ getLocalEffectStylesAsync: async () => [],
root: {
children: [otherPage, currentPage],
},
mixed: Symbol('mixed'),
- variables: { getVariableByIdAsync: async () => null },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
} as unknown as typeof figma
await exportDevup('json', true)
@@ -400,11 +424,15 @@ describe('devup commands', () => {
{ id: 'style1', name: 'heading/1' } as unknown as TextStyle,
{ id: 'style2', name: 'body/2' } as unknown as TextStyle,
],
+ getLocalEffectStylesAsync: async () => [],
root: {
children: [currentPage, otherPage],
},
mixed: Symbol('mixed'),
- variables: { getVariableByIdAsync: async () => null },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
} as unknown as typeof figma
await exportDevup('json', true)
@@ -464,11 +492,15 @@ describe('devup commands', () => {
{ id: 'style1', name: 'heading/1' } as unknown as TextStyle,
{ id: 'style2', name: 'body/2' } as unknown as TextStyle,
],
+ getLocalEffectStylesAsync: async () => [],
root: {
children: [currentPage],
},
mixed: Symbol('mixed'),
- variables: { getVariableByIdAsync: async () => null },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
} as unknown as typeof figma
await exportDevup('json', true)
@@ -519,13 +551,17 @@ describe('devup commands', () => {
{ id: 'style1', name: 'heading/1' },
{ id: 'style2', name: 'heading/2' },
] as unknown as TextStyle[],
+ getLocalEffectStylesAsync: async () => [],
root: { findAllWithCriteria: () => [textNode], children: [] },
getStyleByIdAsync: async (id: string) =>
id === 'style1'
? ({ id: 'style1', name: 'heading/1' } as unknown as TextStyle)
: ({ id: 'style2', name: 'heading/2' } as unknown as TextStyle),
mixed: Symbol('mixed'),
- variables: { getVariableByIdAsync: async () => null },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
} as unknown as typeof figma
await exportDevup('json', true)
@@ -566,8 +602,12 @@ describe('devup commands', () => {
{ id: 'style1', name: 'heading/1' },
{ id: 'style3', name: 'heading/3' },
] as unknown as TextStyle[],
+ getLocalEffectStylesAsync: async () => [],
root: { findAllWithCriteria: () => [], children: [] },
- variables: { getVariableByIdAsync: async () => null },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
} as unknown as typeof figma
await exportDevup('json', false)
@@ -608,8 +648,12 @@ describe('devup commands', () => {
{ id: 'style0', name: 'heading/0' },
{ id: 'style1', name: 'heading/2' },
] as unknown as TextStyle[],
+ getLocalEffectStylesAsync: async () => [],
root: { findAllWithCriteria: () => [], children: [] },
- variables: { getVariableByIdAsync: async () => null },
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
} as unknown as typeof figma
await exportDevup('json', false)
@@ -638,7 +682,10 @@ describe('devup commands', () => {
;(globalThis as { figma?: unknown }).figma = {
util: { rgba: (v: unknown) => v },
- variables: {},
+ variables: {
+ getVariableByIdAsync: async () => null,
+ getLocalVariableCollectionsAsync: async () => [],
+ },
loadAllPagesAsync: async () => {},
getLocalTextStylesAsync: async () => [
{
@@ -647,6 +694,7 @@ describe('devup commands', () => {
fontName: { family: 'Inter', style: 'Regular' },
} as unknown as TextStyle,
],
+ getLocalEffectStylesAsync: async () => [],
root: {
findAllWithCriteria: () => [
{
@@ -734,6 +782,7 @@ describe('devup commands', () => {
createVariable,
},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
createTextStyle: createTextStyleMock,
loadFontAsync,
} as unknown as typeof figma
@@ -823,6 +872,7 @@ describe('devup commands', () => {
createVariable,
},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
createTextStyle: createTextStyleMock,
loadFontAsync,
} as unknown as typeof figma
@@ -881,6 +931,7 @@ describe('devup commands', () => {
}) as unknown as Variable,
},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
createTextStyle: createTextStyleMock,
loadFontAsync,
notify: notifyMock,
@@ -949,6 +1000,7 @@ describe('devup commands', () => {
}) as unknown as Variable,
},
getLocalTextStylesAsync: async () => [],
+ getLocalEffectStylesAsync: async () => [],
createTextStyle: () => styleObj,
loadFontAsync,
notify: mock(() => {}),
diff --git a/src/commands/devup/export-devup.ts b/src/commands/devup/export-devup.ts
index 4d7af29..805895c 100644
--- a/src/commands/devup/export-devup.ts
+++ b/src/commands/devup/export-devup.ts
@@ -1,3 +1,4 @@
+import { addPx } from '../../codegen/utils/add-px'
import {
perfEnd,
perfReport,
@@ -14,7 +15,63 @@ import { toCamel } from '../../utils/to-camel'
import { variableAliasToValue } from '../../utils/variable-alias-to-value'
import type { Devup, DevupTypography } from './types'
import { downloadDevupXlsx } from './utils/download-devup-xlsx'
-import { getDevupColorCollection } from './utils/get-devup-color-collection'
+
+/**
+ * Map mode name to responsive breakpoint index.
+ * mobile=0, sm=1, tablet=2, lg=3, desktop/pc=4.
+ * Numeric mode names are used directly.
+ * Unknown names default to 0.
+ */
+function modeNameToBreakpointLevel(name: string): number {
+ const lower = name.toLowerCase()
+ if (lower === 'mobile') return 0
+ if (lower === 'sm') return 1
+ if (lower === 'tablet') return 2
+ if (lower === 'lg') return 3
+ if (lower === 'desktop' || lower === 'pc') return 4
+ const num = Number.parseInt(lower, 10)
+ if (!Number.isNaN(num) && num >= 0 && num <= 5) return num
+ return 0
+}
+
+/**
+ * Get theme name from colors (first mode name), or "default".
+ */
+function resolveThemeName(devup: Devup): string {
+ const colors = devup.theme?.colors
+ if (colors) {
+ const firstKey = Object.keys(colors)[0]
+ if (firstKey) return firstKey
+ }
+ return 'default'
+}
+
+/**
+ * Optimize a responsive array: single value → plain string, trim trailing nulls.
+ */
+function optimizeResponsiveArray(
+ values: (null | string)[],
+): string | (null | string)[] {
+ const filtered = values.filter((v) => v !== null)
+ if (filtered.length === 0) return filtered[0]
+ if (filtered.length === 1) return filtered[0]
+ // Trim trailing nulls
+ while (values.length > 0 && values[values.length - 1] === null) {
+ values.pop()
+ }
+ // If first element is null, shift to start from first non-null
+ if (values[0] === null) {
+ const arr: (null | string)[] = [filtered[0]]
+ for (let i = 1; i < values.length; i++) {
+ arr.push(values[i])
+ }
+ while (arr.length > 0 && arr[arr.length - 1] === null) {
+ arr.pop()
+ }
+ return arr
+ }
+ return values
+}
type TextSearchNode = SceneNode & {
findAllWithCriteria(criteria: { types: ['TEXT'] }): TextNode[]
@@ -35,8 +92,10 @@ export async function buildDevupConfig(
const devup: Devup = {}
const tColors = perfStart()
- const collection = await getDevupColorCollection()
- if (collection) {
+ // Scan ALL variable collections — not just "Devup Colors"
+ const allCollections =
+ await figma.variables.getLocalVariableCollectionsAsync()
+ for (const collection of allCollections) {
// Pre-fetch all variables once — reuse across modes
const variables = await Promise.all(
collection.variableIds.map((varId) =>
@@ -45,39 +104,97 @@ export async function buildDevupConfig(
)
// Pre-compute camelCase names once (not per variable per mode)
const camelNames = variables.map((v) => (v ? toCamel(v.name) : ''))
- devup.theme ??= {}
- devup.theme.colors ??= {}
- const themeColors = devup.theme.colors
- // Process all modes in parallel
- await Promise.all(
- collection.modes.map(async (mode) => {
- const colors: Record = {}
- themeColors[mode.name.toLowerCase()] = colors
- await Promise.all(
- variables.map(async (variable, i) => {
- if (variable === null) return
- const value = variable.valuesByMode[mode.modeId]
- if (typeof value === 'boolean' || typeof value === 'number') return
- if (isVariableAlias(value)) {
- const nextValue = await variableAliasToValue(value, mode.modeId)
- if (nextValue === null) return
- if (
- typeof nextValue === 'boolean' ||
- typeof nextValue === 'number'
- )
+
+ // Export COLOR variables
+ const hasColors = variables.some(
+ (v) => v !== null && v.resolvedType === 'COLOR',
+ )
+ if (hasColors) {
+ devup.theme ??= {}
+ devup.theme.colors ??= {}
+ const themeColors = devup.theme.colors
+ await Promise.all(
+ collection.modes.map(async (mode) => {
+ const modeName = mode.name.toLowerCase()
+ const colors = themeColors[modeName] ?? {}
+ themeColors[modeName] = colors
+ await Promise.all(
+ 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')
return
- colors[camelNames[i]] = optimizeHex(
- rgbaToHex(figma.util.rgba(nextValue)),
- )
- } else {
- colors[camelNames[i]] = optimizeHex(
- rgbaToHex(figma.util.rgba(value)),
- )
- }
- }),
- )
- }),
+ if (isVariableAlias(value)) {
+ const nextValue = await variableAliasToValue(value, mode.modeId)
+ if (nextValue === null) return
+ if (
+ typeof nextValue === 'boolean' ||
+ typeof nextValue === 'number'
+ )
+ return
+ colors[camelNames[i]] = optimizeHex(
+ rgbaToHex(figma.util.rgba(nextValue)),
+ )
+ } else {
+ colors[camelNames[i]] = optimizeHex(
+ rgbaToHex(figma.util.rgba(value)),
+ )
+ }
+ }),
+ )
+ }),
+ )
+ }
+
+ // Export FLOAT variables as length values with responsive arrays
+ const hasFloats = variables.some(
+ (v) => v !== null && v.resolvedType === 'FLOAT',
)
+ if (hasFloats) {
+ devup.theme ??= {}
+ devup.theme.length ??= {}
+
+ // Determine theme name from colors, or "default"
+ const themeName = resolveThemeName(devup)
+
+ const lengthForTheme = devup.theme.length[themeName] ?? {}
+ devup.theme.length[themeName] = lengthForTheme
+
+ // Build responsive arrays from mode values
+ for (const variable of variables) {
+ if (variable === null || variable.resolvedType !== 'FLOAT') continue
+ const name = toCamel(variable.name)
+ const values: (null | string)[] = [null, null, null, null, null, null]
+ let hasAny = false
+
+ for (const mode of collection.modes) {
+ const level = modeNameToBreakpointLevel(mode.name)
+ const raw = variable.valuesByMode[mode.modeId]
+ let resolved = raw
+ if (isVariableAlias(raw)) {
+ const aliasResult = await variableAliasToValue(raw, mode.modeId)
+ resolved = aliasResult ?? raw
+ }
+ if (typeof resolved !== 'number') continue
+ const px = addPx(resolved)
+ if (!px) continue
+ values[level] = px
+ hasAny = true
+ }
+
+ if (hasAny) {
+ lengthForTheme[name] = optimizeResponsiveArray(values)
+ }
+ }
+
+ // Remove empty
+ if (Object.keys(lengthForTheme).length === 0) {
+ delete devup.theme.length[themeName]
+ }
+ if (Object.keys(devup.theme.length).length === 0) {
+ delete devup.theme.length
+ }
+ }
}
perfEnd('exportDevup.colors', tColors)
@@ -238,9 +355,62 @@ export async function buildDevupConfig(
)
}
+ // Export effect styles as shadow values with theme wrapper
+ const tShadow = perfStart()
+ const effectStyles = await figma.getLocalEffectStylesAsync()
+ if (effectStyles.length > 0) {
+ const shadowByKey: Record = {}
+ for (const style of effectStyles) {
+ const meta = styleNameToTypography(style.name)
+ let shadowValues = shadowByKey[meta.name]
+ if (!shadowValues) {
+ shadowValues = [null, null, null, null, null, null]
+ shadowByKey[meta.name] = shadowValues
+ }
+ if (!shadowValues[meta.level]) {
+ shadowValues[meta.level] = effectStyleToCssShadow(style)
+ }
+ }
+ if (Object.keys(shadowByKey).length > 0) {
+ devup.theme ??= {}
+ devup.theme.shadows ??= {}
+ const themeName = resolveThemeName(devup)
+ const shadowForTheme: Record = {}
+ devup.theme.shadows[themeName] = shadowForTheme
+
+ for (const [key, values] of Object.entries(shadowByKey)) {
+ const optimized = optimizeResponsiveArray(values)
+ if (optimized !== undefined) {
+ shadowForTheme[key] = optimized
+ }
+ }
+ }
+ }
+ perfEnd('exportDevup.shadow', tShadow)
+
return devup
}
+/**
+ * Convert a Figma effect style to a CSS shadow string.
+ */
+function effectStyleToCssShadow(style: EffectStyle): string | null {
+ const parts: string[] = []
+ for (const effect of style.effects) {
+ if (!effect.visible) continue
+ if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
+ const { offset, radius, color } = effect
+ const spread = 'spread' in effect ? (effect.spread ?? 0) : 0
+ const prefix = effect.type === 'INNER_SHADOW' ? 'inset ' : ''
+ const cssColor = optimizeHex(rgbaToHex(color))
+ parts.push(
+ `${prefix}${addPx(offset.x, '0')} ${addPx(offset.y, '0')} ${addPx(radius, '0')} ${addPx(spread, '0')} ${cssColor}`,
+ )
+ }
+ }
+ return parts.length > 0 ? parts.join(', ') : null
+}
+
export async function exportDevup(
output: 'json' | 'excel',
treeshaking: boolean = true,
diff --git a/src/commands/devup/import-devup.ts b/src/commands/devup/import-devup.ts
index 832fb0f..2c971db 100644
--- a/src/commands/devup/import-devup.ts
+++ b/src/commands/devup/import-devup.ts
@@ -8,7 +8,9 @@ import { uploadDevupXlsx } from './utils/upload-devup-xlsx'
export async function importDevup(input: 'json' | 'excel') {
const devup = await loadDevup(input)
await importColors(devup)
+ await importLength(devup)
await importTypography(devup)
+ await importShadow(devup)
}
async function loadDevup(input: 'json' | 'excel'): Promise {
@@ -81,6 +83,74 @@ async function importColors(devup: Devup) {
}
}
+const RESPONSIVE_MODE_NAMES = [
+ 'mobile',
+ '1',
+ 'tablet',
+ '3',
+ 'desktop',
+ '5',
+] as const
+
+async function importLength(devup: Devup) {
+ const length = devup.theme?.length
+ if (!length) return
+
+ const collection =
+ (await getDevupColorCollection()) ??
+ (await figma.variables.createVariableCollection('Devup Colors'))
+ const variables = await figma.variables.getLocalVariablesAsync()
+ const collectionVariableIds = new Set(collection.variableIds)
+ const variablesByName = new Map()
+ for (const variable of variables) {
+ if (
+ collectionVariableIds.has(variable.id) &&
+ variable.resolvedType === 'FLOAT' &&
+ !variablesByName.has(variable.name)
+ ) {
+ variablesByName.set(variable.name, variable)
+ }
+ }
+ const modeIdsByName = new Map(
+ collection.modes.map((mode) => [mode.name, mode.modeId] as const),
+ )
+
+ // Format: length[theme][varName] = string | (string | null)[]
+ for (const themeValues of Object.values(length)) {
+ for (const [varName, value] of Object.entries(themeValues)) {
+ let variable = variablesByName.get(varName)
+ if (!variable) {
+ variable = figma.variables.createVariable(varName, collection, 'FLOAT')
+ variablesByName.set(varName, variable)
+ }
+
+ if (typeof value === 'string') {
+ // Single value → set for first mode (mobile)
+ const modeName = RESPONSIVE_MODE_NAMES[0]
+ let modeId = modeIdsByName.get(modeName)
+ if (!modeId) {
+ modeId = collection.addMode(modeName)
+ modeIdsByName.set(modeName, modeId)
+ }
+ variable.setValueForMode(modeId, Number.parseFloat(value))
+ } else if (Array.isArray(value)) {
+ // Responsive array → set per breakpoint mode
+ for (let i = 0; i < value.length; i++) {
+ const v = value[i]
+ if (!v) continue
+ const modeName = RESPONSIVE_MODE_NAMES[i] ?? `${i}`
+ let modeId = modeIdsByName.get(modeName)
+ if (!modeId) {
+ modeId = collection.addMode(modeName)
+ modeIdsByName.set(modeName, modeId)
+ }
+ variable.setValueForMode(modeId, Number.parseFloat(v))
+ }
+ }
+ }
+ }
+}
+
async function importTypography(devup: Devup) {
const typography = devup.theme?.typography
if (!typography) return
@@ -94,3 +164,162 @@ async function importTypography(devup: Devup) {
}
}
}
+
+const SHADOW_PREFIX = ['mobile', '1', 'tablet', '3', 'desktop', '5'] as const
+
+async function importShadow(devup: Devup) {
+ const shadow = devup.theme?.shadows
+ if (!shadow) return
+
+ const styles = await figma.getLocalEffectStylesAsync()
+
+ // Format: shadow[theme][styleName] = string | (string | null)[]
+ for (const themeValues of Object.values(shadow)) {
+ for (const [name, value] of Object.entries(themeValues)) {
+ if (typeof value === 'string') {
+ await applyShadow(`mobile/${name}`, value, styles)
+ } else if (Array.isArray(value)) {
+ for (let i = 0; i < value.length; i++) {
+ const v = value[i]
+ if (!v) continue
+ const prefix = SHADOW_PREFIX[i] ?? `${i}`
+ await applyShadow(`${prefix}/${name}`, v, styles)
+ }
+ }
+ }
+ }
+}
+
+async function applyShadow(
+ target: string,
+ cssShadow: string,
+ styles: EffectStyle[],
+) {
+ const effects = parseCssShadow(cssShadow)
+ if (effects.length === 0) return
+
+ const st = styles.find((s) => s.name === target) ?? figma.createEffectStyle()
+ st.name = target
+ st.effects = effects
+}
+
+/**
+ * Parse a CSS box-shadow string into Figma Effect objects.
+ * Supports: [inset]
+ */
+function parseCssShadow(css: string): Effect[] {
+ // Split multiple shadows by comma (but not commas inside rgba/hsla)
+ const shadowParts = splitCssShadows(css)
+ const effects: Effect[] = []
+
+ for (const part of shadowParts) {
+ const trimmed = part.trim()
+ if (!trimmed) continue
+
+ const isInset = trimmed.startsWith('inset ')
+ const values = isInset ? trimmed.slice(6).trim() : trimmed
+
+ // Extract color (last token or rgba/hsla function)
+ const { lengths, color } = extractShadowParts(values)
+ if (lengths.length < 2) continue
+
+ const offsetX = parsePxValue(lengths[0])
+ const offsetY = parsePxValue(lengths[1])
+ const blurRadius = lengths.length > 2 ? parsePxValue(lengths[2]) : 0
+ const spreadRadius = lengths.length > 3 ? parsePxValue(lengths[3]) : 0
+
+ effects.push({
+ type: isInset ? 'INNER_SHADOW' : 'DROP_SHADOW',
+ visible: true,
+ blendMode: 'NORMAL',
+ color: parseColor(color),
+ offset: { x: offsetX, y: offsetY },
+ radius: blurRadius,
+ spread: spreadRadius,
+ showShadowBehindNode: false,
+ } as DropShadowEffect)
+ }
+
+ return effects
+}
+
+function splitCssShadows(css: string): string[] {
+ const parts: string[] = []
+ let depth = 0
+ let current = ''
+ for (const char of css) {
+ if (char === '(') depth++
+ else if (char === ')') depth--
+ else if (char === ',' && depth === 0) {
+ parts.push(current)
+ current = ''
+ continue
+ }
+ current += char
+ }
+ if (current) parts.push(current)
+ return parts
+}
+
+function extractShadowParts(values: string): {
+ lengths: string[]
+ color: string
+} {
+ // Match rgba/hsla functions or hex colors
+ const colorMatch = values.match(
+ /(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8})\s*$/,
+ )
+ if (colorMatch) {
+ const colorStr = colorMatch[1]
+ const lengthStr = values.slice(0, values.length - colorMatch[0].length)
+ return {
+ lengths: lengthStr.trim().split(/\s+/),
+ color: colorStr,
+ }
+ }
+ // Fallback: assume last token is color
+ const tokens = values.trim().split(/\s+/)
+ return {
+ lengths: tokens.slice(0, -1),
+ color: tokens[tokens.length - 1] || '#000',
+ }
+}
+
+function parsePxValue(value: string): number {
+ if (value === '0') return 0
+ return Number.parseFloat(value) || 0
+}
+
+function parseColor(color: string): RGBA {
+ // Handle hex
+ if (color.startsWith('#')) {
+ const hex = color.slice(1)
+ if (hex.length <= 4) {
+ // Short hex: #RGB or #RGBA
+ const r = Number.parseInt(hex[0] + hex[0], 16) / 255
+ const g = Number.parseInt(hex[1] + hex[1], 16) / 255
+ const b = Number.parseInt(hex[2] + hex[2], 16) / 255
+ const a =
+ hex.length === 4 ? Number.parseInt(hex[3] + hex[3], 16) / 255 : 1
+ return { r, g, b, a }
+ }
+ const r = Number.parseInt(hex.slice(0, 2), 16) / 255
+ const g = Number.parseInt(hex.slice(2, 4), 16) / 255
+ const b = Number.parseInt(hex.slice(4, 6), 16) / 255
+ const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1
+ return { r, g, b, a }
+ }
+ // Handle rgba(r, g, b, a)
+ const rgbaMatch = color.match(
+ /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/,
+ )
+ if (rgbaMatch) {
+ return {
+ r: Number.parseInt(rgbaMatch[1], 10) / 255,
+ g: Number.parseInt(rgbaMatch[2], 10) / 255,
+ b: Number.parseInt(rgbaMatch[3], 10) / 255,
+ a: rgbaMatch[4] ? Number.parseFloat(rgbaMatch[4]) : 1,
+ }
+ }
+ return { r: 0, g: 0, b: 0, a: 1 }
+}
diff --git a/src/commands/devup/types.ts b/src/commands/devup/types.ts
index 034b3ec..d941650 100644
--- a/src/commands/devup/types.ts
+++ b/src/commands/devup/types.ts
@@ -12,6 +12,8 @@ type Theme = string
export interface DevupTheme {
colors?: Record>
typography?: Record
+ length?: Record>
+ shadows?: Record>
}
export interface Devup {
theme?: DevupTheme
diff --git a/tsconfig.json b/tsconfig.json
index 76793cf..f2b5edf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,7 +7,6 @@
"module": "ESNext",
"outDir": "./dist",
"isolatedModules": true,
- "moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true
},